diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/ui/navigator.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/ui/navigator.rs')
| -rw-r--r-- | src/ui/navigator.rs | 643 |
1 files changed, 0 insertions, 643 deletions
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs deleted file mode 100644 index cb2f235e..00000000 --- a/src/ui/navigator.rs +++ /dev/null @@ -1,643 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::{ - scrollbar::{render_scrollbar, should_show_scrollbar}, - status::{state_icon, state_label_color}, - text::{display_width_u16, middle_elide, truncate_end}, - widgets::{panel_contrast_fg, render_panel_shell}, -}; -use crate::app::state::{ - navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter, - NavigatorTarget, -}; -use crate::terminal::TerminalRuntimeRegistry; - -pub(super) fn render_navigator_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let popup = app.navigator_popup_rect(); - let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let search = app.navigator_search_rect(); - let body = app.navigator_body_rect(); - let detail = app.navigator_detail_rect(); - let footer = app.navigator_footer_rect(); - render_search(app, frame, search); - - if body.height > 0 { - let rows = app.navigator_rows_from(terminal_runtimes); - let lines = navigator_display_lines(&rows); - render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); - render_rows(app, &rows, &lines, frame, body); - render_navigator_scrollbar(app, lines.len(), frame, body); - } - render_detail(app, terminal_runtimes, frame, detail); - render_footer(app, frame, footer); -} - -fn render_search(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let focus_style = if app.navigator.search_focused { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - let count = app - .workspaces - .iter() - .flat_map(|workspace| workspace.tabs.iter()) - .map(|tab| tab.panes.len()) - .sum::<usize>(); - let mut spans = vec![Span::styled(" / ", focus_style)]; - let query = app.navigator.query.trim(); - match app.navigator.state_filter { - Some(NavigatorStateFilter::Blocked) => push_state_chip( - &mut spans, - crate::detect::AgentState::Blocked, - true, - "blocked", - app, - ), - Some(NavigatorStateFilter::Working) => push_state_chip( - &mut spans, - crate::detect::AgentState::Working, - true, - "working", - app, - ), - Some(NavigatorStateFilter::Idle) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - true, - "idle", - app, - ), - Some(NavigatorStateFilter::Done) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - false, - "done", - app, - ), - None if query.is_empty() => spans.push(Span::styled( - "search panes", - Style::default().fg(p.overlay0), - )), - None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))), - } - spans.push(Span::styled( - format!( - "{count:>width$} panes", - width = area.width.saturating_sub(16) as usize - ), - Style::default().fg(p.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn push_state_chip( - spans: &mut Vec<Span<'static>>, - state: crate::detect::AgentState, - seen: bool, - label: &'static str, - app: &AppState, -) { - let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette); - spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - label, - Style::default() - .fg(state_label_color(state, seen, &app.palette)) - .add_modifier(Modifier::BOLD), - )); -} - -fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { - if area.height == 0 || area.width == 0 { - return; - } - let line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(line).style(Style::default().fg(app.palette.surface1)), - area, - ); -} - -fn render_rows( - app: &AppState, - rows: &[NavigatorRow], - lines: &[NavigatorDisplayLine], - frame: &mut Frame, - body: Rect, -) { - let start = app.navigator.scroll.min(lines.len()); - let end = lines.len().min(start.saturating_add(body.height as usize)); - for (visible_idx, line) in lines[start..end].iter().enumerate() { - let NavigatorDisplayLine::Row(idx) = *line else { - continue; - }; - let y = body.y + visible_idx as u16; - let rect = Rect::new(body.x, y, body.width, 1); - let selected = idx == app.navigator.selected; - render_row(app, frame, rect, rows, idx, selected); - } -} - -fn render_row( - app: &AppState, - frame: &mut Frame, - rect: Rect, - rows: &[NavigatorRow], - idx: usize, - selected: bool, -) { - let row = &rows[idx]; - let p = &app.palette; - frame.render_widget(Clear, rect); - let base_style = if selected { - Style::default().bg(p.accent).fg(panel_contrast_fg(p)) - } else { - Style::default().bg(p.panel_bg).fg(p.text) - }; - let dim_style = if selected { - base_style - } else { - Style::default().fg(p.overlay0).bg(p.panel_bg) - }; - let filter_active = - app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty(); - let context_only = filter_active && !row.matched; - let text_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg); - if row.is_workspace { - dimmed.add_modifier(Modifier::BOLD) - } else { - dimmed - } - } else if row.is_workspace { - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else if row.is_current { - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).bg(p.panel_bg) - }; - let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p); - let status_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - status_style.bg(p.panel_bg) - }; - - let prefix = tree_prefix(rows, idx); - let current = if row.is_current { "◆" } else { " " }; - let gutter = format!(" {current} "); - let gutter_style = if selected { - base_style - } else if row.is_current { - Style::default().fg(p.accent).bg(p.panel_bg) - } else { - dim_style - }; - // Branch glyphs recede one shade below the workspace caret so the - // structure stays behind the labels. - let tree_style = if selected { - base_style - } else if row.is_workspace { - dim_style - } else { - Style::default().fg(p.surface1).bg(p.panel_bg) - }; - let meta_width = metadata_width(rect.width); - let left_budget = rect - .width - .saturating_sub(meta_width) - .saturating_sub(display_width_u16(&format!("{gutter}{prefix} "))) - .saturating_sub(3) as usize; - let title = truncate_end(&row.label, left_budget); - - let spans = vec![ - Span::styled(gutter, gutter_style), - Span::styled(prefix, tree_style), - Span::styled(" ", base_style), - Span::styled(status_icon, status_style), - Span::raw(" "), - Span::styled(title, text_style), - ]; - frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect); - - if meta_width > 0 { - let meta_rect = Rect::new( - rect.x + rect.width.saturating_sub(meta_width), - rect.y, - meta_width, - 1, - ); - let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); - let meta_style = if selected { - base_style - } else if context_only || row.is_workspace || row.is_tab { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - Style::default() - .fg(state_label_color(row.status, row.seen, p)) - .bg(p.panel_bg) - }; - frame.render_widget( - Paragraph::new(format!(" {meta}")).style(meta_style), - meta_rect, - ); - } -} - -/// Tree prefix for a navigator row: expand caret for workspaces, connected -/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│` -/// continuation lines under ancestors that have more siblings below). -fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String { - let row = &rows[idx]; - if row.is_workspace { - return if row.expanded { "▾" } else { "▸" }.to_string(); - } - if row.depth == 0 { - return " ".to_string(); - } - let mut prefix = String::new(); - for level in 1..row.depth { - prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) { - "│ " - } else { - " " - }); - } - prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) { - "├──" - } else { - "└──" - }); - prefix -} - -/// Whether another row at `depth` follows `idx` before the subtree at that -/// depth ends (a row shallower than `depth` closes the subtree). -fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool { - rows[idx + 1..] - .iter() - .take_while(|row| row.depth >= depth) - .any(|row| row.depth == depth) -} - -fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) { - if body.width <= 1 || body.height == 0 { - return; - } - let viewport = body.height as usize; - if line_count <= viewport { - return; - } - let metrics = crate::pane::ScrollMetrics { - viewport_rows: viewport, - offset_from_bottom: line_count - .saturating_sub(viewport) - .saturating_sub(app.navigator.scroll), - max_offset_from_bottom: line_count.saturating_sub(viewport), - }; - if !should_show_scrollbar(metrics) { - return; - } - let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height); - render_scrollbar( - frame, - metrics, - track, - app.palette.surface_dim, - app.palette.overlay0, - "▕", - ); -} - -fn metadata_width(width: u16) -> u16 { - if width >= 90 { - 28 - } else if width >= 68 { - 20 - } else if width >= 52 { - 14 - } else { - 0 - } -} - -fn render_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.height == 0 || area.width == 0 { - return; - } - render_separator(frame, area, app); - let detail = selected_detail(app, terminal_runtimes); - if detail.is_empty() { - return; - } - let text = middle_elide(&detail, area.width.saturating_sub(2) as usize); - frame.render_widget( - Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)), - area, - ); -} - -fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) -> String { - let rows = app.navigator_rows_from(terminal_runtimes); - let Some(row) = rows.get(app.navigator.selected) else { - return String::new(); - }; - match row.target { - NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx), - NavigatorTarget::Tab { ws_idx, tab_idx } => { - tab_detail(app, terminal_runtimes, ws_idx, tab_idx) - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id), - } -} - -fn workspace_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>(); - let mut parts = vec![label, format!("{pane_count} panes")]; - if !rowless_workspace_activity(app, terminal_runtimes, ws_idx).is_empty() { - parts.push(rowless_workspace_activity(app, terminal_runtimes, ws_idx)); - } - parts.join(" · ") -} - -fn tab_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ - ws.display_name_from(&app.terminals, terminal_runtimes), - format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - ), - format!("{} panes", tab.panes.len()), - ]; - let rows = app.navigator_rows_from(terminal_runtimes); - if let Some(meta) = rows - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx)) - .map(|row| row.meta) - .filter(|meta| !meta.is_empty()) - { - parts.push(meta); - } - parts.join(" · ") -} - -fn pane_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)]; - if ws.tabs.len() > 1 { - parts.push(format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - )); - } - if let Some(pane_number) = ws.public_pane_number(pane_id) { - parts.push(format!("pane {pane_number}")); - } - if let Some(terminal_id) = tab.terminal_id(pane_id) { - if let Some(terminal) = app.terminals.get(terminal_id) { - let presentation = terminal.effective_presentation(); - if let Some(title) = presentation.title { - parts.push(title); - } - let display_agent = terminal.effective_display_agent(); - if let Some(agent) = display_agent.as_deref().or_else(|| { - terminal - .agent_name - .as_deref() - .or_else(|| terminal.effective_agent_label()) - }) { - parts.push(agent.to_string()); - let seen = tab - .panes - .get(&pane_id) - .map(|pane| pane.seen) - .unwrap_or(true); - let state = row_state(app, ws_idx, tab_idx, pane_id); - let status = presentation - .state_labels - .get(display_state(state, seen)) - .cloned() - .unwrap_or_else(|| display_state(state, seen).to_string()); - parts.push(status); - } else { - parts.push("shell".to_string()); - } - } - } - parts.join(" · ") -} - -fn rowless_workspace_activity( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - app.navigator_rows_from(terminal_runtimes) - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx)) - .map(|row| row.meta) - .unwrap_or_default() -} - -fn row_state( - app: &AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> crate::detect::AgentState { - app.workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .and_then(|tab| tab.terminal_id(pane_id)) - .and_then(|terminal_id| app.terminals.get(terminal_id)) - .map(|terminal| terminal.state) - .unwrap_or(crate::detect::AgentState::Unknown) -} - -fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => "blocked", - (crate::detect::AgentState::Working, _) => "working", - (crate::detect::AgentState::Idle, false) => "done", - (crate::detect::AgentState::Idle, true) => "idle", - (crate::detect::AgentState::Unknown, _) => "unknown", - } -} - -fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { - if area.height == 0 { - return; - } - let p = &app.palette; - let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(p.overlay0); - let line = if app.navigator.search_focused { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("↑↓", key), - Span::styled(" move ", dim), - Span::styled("ctrl+u", key), - Span::styled(" clear ", dim), - Span::styled("esc", key), - Span::styled(" back", dim), - ]) - } else { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("/", key), - Span::styled(" search ", dim), - Span::styled("b/w/i/d/a", key), - Span::styled(" states ", dim), - Span::styled("j/k/↑↓", key), - Span::styled(" move ", dim), - Span::styled("esc", key), - Span::styled(" close", dim), - ]) - }; - frame.render_widget(Paragraph::new(line), area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::detect::AgentState; - - fn row(depth: u8, is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth, - label: String::new(), - meta: String::new(), - status: AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - fn multi_tab_rows() -> Vec<NavigatorRow> { - vec![ - row(0, true), // workspace - row(1, false), // tab a - row(2, false), // pane - row(2, false), // pane (last in tab a) - row(1, false), // tab b (last tab) - row(2, false), // pane (last in tab b) - row(0, true), // workspace - row(1, false), // pane (single child) - ] - } - - #[test] - fn workspace_rows_use_expand_caret() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 0), "▾"); - let mut collapsed = rows.clone(); - collapsed[0].expanded = false; - assert_eq!(tree_prefix(&collapsed, 0), "▸"); - } - - #[test] - fn middle_children_get_branch_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 1), "├──"); - assert_eq!(tree_prefix(&rows, 2), "│ ├──"); - } - - #[test] - fn last_children_get_terminator_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 3), "│ └──"); - assert_eq!(tree_prefix(&rows, 4), "└──"); - assert_eq!(tree_prefix(&rows, 7), "└──"); - } - - #[test] - fn spine_stops_after_last_ancestor_sibling() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 5), " └──"); - } - - #[test] - fn next_workspace_does_not_extend_previous_subtree() { - // The pane at idx 5 is last in its workspace even though another - // workspace with children follows. - let rows = multi_tab_rows(); - assert!(!has_following_sibling_at_depth(&rows, 5, 1)); - assert!(!has_following_sibling_at_depth(&rows, 5, 2)); - } -} |
