aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/overlays.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell/overlays.rs')
-rw-r--r--src/client/shell/overlays.rs1169
1 files changed, 1169 insertions, 0 deletions
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
new file mode 100644
index 00000000..30ac78b9
--- /dev/null
+++ b/src/client/shell/overlays.rs
@@ -0,0 +1,1169 @@
+use super::*;
+
+mod settings_overlay;
+mod worktree_overlays;
+
+#[derive(Default)]
+pub(crate) struct OverlayRender {
+ pub(crate) primary: Rect,
+ pub(crate) clear: Rect,
+ pub(crate) cancel: Rect,
+ pub(crate) navigator_popup: Rect,
+ pub(crate) navigator_search: Rect,
+ pub(crate) navigator_rows: Vec<(Rect, usize)>,
+ pub(crate) worktree_search: Rect,
+ pub(crate) worktree_rows: Vec<(Rect, usize)>,
+ pub(crate) help_popup: Rect,
+ pub(crate) help_scrollbar: Rect,
+ pub(crate) help_scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ pub(crate) help_max_scroll: usize,
+ pub(crate) settings_popup: Rect,
+ pub(crate) settings_tabs: Vec<(Rect, ClientSettingsSection)>,
+ pub(crate) settings_choices: Vec<(Rect, usize)>,
+ pub(crate) product_announcement_scrollbar: Rect,
+ pub(crate) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ pub(crate) product_announcement_max_scroll: usize,
+ pub(crate) release_notes_scrollbar: Rect,
+ pub(crate) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ pub(crate) release_notes_max_scroll: usize,
+ pub(crate) cursor: Option<crate::protocol::CursorState>,
+}
+
+pub(crate) fn render_client_overlay(
+ b: &mut Buffer,
+ o: &ClientShellOverlay,
+ s: &ClientShellSnapshot,
+ k: &LiveKeybindConfig,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ if !matches!(
+ o,
+ ClientShellOverlay::Navigator(_)
+ | ClientShellOverlay::ContextMenu(_)
+ | ClientShellOverlay::GlobalMenu(_)
+ ) {
+ for y in b.area.y..b.area.bottom() {
+ for x in b.area.x..b.area.right() {
+ let c = &mut b[(x, y)];
+ c.set_style(c.style().add_modifier(Modifier::DIM));
+ }
+ }
+ }
+ match o {
+ ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p),
+ ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p),
+ ClientShellOverlay::ReleaseNotes(v) => {
+ render_release_notes_overlay(b, v, &s.update_install_command, p)
+ }
+ ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p),
+ ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p),
+ ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p),
+ ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p),
+ ClientShellOverlay::Settings(v) => {
+ settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p)
+ }
+ ClientShellOverlay::WorktreeCreate(v) => {
+ worktree_overlays::render_worktree_create_overlay(b, v, p)
+ }
+ ClientShellOverlay::WorktreeOpen(v) => {
+ worktree_overlays::render_worktree_open_overlay(b, v, p)
+ }
+ ClientShellOverlay::WorktreeRemove(v) => {
+ worktree_overlays::render_worktree_remove_overlay(b, v, p)
+ }
+ ClientShellOverlay::ContextMenu(_) | ClientShellOverlay::GlobalMenu(_) => None,
+ }
+}
+
+pub(crate) fn render_global_menu(
+ buffer: &mut Buffer,
+ launcher: Rect,
+ menu: &ClientGlobalMenuOverlay,
+ snapshot: &ClientShellSnapshot,
+ palette: &Palette,
+) -> Option<Vec<(Rect, usize)>> {
+ let items = super::super::global_menu::global_menu_items(snapshot);
+ let screen = buffer.area;
+ let width = items
+ .iter()
+ .map(|(label, action)| {
+ display_width(label)
+ + u16::from(super::super::global_menu::global_menu_item_has_badge(
+ snapshot, *action,
+ )) * 2
+ })
+ .max()
+ .unwrap_or(8)
+ .saturating_add(4)
+ .min(screen.width.max(1));
+ let height = (items.len() as u16)
+ .saturating_add(2)
+ .min(screen.height.max(1));
+ let x = launcher
+ .right()
+ .saturating_sub(width)
+ .min(screen.right().saturating_sub(width));
+ let y = launcher.y.saturating_sub(height).max(screen.y);
+ let inner = panel(
+ buffer,
+ Rect::new(x, y, width, height),
+ palette.accent,
+ palette.panel_bg,
+ )?;
+ let mut rows = Vec::new();
+ for (index, (label, action)) in items.iter().enumerate() {
+ let row_y = inner.y.saturating_add(index as u16);
+ if row_y >= inner.bottom() {
+ break;
+ }
+ let row = Rect::new(inner.x, row_y, inner.width, 1);
+ let highlighted = index == menu.highlighted;
+ let style = if highlighted {
+ Style::default()
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(palette.text).bg(palette.panel_bg)
+ };
+ buffer.set_style(row, style);
+ let has_badge = super::super::global_menu::global_menu_item_has_badge(snapshot, *action);
+ if has_badge {
+ let badge_style = if highlighted {
+ style
+ } else {
+ Style::default()
+ .fg(palette.accent)
+ .bg(palette.panel_bg)
+ .add_modifier(Modifier::BOLD)
+ };
+ put_text(buffer, row.x, row.y, row.width.min(2), " ●", badge_style);
+ put_text(
+ buffer,
+ row.x.saturating_add(2),
+ row.y,
+ row.width.saturating_sub(2),
+ &format!(" {label}"),
+ style,
+ );
+ } else {
+ put_text(buffer, row.x, row.y, row.width, &format!(" {label}"), style);
+ }
+ rows.push((row, index));
+ }
+ Some(rows)
+}
+
+pub(crate) fn render_context_menu(
+ buffer: &mut Buffer,
+ menu: &ClientContextMenuOverlay,
+ palette: &Palette,
+) -> Option<Vec<(Rect, usize)>> {
+ let items = menu.items();
+ let screen = buffer.area;
+ let max_item_width = items
+ .iter()
+ .map(|item| display_width(item.label))
+ .max()
+ .unwrap_or(0);
+ let width = max_item_width
+ .saturating_add(4)
+ .max(14)
+ .min(screen.width.max(1));
+ let height = (items.len() as u16)
+ .saturating_add(2)
+ .min(screen.height.max(1));
+ let x = menu
+ .x
+ .min(screen.x.saturating_add(screen.width.saturating_sub(width)));
+ let y = menu.y.min(
+ screen
+ .y
+ .saturating_add(screen.height.saturating_sub(height)),
+ );
+ let rect = Rect::new(x, y, width, height);
+ let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?;
+ let mut rows = Vec::new();
+ for (index, item) in items.iter().enumerate() {
+ let row_y = inner.y.saturating_add(index as u16);
+ if row_y >= inner.bottom() {
+ break;
+ }
+ let row = Rect::new(inner.x, row_y, inner.width, 1);
+ let highlighted = index == menu.highlighted;
+ let style = if highlighted {
+ Style::default()
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(palette.text).bg(palette.panel_bg)
+ };
+ buffer.set_style(row, style);
+ put_text(buffer, row.x, row.y, row.width, item.label, style);
+ rows.push((row, index));
+ }
+ Some(rows)
+}
+
+fn panel(
+ b: &mut Buffer,
+ a: Rect,
+ c: ratatui::style::Color,
+ bg: ratatui::style::Color,
+) -> Option<Rect> {
+ if a.width < 2 || a.height < 2 {
+ return None;
+ }
+ let background = Style::default().bg(bg).remove_modifier(Modifier::DIM);
+ let border = Style::default().fg(c).bg(bg).remove_modifier(Modifier::DIM);
+ for y in a.y..a.bottom() {
+ for x in a.x..a.right() {
+ b[(x, y)].set_symbol(" ").set_style(background);
+ }
+ }
+ for x in a.x..a.right() {
+ b[(x, a.y)]
+ .set_symbol(if x == a.x {
+ "┌"
+ } else if x + 1 == a.right() {
+ "┐"
+ } else {
+ "─"
+ })
+ .set_style(border);
+ let y = a.bottom() - 1;
+ b[(x, y)]
+ .set_symbol(if x == a.x {
+ "└"
+ } else if x + 1 == a.right() {
+ "┘"
+ } else {
+ "─"
+ })
+ .set_style(border);
+ }
+ for y in a.y + 1..a.bottom() - 1 {
+ b[(a.x, y)].set_symbol("│").set_style(border);
+ b[(a.right() - 1, y)].set_symbol("│").set_style(border);
+ }
+ Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2))
+}
+fn popup(a: Rect, w: u16, h: u16) -> Option<Rect> {
+ let w = w.min(a.width.saturating_sub(4));
+ let h = h.min(a.height.saturating_sub(2));
+ if w < 4 || h < 4 {
+ return None;
+ }
+ Some(Rect::new(
+ a.x + (a.width - w) / 2,
+ a.y + (a.height - h) / 2,
+ w,
+ h,
+ ))
+}
+fn button(b: &mut Buffer, r: Rect, t: &str, s: Style) {
+ b.set_style(r, s);
+ let w = display_width(t).min(r.width);
+ put_text(b, r.x + (r.width - w) / 2, r.y, w, t, s)
+}
+fn row(i: Rect, ws: &[u16], gap: u16, off: u16) -> Vec<Rect> {
+ let total = ws.iter().sum::<u16>() + gap * (ws.len().saturating_sub(1) as u16);
+ let mut x = i.x + i.width.saturating_sub(total) / 2;
+ ws.iter()
+ .map(|w| {
+ let r = Rect::new(
+ x,
+ i.y + off.min(i.height.saturating_sub(1)),
+ (*w).min(i.width.saturating_sub(x - i.x)),
+ 1,
+ );
+ x += *w + gap;
+ r
+ })
+ .collect()
+}
+fn contrast(p: &Palette) -> ratatui::style::Color {
+ match p.panel_bg {
+ ratatui::style::Color::Reset => p.surface_dim,
+ c => c,
+ }
+}
+fn render_release_notes_overlay(
+ b: &mut Buffer,
+ notes: &crate::app::state::ReleaseNotesState,
+ install_command: &str,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ let outer = popup(
+ b.area,
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.0,
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.1,
+ )?;
+ let inner = panel(b, outer, p.accent, p.panel_bg)?;
+ if inner.height < 8 || inner.width < 20 {
+ return Some(OverlayRender::default());
+ }
+
+ let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1);
+ let title_area = Rect::new(
+ stack.header.x.saturating_add(1),
+ stack.header.y,
+ stack.header.width.saturating_sub(2),
+ 1,
+ );
+ let subtitle_area = Rect::new(
+ stack.header.x.saturating_add(1),
+ stack.header.y.saturating_add(1),
+ stack.header.width.saturating_sub(2),
+ 1,
+ );
+ let base = Style::default()
+ .bg(p.panel_bg)
+ .remove_modifier(Modifier::DIM);
+ put_text(
+ b,
+ title_area.x,
+ title_area.y,
+ title_area.width,
+ &format!("v{}", notes.version),
+ base.fg(p.text).add_modifier(Modifier::BOLD),
+ );
+ put_text(
+ b,
+ subtitle_area.x,
+ subtitle_area.y,
+ subtitle_area.width,
+ if notes.preview {
+ "update ready"
+ } else {
+ "what's new in this release"
+ },
+ base.fg(p.overlay1),
+ );
+ let close = crate::ui::release_notes_close_button_rect(Rect::new(
+ stack.header.x,
+ stack.header.y,
+ stack.header.width,
+ 1,
+ ));
+ button(
+ b,
+ close,
+ " esc close ",
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ .remove_modifier(Modifier::DIM),
+ );
+
+ let body = stack.content;
+ let lines = crate::ui::release_notes_display_lines(notes, install_command, p);
+ let metrics = crate::ui::release_notes_scroll_metrics(notes, install_command, body, p);
+ let max_scroll = metrics.max_offset_from_bottom;
+ let scroll = usize::from(notes.scroll).min(max_scroll);
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics);
+ let text_area = track
+ .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height))
+ .unwrap_or(body);
+ let paragraph = ratatui::widgets::Paragraph::new(
+ lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(),
+ )
+ .wrap(ratatui::widgets::Wrap { trim: false })
+ .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
+ ratatui::widgets::Widget::render(paragraph, text_area, b);
+ if let Some(track) = track {
+ crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐");
+ }
+
+ if let Some(footer_area) = stack.footer {
+ let footer_line = ratatui::text::Line::from(vec![
+ ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)),
+ ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)),
+ ratatui::text::Span::styled(" · ", base.fg(p.overlay0)),
+ ratatui::text::Span::styled("close", base.fg(p.overlay0)),
+ ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)),
+ ]);
+ ratatui::widgets::Widget::render(
+ ratatui::widgets::Paragraph::new(footer_line),
+ footer_area,
+ b,
+ );
+ }
+
+ Some(OverlayRender {
+ primary: close,
+ release_notes_scrollbar: track.unwrap_or_default(),
+ release_notes_scroll_metrics: Some(metrics),
+ release_notes_max_scroll: max_scroll,
+ ..OverlayRender::default()
+ })
+}
+
+fn render_product_announcement_overlay(
+ b: &mut Buffer,
+ announcement: &crate::app::state::ProductAnnouncementState,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ let outer = popup(
+ b.area,
+ crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
+ crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
+ )?;
+ let inner = panel(b, outer, p.accent, p.panel_bg)?;
+ if inner.height < 8 || inner.width < 20 {
+ return Some(OverlayRender::default());
+ }
+
+ let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1);
+ let title_area = Rect::new(
+ stack.header.x.saturating_add(1),
+ stack.header.y,
+ stack.header.width.saturating_sub(2),
+ 1,
+ );
+ let subtitle_area = Rect::new(
+ stack.header.x.saturating_add(1),
+ stack.header.y.saturating_add(1),
+ stack.header.width.saturating_sub(2),
+ 1,
+ );
+ let base = Style::default()
+ .bg(p.panel_bg)
+ .remove_modifier(Modifier::DIM);
+ put_text(
+ b,
+ title_area.x,
+ title_area.y,
+ title_area.width,
+ &announcement.title,
+ base.fg(p.text).add_modifier(Modifier::BOLD),
+ );
+ let subtitle = if announcement.preview {
+ "product announcement preview"
+ } else {
+ "product announcement"
+ };
+ put_text(
+ b,
+ subtitle_area.x,
+ subtitle_area.y,
+ subtitle_area.width,
+ &format!("{subtitle} · v{}", announcement.version),
+ base.fg(p.overlay1),
+ );
+ let close = crate::ui::release_notes_close_button_rect(Rect::new(
+ stack.header.x,
+ stack.header.y,
+ stack.header.width,
+ 1,
+ ));
+ button(
+ b,
+ close,
+ " esc close ",
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ .remove_modifier(Modifier::DIM),
+ );
+
+ let body = stack.content;
+ let lines = crate::ui::product_announcement_display_lines(announcement, p);
+ let metrics = crate::ui::product_announcement_scroll_metrics(announcement, body, p);
+ let max_scroll = metrics.max_offset_from_bottom;
+ let scroll = usize::from(announcement.scroll).min(max_scroll);
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics);
+ let text_area = track
+ .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height))
+ .unwrap_or(body);
+ let paragraph = ratatui::widgets::Paragraph::new(
+ lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(),
+ )
+ .wrap(ratatui::widgets::Wrap { trim: false })
+ .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
+ ratatui::widgets::Widget::render(paragraph, text_area, b);
+ if let Some(track) = track {
+ crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐");
+ }
+
+ if let Some(footer_area) = stack.footer {
+ let footer_line = ratatui::text::Line::from(vec![
+ ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)),
+ ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)),
+ ratatui::text::Span::styled(" · ", base.fg(p.overlay0)),
+ ratatui::text::Span::styled("close", base.fg(p.overlay0)),
+ ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)),
+ ]);
+ ratatui::widgets::Widget::render(
+ ratatui::widgets::Paragraph::new(footer_line),
+ footer_area,
+ b,
+ );
+ }
+
+ Some(OverlayRender {
+ primary: close,
+ product_announcement_scrollbar: track.unwrap_or_default(),
+ product_announcement_scroll_metrics: Some(metrics),
+ product_announcement_max_scroll: max_scroll,
+ ..OverlayRender::default()
+ })
+}
+
+fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRender> {
+ let outer = popup(b.area, 64, 16)?;
+ let inner = panel(b, outer, p.accent, p.panel_bg)?;
+ if inner.height < 11 {
+ return Some(OverlayRender::default());
+ }
+ let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1);
+ let base = Style::default()
+ .bg(p.panel_bg)
+ .remove_modifier(Modifier::DIM);
+ let title = base.fg(p.text).add_modifier(Modifier::BOLD);
+ let muted = base.fg(p.overlay0);
+ let text = base.fg(p.overlay1);
+ let accent = base.fg(p.accent).add_modifier(Modifier::BOLD);
+
+ put_text(
+ b,
+ stack.header.x,
+ stack.header.y,
+ stack.header.width,
+ crate::ui::ONBOARDING_TITLE,
+ title,
+ );
+ put_text(
+ b,
+ stack.header.x,
+ stack.header.y.saturating_add(1),
+ stack.header.width,
+ crate::ui::ONBOARDING_SUBTITLE,
+ muted,
+ );
+
+ let content = stack.content;
+ for (offset, line) in crate::ui::ONBOARDING_DESCRIPTION.iter().enumerate() {
+ put_text(
+ b,
+ content.x,
+ content.y.saturating_add(offset as u16),
+ content.width,
+ line,
+ text,
+ );
+ }
+
+ let key_y = content.y.saturating_add(4);
+ let mut key_x = content.x;
+ for (value, style) in [
+ (" ", base),
+ (crate::ui::ONBOARDING_PREFIX_LABEL, accent),
+ (crate::ui::ONBOARDING_PREFIX_SUFFIX, text),
+ (crate::ui::ONBOARDING_HELP_LABEL, accent),
+ (crate::ui::ONBOARDING_HELP_SUFFIX, text),
+ ] {
+ let width = display_width(value);
+ put_text(
+ b,
+ key_x,
+ key_y,
+ content.right().saturating_sub(key_x),
+ value,
+ style,
+ );
+ key_x = key_x.saturating_add(width);
+ }
+ put_text(
+ b,
+ content.x,
+ content.y.saturating_add(5),
+ content.width,
+ crate::ui::ONBOARDING_NEXT,
+ text,
+ );
+
+ let primary = crate::ui::onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
+ button(
+ b,
+ primary,
+ " ↵ continue ",
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ .remove_modifier(Modifier::DIM),
+ );
+ Some(OverlayRender {
+ primary,
+ ..OverlayRender::default()
+ })
+}
+
+fn render_rename_overlay(
+ b: &mut Buffer,
+ v: &ClientRenameOverlay,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ let q = popup(b.area, 56, 7)?;
+ let i = panel(b, q, p.accent, p.panel_bg)?;
+ put_text(
+ b,
+ i.x,
+ i.y,
+ i.width,
+ v.title,
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ );
+ let input = Rect::new(i.x, i.y + 2, i.width, 1);
+ b.set_style(input, Style::default().fg(p.text).bg(p.surface0));
+ put_text(
+ b,
+ input.x,
+ input.y,
+ input.width.saturating_sub(1),
+ &format!(" {}", v.input),
+ Style::default().fg(p.text).bg(p.surface0),
+ );
+ let rs = row(i, &[8, 10, 12], 2, 3);
+ let [save, clear, cancel] = rs.as_slice() else {
+ return None;
+ };
+ button(
+ b,
+ *save,
+ " ↵ save ",
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ let n = Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD);
+ button(b, *clear, " ^c clear ", n);
+ button(b, *cancel, " esc cancel ", n);
+ Some(OverlayRender {
+ primary: *save,
+ clear: *clear,
+ cancel: *cancel,
+ navigator_popup: Rect::default(),
+ navigator_search: Rect::default(),
+ navigator_rows: Vec::new(),
+ worktree_search: Rect::default(),
+ worktree_rows: Vec::new(),
+ cursor: Some(crate::protocol::CursorState {
+ x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1),
+ y: input.y,
+ visible: true,
+ shape: 0,
+ }),
+ ..OverlayRender::default()
+ })
+}
+
+pub(crate) fn client_navigator_rows(
+ s: &ClientShellSnapshot,
+ n: &ClientNavigatorOverlay,
+) -> Vec<ClientNavigatorRow> {
+ let q = n.query.trim().to_lowercase();
+ let filter = |st| match n.filter {
+ Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked,
+ Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working,
+ Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle,
+ Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done,
+ None => true,
+ };
+ let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q);
+ let filtering = n.filter.is_some() || !q.is_empty();
+ let mut out = Vec::new();
+ for w in &s.workspaces {
+ let mut children = Vec::new();
+ for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) {
+ let mut panes = Vec::new();
+ for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() {
+ let a = s.agents.iter().find(|a| a.pane_id == p.pane_id);
+ let st = a
+ .map(|a| a.agent_status)
+ .unwrap_or(crate::api::schema::AgentStatus::Unknown);
+ let label = p
+ .label
+ .clone()
+ .or_else(|| a.and_then(|a| a.name.clone()))
+ .or_else(|| a.and_then(|a| a.display_agent.clone()))
+ .or_else(|| a.and_then(|a| a.title.clone()))
+ .unwrap_or_else(|| format!("pane {}", ix + 1));
+ let meta = p
+ .foreground_cwd
+ .clone()
+ .or_else(|| p.cwd.clone())
+ .unwrap_or_default();
+ if !filtering || filter(st) && (text(&label) || text(&meta)) {
+ panes.push(ClientNavigatorRow {
+ depth: 2,
+ label,
+ meta,
+ status: st,
+ current: s.focused_pane_id.as_deref() == Some(&p.pane_id),
+ target: ClientNavigatorTarget::Pane(p.pane_id.clone()),
+ })
+ }
+ }
+ if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() {
+ children.push(ClientNavigatorRow {
+ depth: 1,
+ label: t.label.clone(),
+ meta: format!(
+ "{} panes",
+ s.panes.iter().filter(|p| p.tab_id == t.tab_id).count()
+ ),
+ status: t.agent_status,
+ current: s.focused_tab_id.as_deref() == Some(&t.tab_id),
+ target: ClientNavigatorTarget::Tab(t.tab_id.clone()),
+ });
+ children.extend(panes)
+ }
+ }
+ let wm =
+ filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text));
+ if !filtering || wm || !children.is_empty() {
+ out.push(ClientNavigatorRow {
+ depth: 0,
+ label: w.label.clone(),
+ meta: w.branch.clone().unwrap_or_default(),
+ status: w.agent_status,
+ current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id),
+ target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()),
+ });
+ if n.expanded_workspaces.contains(&w.workspace_id) || filtering {
+ out.extend(children)
+ }
+ }
+ }
+ out
+}
+
+fn render_navigator_overlay(
+ b: &mut Buffer,
+ n: &ClientNavigatorOverlay,
+ s: &ClientShellSnapshot,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ let a = b.area;
+ let mx = (a.width / 16).max(2);
+ let my = (a.height / 10).max(1);
+ let q = Rect::new(
+ a.x + mx,
+ a.y + my,
+ a.width.saturating_sub(mx * 2).max(4),
+ a.height.saturating_sub(my * 2).max(4),
+ );
+ let i = panel(b, q, p.accent, p.panel_bg)?;
+ let rows = client_navigator_rows(s, n);
+ let search = if n.search_focused {
+ format!(" / {}", n.query)
+ } else if let Some(f) = n.filter {
+ format!(
+ " / {}",
+ match f {
+ ClientNavigatorFilter::Blocked => "blocked",
+ ClientNavigatorFilter::Working => "working",
+ ClientNavigatorFilter::Idle => "idle",
+ ClientNavigatorFilter::Done => "done",
+ }
+ )
+ } else if n.query.is_empty() {
+ " / search panes".to_owned()
+ } else {
+ format!(" / {}", n.query)
+ };
+ put_text(
+ b,
+ i.x,
+ i.y,
+ i.width,
+ &search,
+ Style::default()
+ .fg(if n.search_focused { p.text } else { p.overlay0 })
+ .bg(p.panel_bg),
+ );
+ put_right_text(
+ b,
+ i,
+ i.y,
+ &format!("{} panes", s.panes.len()),
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ );
+ put_text(
+ b,
+ i.x,
+ i.y + 1,
+ i.width,
+ &"─".repeat(i.width as usize),
+ Style::default().fg(p.surface1).bg(p.panel_bg),
+ );
+ let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4));
+ let max = rows.len().saturating_sub(body.height as usize);
+ let scroll = n
+ .scroll
+ .max(
+ n.selected
+ .saturating_sub(body.height.saturating_sub(1) as usize),
+ )
+ .min(n.selected)
+ .min(max);
+ let mut row_hits = Vec::new();
+ for (vis, (ix, r)) in rows
+ .iter()
+ .enumerate()
+ .skip(scroll)
+ .take(body.height as usize)
+ .enumerate()
+ {
+ let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1);
+ row_hits.push((rect, ix));
+ let st = if ix == n.selected {
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ .fg(if r.current { p.text } else { p.subtext0 })
+ .bg(p.panel_bg)
+ };
+ b.set_style(rect, st);
+ let tree = match r.depth {
+ 0 => match &r.target {
+ ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾",
+ _ => "▸",
+ },
+ 1 => "└──",
+ _ => " └──",
+ };
+ let label = format!(
+ " {} {} {} {}",
+ if r.current { "◆" } else { " " },
+ tree,
+ status_dot(r.status),
+ r.label
+ );
+ put_text(b, rect.x, rect.y, rect.width, &label, st);
+ if !r.meta.is_empty() {
+ let label_width = display_width(&label).min(rect.width);
+ let meta = Rect::new(
+ rect.x.saturating_add(label_width).saturating_add(1),
+ rect.y,
+ rect.width.saturating_sub(label_width.saturating_add(1)),
+ 1,
+ );
+ put_right_text(b, meta, rect.y, &r.meta, st)
+ }
+ }
+ let dy = i.bottom() - 2;
+ if let Some(r) = rows.get(n.selected) {
+ put_text(
+ b,
+ i.x,
+ dy,
+ i.width,
+ &format!(" {} · {}", r.label, r.meta),
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ )
+ }
+ put_text(
+ b,
+ i.x,
+ i.bottom() - 1,
+ i.width,
+ if n.search_focused {
+ " search type · move ↑↓/ctrl+n/p · open enter · back esc"
+ } else {
+ " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc"
+ },
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ );
+ Some(OverlayRender {
+ primary: Rect::default(),
+ clear: Rect::default(),
+ cancel: Rect::default(),
+ navigator_popup: q,
+ navigator_search: Rect::new(i.x, i.y, i.width, 1),
+ navigator_rows: row_hits,
+ worktree_search: Rect::default(),
+ worktree_rows: Vec::new(),
+ cursor: n.search_focused.then(|| crate::protocol::CursorState {
+ x: i.x + 3 + display_width(&n.query),
+ y: i.y,
+ visible: true,
+ shape: 0,
+ }),
+ ..OverlayRender::default()
+ })
+}
+
+fn help_lines(
+ keybinds: &LiveKeybindConfig,
+ query: &str,
+ palette: &Palette,
+) -> Vec<(usize, ratatui::text::Line<'static>)> {
+ use ratatui::text::{Line, Span};
+
+ let groups = crate::input::filter_keybind_help_groups(
+ crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix),
+ query,
+ );
+ let key_width = groups
+ .iter()
+ .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
+ .max()
+ .unwrap_or(8);
+ if groups.is_empty() {
+ let message = " no matching keybinds";
+ return vec![(
+ message.chars().count(),
+ Line::from(Span::styled(
+ message,
+ Style::default().fg(palette.overlay1).bg(palette.panel_bg),
+ )),
+ )];
+ }
+
+ let mut lines = Vec::new();
+ for (group, entries) in groups {
+ lines.push((
+ group.len() + 1,
+ Line::from(Span::styled(
+ format!(" {group}"),
+ Style::default()
+ .fg(palette.accent)
+ .bg(palette.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ )),
+ ));
+ for (key, label) in entries {
+ let padded_key = format!(" {key:<key_width$} ");
+ let width = padded_key.chars().count() + label.chars().count();
+ lines.push((
+ width,
+ Line::from(vec![
+ Span::styled(
+ padded_key,
+ Style::default()
+ .fg(palette.mauve)
+ .bg(palette.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ label.into_owned(),
+ Style::default().fg(palette.text).bg(palette.panel_bg),
+ ),
+ ]),
+ ));
+ }
+ lines.push((0, Line::raw("")));
+ }
+ lines
+}
+
+fn render_help_overlay(
+ b: &mut Buffer,
+ h: &ClientHelpOverlay,
+ k: &LiveKeybindConfig,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ use ratatui::widgets::{Paragraph, Widget, Wrap};
+
+ let q = popup(b.area, 76, 22)?;
+ let i = panel(b, q, p.accent, p.panel_bg)?;
+ if i.width < 20 || i.height < 6 {
+ return None;
+ }
+ put_text(
+ b,
+ i.x,
+ i.y,
+ i.width,
+ "keybinds",
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ );
+ let close = Rect::new(i.right() - 13, i.y, 13, 1);
+ button(
+ b,
+ close,
+ if h.search_focused {
+ " esc back "
+ } else {
+ " esc close "
+ },
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ let sy = i.y + 1;
+ put_text(
+ b,
+ i.x,
+ sy,
+ i.width,
+ &if h.search_focused {
+ format!(" / {}", h.query)
+ } else {
+ " / press / to filter by command or shortcut".to_owned()
+ },
+ Style::default()
+ .fg(if h.search_focused { p.text } else { p.overlay0 })
+ .bg(p.panel_bg),
+ );
+
+ let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5));
+ let lines = help_lines(k, &h.query, p);
+ let viewport_rows = usize::from(body.height.max(1));
+ let wrapped_rows = |width: u16| {
+ let width = usize::from(width.max(1));
+ lines
+ .iter()
+ .map(|(line_width, _)| line_width.max(&1).div_ceil(width))
+ .sum::<usize>()
+ };
+ let needs_scrollbar = wrapped_rows(body.width) > viewport_rows;
+ let text_area = if needs_scrollbar {
+ Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)
+ } else {
+ body
+ };
+ let total_rows = wrapped_rows(text_area.width);
+ let max_scroll = total_rows.saturating_sub(viewport_rows);
+ let scroll = h.scroll.min(max_scroll);
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: max_scroll.saturating_sub(scroll),
+ max_offset_from_bottom: max_scroll,
+ viewport_rows,
+ };
+ let scrollbar = needs_scrollbar.then_some(Rect::new(
+ body.right().saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ));
+ Widget::render(
+ Paragraph::new(lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>())
+ .wrap(Wrap { trim: false })
+ .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)),
+ text_area,
+ b,
+ );
+ if let Some(track) = scrollbar {
+ if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) {
+ for y in track.y..track.bottom() {
+ b[(track.x, y)]
+ .set_symbol("▐")
+ .set_style(Style::default().fg(p.overlay0).bg(p.panel_bg));
+ }
+ for y in thumb.top..thumb.top.saturating_add(thumb.len) {
+ b[(track.x, y)]
+ .set_symbol("▐")
+ .set_style(Style::default().fg(p.overlay1).bg(p.panel_bg));
+ }
+ }
+ }
+
+ put_text(
+ b,
+ i.x,
+ i.bottom() - 1,
+ i.width,
+ if h.search_focused {
+ " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc"
+ } else {
+ " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter"
+ },
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ );
+ Some(OverlayRender {
+ cancel: close,
+ help_popup: q,
+ help_scrollbar: scrollbar.unwrap_or_default(),
+ help_scroll_metrics: Some(metrics),
+ help_max_scroll: max_scroll,
+ cursor: h.search_focused.then(|| crate::protocol::CursorState {
+ x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1),
+ y: sy,
+ visible: true,
+ shape: 0,
+ }),
+ ..OverlayRender::default()
+ })
+}
+fn render_confirm_close_overlay(
+ b: &mut Buffer,
+ c: &ClientConfirmCloseOverlay,
+ p: &Palette,
+) -> Option<OverlayRender> {
+ let q = popup(b.area, 64, 6)?;
+ let i = panel(b, q, p.red, p.panel_bg)?;
+ put_text(
+ b,
+ i.x,
+ i.y,
+ i.width,
+ &format!(" {}", c.title),
+ Style::default()
+ .fg(p.red)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ );
+ put_text(
+ b,
+ i.x,
+ i.y + 1,
+ i.width,
+ &format!(" {}", c.detail),
+ Style::default().fg(p.text).bg(p.panel_bg),
+ );
+ let rs = row(i, &[13, 12], 2, 3);
+ let [ok, cancel] = rs.as_slice() else {
+ return None;
+ };
+ button(
+ b,
+ *ok,
+ " ↵ confirm ",
+ Style::default()
+ .fg(contrast(p))
+ .bg(p.red)
+ .add_modifier(Modifier::BOLD),
+ );
+ button(
+ b,
+ *cancel,
+ " esc cancel ",
+ Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+ Some(OverlayRender {
+ primary: *ok,
+ clear: Rect::default(),
+ cancel: *cancel,
+ navigator_popup: Rect::default(),
+ navigator_search: Rect::default(),
+ navigator_rows: Vec::new(),
+ worktree_search: Rect::default(),
+ worktree_rows: Vec::new(),
+ cursor: None,
+ ..OverlayRender::default()
+ })
+}