aboutsummaryrefslogtreecommitdiff
path: root/src/ui/navigator.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/ui/navigator.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs643
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));
- }
-}