aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-19 15:43:47 +0300
committerGitHub <noreply@github.com>2026-09-19 15:43:47 +0300
commite0507237ad8d41f60aece10d00ef29ce2339ca34 (patch)
tree829503bc869cf76285953866367b74a90927a19d /src
parentcc7c696cd0772e466da403eb4e3f00f4e8d8ae1e (diff)
feat: show every agent and terminal in the go to picker (#4384)
Diffstat (limited to 'src')
-rw-r--r--src/client/shell/aggregate_navigation.rs143
-rw-r--r--src/client/shell/composition.rs2
-rw-r--r--src/client/shell/mouse.rs53
-rw-r--r--src/client/shell/overlay_input.rs105
-rw-r--r--src/client/shell/overlays.rs253
-rw-r--r--src/client/shell/state.rs12
-rw-r--r--src/client/shell/tests/copy.rs552
-rw-r--r--src/client/shell/tests/endpoints.rs57
-rw-r--r--src/client/shell/tests/graphics.rs1
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs1
10 files changed, 913 insertions, 266 deletions
diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs
index c0195f93..9d3108cc 100644
--- a/src/client/shell/aggregate_navigation.rs
+++ b/src/client/shell/aggregate_navigation.rs
@@ -299,49 +299,92 @@ pub(super) fn navigator_rows(
let endpoint_query_matches = !query.is_empty() && text(&endpoint.label);
let mut endpoint_rows = Vec::new();
if let Some(snapshot) = endpoint.snapshot.as_deref() {
+ let agents = snapshot
+ .agents
+ .iter()
+ .map(|agent| (agent.pane_id.as_str(), agent))
+ .collect::<HashMap<_, _>>();
for workspace in &snapshot.workspaces {
- let workspace_meta = workspace.branch.clone().unwrap_or_default();
+ let workspace_matches = endpoint_query_matches
+ || text(&workspace.label)
+ || workspace.branch.as_deref().is_some_and(text);
let mut children = Vec::new();
- for tab in snapshot
+ let workspace_tabs = snapshot
.tabs
.iter()
- .filter(|tab| tab.workspace_id == workspace.workspace_id)
- {
- let mut panes = Vec::new();
- for (index, pane) in snapshot
+ .filter(|tab| tab.workspace_id == workspace.workspace_id);
+ let multiple_tabs = workspace_tabs.clone().nth(1).is_some();
+ for tab in workspace_tabs {
+ let tab_matches = workspace_matches || text(&tab.label);
+ let tab_panes = snapshot
.panes
.iter()
.filter(|pane| pane.tab_id == tab.tab_id)
- .enumerate()
- {
- let agent = snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane.pane_id);
+ .collect::<Vec<_>>();
+ for (index, pane) in tab_panes.iter().enumerate() {
+ let agent = agents.get(pane.pane_id.as_str()).copied();
let status = agent
.map_or(crate::api::schema::AgentStatus::Unknown, |agent| {
agent.agent_status
});
- let label = pane
+ let agent_kind = agent.and_then(|agent| {
+ agent.agent.as_deref().or(agent.display_agent.as_deref())
+ });
+ let name = pane
.label
- .clone()
- .or_else(|| agent.and_then(|agent| agent.name.clone()))
- .or_else(|| agent.and_then(|agent| agent.display_agent.clone()))
- .or_else(|| agent.and_then(|agent| agent.title.clone()))
- .unwrap_or_else(|| format!("pane {}", index + 1));
+ .as_deref()
+ .or_else(|| agent.and_then(|agent| agent.name.as_deref()));
+ let title = agent.and_then(|agent| {
+ agent
+ .title
+ .as_deref()
+ .or(agent.terminal_title_stripped.as_deref())
+ });
+ let tab_name = (tab.custom_label || tab.label.parse::<usize>().is_err())
+ .then_some(tab.label.as_str());
+ let label = if tab_panes.len() == 1 {
+ match name.or(tab_name).or(title) {
+ Some(label) => label.to_owned(),
+ None if multiple_tabs => {
+ format!("{} · {}", agent_kind.unwrap_or("terminal"), tab.label)
+ }
+ None => workspace.label.clone(),
+ }
+ } else {
+ let pane_name = name.or(title).or(agent_kind).unwrap_or("terminal");
+ match tab_name {
+ Some(tab_name) if tab_name != pane_name => {
+ format!("{tab_name} · {pane_name} · {}", index + 1)
+ }
+ _ => format!("{pane_name} · {}", index + 1),
+ }
+ };
let meta = pane
.foreground_cwd
- .clone()
- .or_else(|| pane.cwd.clone())
+ .as_deref()
+ .or(pane.cwd.as_deref())
.unwrap_or_default();
- if !filtering
- || filter(status)
- && (endpoint_query_matches || text(&label) || text(&meta))
+ if filter(status)
+ && (tab_matches
+ || text(&label)
+ || text(meta)
+ || pane.cwd.as_deref().is_some_and(text)
+ || agent_kind.is_some_and(text)
+ || title.is_some_and(text)
+ || agent
+ .and_then(|agent| agent.display_agent.as_deref())
+ .is_some_and(text)
+ || text(&pane.pane_id))
{
- panes.push(ClientNavigatorRow {
- depth: 2 + depth_offset,
+ children.push(ClientNavigatorRow {
+ depth: 1 + depth_offset,
label,
- meta,
+ meta: meta.to_owned(),
+ detail: format!(
+ "{} / {} / {}",
+ workspace.label, tab.label, pane.pane_id
+ ),
+ agent: agent_kind.map(str::to_owned),
status: Some(status),
stale,
current: endpoint.endpoint_id == *active_endpoint_id
@@ -353,40 +396,17 @@ pub(super) fn navigator_rows(
});
}
}
- if !filtering
- || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label))
- || !panes.is_empty()
- {
- children.push(ClientNavigatorRow {
- depth: 1 + depth_offset,
- label: tab.label.clone(),
- meta: format!(
- "{} panes",
- snapshot
- .panes
- .iter()
- .filter(|pane| pane.tab_id == tab.tab_id)
- .count()
- ),
- status: None,
- stale,
- current: false,
- target: ClientNavigatorTarget::Tab {
- endpoint_id: endpoint.endpoint_id.clone(),
- tab_id: tab.tab_id.clone(),
- },
- });
- children.extend(panes);
- }
}
- let workspace_matches = filter(workspace.agent_status)
- && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta));
- if !filtering || workspace_matches || !children.is_empty() {
- let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone());
+ if !filtering
+ || !children.is_empty()
+ || (navigator.filter.is_none() && !query.is_empty() && workspace_matches)
+ {
endpoint_rows.push(ClientNavigatorRow {
depth: depth_offset,
label: workspace.label.clone(),
- meta: workspace_meta,
+ meta: workspace.branch.clone().unwrap_or_default(),
+ detail: workspace.new_workspace_cwd.clone(),
+ agent: None,
status: None,
stale,
current: false,
@@ -395,9 +415,7 @@ pub(super) fn navigator_rows(
workspace_id: workspace.workspace_id.clone(),
},
});
- if navigator.expanded_workspaces.contains(&key) || filtering {
- endpoint_rows.extend(children);
- }
+ endpoint_rows.extend(children);
}
}
}
@@ -407,6 +425,8 @@ pub(super) fn navigator_rows(
depth: 0,
label: endpoint.label.to_owned(),
meta: String::new(),
+ detail: String::new(),
+ agent: None,
status: None,
stale,
current: false,
@@ -427,7 +447,10 @@ pub(super) fn navigator_selected_index(
) -> Option<usize> {
match navigator.selected.as_ref() {
Some(target) => rows.iter().position(|row| row.target == *target),
- None => (!rows.is_empty()).then_some(0),
+ None => rows
+ .iter()
+ .position(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .or_else(|| (!rows.is_empty()).then_some(0)),
}
}
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index e0888d0d..10dc6633 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -649,6 +649,8 @@ impl ClientShellState {
self.hits.navigator_popup = rendered.navigator_popup;
self.hits.navigator_search = rendered.navigator_search;
self.hits.navigator_rows = rendered.navigator_rows;
+ self.hits.navigator_scrollbar = rendered.navigator_scrollbar;
+ self.hits.navigator_scroll_metrics = rendered.navigator_scroll_metrics;
self.hits.worktree_search = rendered.worktree_search;
self.hits.worktree_rows = rendered.worktree_rows;
self.hits.help_popup = rendered.help_popup;
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 0b9326d0..a7e0dd81 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -1032,6 +1032,22 @@ impl ClientShellState {
}
return;
}
+ Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) => {
+ if let Some(metrics) = self.hits.navigator_scroll_metrics {
+ let offset = crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ self.hits.navigator_scrollbar,
+ mouse.row,
+ *grab_row_offset,
+ );
+ self.scroll_navigator_to(
+ metrics.max_offset_from_bottom.saturating_sub(offset),
+ metrics.viewport_rows,
+ );
+ outcome.repaint = true;
+ }
+ return;
+ }
Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => {
if let (Some(metrics), Some(ClientShellOverlay::Help(help))) =
(self.hits.help_scroll_metrics, self.overlay.as_mut())
@@ -1317,6 +1333,7 @@ impl ClientShellState {
ClientChromeDrag::WorkspaceScrollbar { .. }
| ClientChromeDrag::AgentScrollbar { .. }
| ClientChromeDrag::HelpScrollbar { .. }
+ | ClientChromeDrag::NavigatorScrollbar { .. }
| ClientChromeDrag::ProductAnnouncementScrollbar { .. }
| ClientChromeDrag::ReleaseNotesScrollbar { .. } => {}
}
@@ -1613,7 +1630,29 @@ impl ClientShellState {
}
}
MouseEventKind::Down(MouseButton::Left) => {
- if super::contains(self.hits.navigator_search, point) {
+ if super::contains(self.hits.navigator_scrollbar, point) {
+ if let Some(metrics) = self.hits.navigator_scroll_metrics {
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(
+ metrics,
+ self.hits.navigator_scrollbar,
+ mouse.row,
+ ) {
+ self.chrome_drag =
+ Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset });
+ } else {
+ let offset = crate::ui::scrollbar_offset_from_row(
+ metrics,
+ self.hits.navigator_scrollbar,
+ mouse.row,
+ );
+ self.scroll_navigator_to(
+ metrics.max_offset_from_bottom.saturating_sub(offset),
+ metrics.viewport_rows,
+ );
+ outcome.repaint = true;
+ }
+ }
+ } else if super::contains(self.hits.navigator_search, point) {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
@@ -1621,19 +1660,13 @@ impl ClientShellState {
navigator.filter = None;
}
outcome.repaint = true;
- } else if let Some((rect, target)) = row_hit {
+ } else if let Some((_, target)) = row_hit {
if let Some(ClientShellOverlay::Navigator(navigator)) =
self.overlay.as_mut()
{
- navigator.selected = Some(target.clone());
- }
- let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. });
- if workspace && mouse.column <= rect.x.saturating_add(3) {
- self.toggle_selected_navigator_workspace();
- outcome.repaint = true;
- } else {
- self.accept_navigator_selection(outcome);
+ navigator.selected = Some(target);
}
+ self.accept_navigator_selection(outcome);
} else if !super::contains(self.hits.navigator_popup, point) {
self.overlay = None;
outcome.repaint = true;
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs
index 7cf151cb..e4248304 100644
--- a/src/client/shell/overlay_input.rs
+++ b/src/client/shell/overlay_input.rs
@@ -191,21 +191,12 @@ impl ClientShellState {
}
pub(super) fn open_navigator_overlay(&mut self) {
- let expanded_workspaces =
- super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints)
- .flat_map(|endpoint| {
- endpoint.snapshot.workspaces.iter().map(move |workspace| {
- (endpoint.endpoint_id.clone(), workspace.workspace_id.clone())
- })
- })
- .collect();
let mut navigator = ClientNavigatorOverlay {
query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
filter: None,
- expanded_workspaces,
};
let rows =
render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator);
@@ -233,6 +224,55 @@ impl ClientShellState {
navigator.selected = Some(rows[next].target.clone());
}
+ pub(super) fn scroll_navigator_to(&mut self, scroll: usize, viewport_rows: usize) {
+ let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
+ return;
+ };
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
+ let viewport_rows = viewport_rows.max(1);
+ navigator.scroll = scroll.min(rows.len().saturating_sub(viewport_rows));
+ let selected =
+ super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0);
+ // Keep the selection in the dragged viewport so rendering does not snap back to it.
+ let selected = selected.clamp(navigator.scroll, navigator.scroll + viewport_rows - 1);
+ navigator.selected = rows.get(selected).map(|row| row.target.clone());
+ }
+
+ fn move_navigator_workspace(&mut self, forward: bool) {
+ let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
+ return;
+ };
+ let rows =
+ render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
+ let Some(selected) =
+ super::aggregate_navigation::navigator_selected_index(&rows, navigator)
+ else {
+ return;
+ };
+ let section = rows[..=selected]
+ .iter()
+ .rposition(|row| !matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .unwrap_or(selected);
+ let mut destinations = rows.windows(2).enumerate().filter(|(index, pair)| {
+ matches!(pair[0].target, ClientNavigatorTarget::Workspace { .. })
+ && matches!(pair[1].target, ClientNavigatorTarget::Pane { .. })
+ && if forward {
+ *index > section
+ } else {
+ *index < section
+ }
+ });
+ let destination = if forward {
+ destinations.next()
+ } else {
+ destinations.next_back()
+ };
+ if let Some((_, pair)) = destination {
+ navigator.selected = Some(pair[1].target.clone());
+ }
+ }
+
pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) {
let target = self.overlay.as_ref().and_then(|overlay| match overlay {
ClientShellOverlay::Navigator(navigator) => {
@@ -260,12 +300,6 @@ impl ClientShellState {
ClientEndpointFocusTarget::Workspace(workspace_id),
outcome,
),
- ClientNavigatorTarget::Tab {
- endpoint_id,
- tab_id,
- } => {
- self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome)
- }
ClientNavigatorTarget::Pane {
endpoint_id,
pane_id,
@@ -281,37 +315,6 @@ impl ClientShellState {
outcome.repaint = true;
}
- pub(super) fn toggle_selected_navigator_workspace(&mut self) {
- let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay {
- ClientShellOverlay::Navigator(navigator) => {
- let rows = render::client_navigator_rows(
- &self.endpoints,
- &self.active_endpoint_id,
- navigator,
- );
- super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then(
- |target| match target {
- ClientNavigatorTarget::Workspace {
- endpoint_id,
- workspace_id,
- } => Some((endpoint_id, workspace_id)),
- _ => None,
- },
- )
- }
- _ => None,
- });
- if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) =
- (workspace_key, self.overlay.as_mut())
- {
- if !navigator.expanded_workspaces.remove(&workspace_key) {
- navigator.expanded_workspaces.insert(workspace_key);
- }
- navigator.selected = None;
- navigator.scroll = 0;
- }
- }
-
pub(super) fn workspace_action_id(&self) -> Option<String> {
self.navigate_workspace_id
.as_ref()
@@ -676,6 +679,11 @@ impl ClientShellState {
}
return;
}
+ if matches!(code, KeyCode::Left | KeyCode::Right) && modifiers.is_empty() {
+ self.move_navigator_workspace(code == KeyCode::Right);
+ outcome.repaint = true;
+ return;
+ }
if code == KeyCode::Backspace && modifiers.is_empty() {
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
if navigator.filter.take().is_some() {
@@ -762,11 +770,6 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
- if code == KeyCode::Char(' ') && modifiers.is_empty() {
- self.toggle_selected_navigator_workspace();
- outcome.repaint = true;
- return;
- }
return;
}
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
index 07417bd0..c8eee5f4 100644
--- a/src/client/shell/overlays.rs
+++ b/src/client/shell/overlays.rs
@@ -13,6 +13,8 @@ pub(crate) struct OverlayRender {
pub(crate) navigator_popup: Rect,
pub(crate) navigator_search: Rect,
pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
+ pub(crate) navigator_scrollbar: Rect,
+ pub(crate) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>,
pub(crate) worktree_search: Rect,
pub(crate) worktree_rows: Vec<(Rect, usize)>,
pub(crate) help_popup: Rect,
@@ -685,24 +687,6 @@ fn render_rename_overlay(
})
}
-/// Mark following siblings in preorder without rescanning descendants for each row.
-/// The reverse stack contains at most one entry per depth; every entry is pushed
-/// and popped at most once, so this pass is linear in the number of rows.
-fn navigator_following_siblings(rows: &[ClientNavigatorRow]) -> Vec<bool> {
- let mut following = vec![false; rows.len()];
- let mut depths = Vec::new();
- for (index, row) in rows.iter().enumerate().rev() {
- while depths.last().is_some_and(|depth| *depth > row.depth) {
- depths.pop();
- }
- following[index] = depths.last() == Some(&row.depth);
- if !following[index] {
- depths.push(row.depth);
- }
- }
- following
-}
-
fn render_navigator_overlay(
b: &mut Buffer,
n: &ClientNavigatorOverlay,
@@ -711,16 +695,27 @@ fn render_navigator_overlay(
p: &Palette,
) -> Option<OverlayRender> {
let a = b.area;
- let mx = (a.width / 16).max(2);
- let my = (a.height / 10).max(1);
+ let width = a.width.saturating_sub(4).min(116);
+ let height = a.height.saturating_sub(2).min(42);
+ if width < 4 || height < 9 {
+ return None;
+ }
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),
+ a.x + (a.width - width) / 2,
+ a.y + (a.height - height) / 2,
+ width,
+ height,
)
.intersection(a);
let i = panel(b, q, p.accent, p.panel_bg)?;
+ put_text(
+ b,
+ q.x + 2,
+ q.y,
+ q.width.saturating_sub(4),
+ " Go to ",
+ Style::default().fg(p.accent).bg(p.panel_bg),
+ );
let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
let search = if n.search_focused {
" / ".to_owned()
@@ -735,26 +730,32 @@ fn render_navigator_overlay(
}
)
} else if n.query.is_empty() {
- " / search panes".to_owned()
+ " / search agents and terminals".to_owned()
} else {
format!(" / {}", n.query)
};
+ let terminal_count = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .count();
+ let count = format!(
+ "{terminal_count} {}",
+ if terminal_count == 1 {
+ "terminal"
+ } else {
+ "terminals"
+ }
+ );
put_text(
b,
i.x,
i.y,
- i.width,
+ i.width.saturating_sub(display_width(&count) + 1),
&search,
Style::default()
.fg(if n.search_focused { p.text } else { p.overlay0 })
.bg(p.panel_bg),
);
- let count = format!(
- "{} panes",
- rows.iter()
- .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
- .count()
- );
let cursor = if n.search_focused {
text_editor::render(
b,
@@ -785,7 +786,7 @@ fn render_navigator_overlay(
&"─".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 body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(5));
let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0);
let max = rows.len().saturating_sub(body.height as usize);
let scroll = n
@@ -793,17 +794,32 @@ fn render_navigator_overlay(
.max(selected.saturating_sub(body.height.saturating_sub(1) as usize))
.min(selected)
.min(max);
- let following_siblings = navigator_following_siblings(&rows);
- let mut ancestor_siblings = Vec::new();
- let federated = endpoints.len() > 1;
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: max.saturating_sub(scroll),
+ max_offset_from_bottom: max,
+ viewport_rows: usize::from(body.height),
+ };
+ let scrollbar =
+ (max > 0 && body.width > 1).then_some(Rect::new(body.right() - 1, body.y, 1, body.height));
+ let row_width = body.width.saturating_sub(u16::from(scrollbar.is_some()));
let mut row_hits = Vec::new();
- for (ix, r) in rows.iter().enumerate().take(scroll + body.height as usize) {
- ancestor_siblings.truncate(usize::from(r.depth));
- ancestor_siblings.push(following_siblings[ix]);
- if ix < scroll {
- continue;
- }
- let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, body.width, 1);
+ if rows.is_empty() {
+ put_text(
+ b,
+ body.x,
+ body.y,
+ body.width,
+ " No matching agents or terminals",
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ );
+ }
+ for (ix, r) in rows
+ .iter()
+ .enumerate()
+ .skip(scroll)
+ .take(body.height as usize)
+ {
+ let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, row_width, 1);
row_hits.push((rect, r.target.clone()));
let st = if r.stale {
Style::default()
@@ -821,50 +837,74 @@ fn render_navigator_overlay(
.add_modifier(Modifier::BOLD)
} else {
Style::default()
- .fg(if r.current { p.text } else { p.subtext0 })
+ .fg(
+ if matches!(r.target, ClientNavigatorTarget::Machine { .. }) {
+ p.subtext0
+ } else {
+ p.text
+ },
+ )
.bg(p.panel_bg)
};
- b.set_style(rect, st);
- let tree = match &r.target {
- ClientNavigatorTarget::Machine { .. } => "▾".to_owned(),
- ClientNavigatorTarget::Workspace {
- endpoint_id,
- workspace_id,
- } if n
- .expanded_workspaces
- .contains(&(endpoint_id.clone(), workspace_id.clone())) =>
- {
- if r.depth == 0 { "▾" } else { " ▾" }.to_owned()
- }
- ClientNavigatorTarget::Workspace { .. } => {
- if r.depth == 0 { "▸" } else { " ▸" }.to_owned()
- }
- ClientNavigatorTarget::Tab { .. } | ClientNavigatorTarget::Pane { .. } => {
- // Machines and workspaces keep their existing caret decoration.
- // Connected branches begin below each workspace.
- let mut prefix = if federated { " " } else { "" }.to_owned();
- for &following in ancestor_siblings
- .iter()
- .take(usize::from(r.depth))
- .skip(if federated { 2 } else { 1 })
- {
- prefix.push_str(if following { "│ " } else { " " });
- }
- prefix.push_str(if following_siblings[ix] {
- "├──"
- } else {
- "└──"
- });
- prefix
- }
+ let is_pane = matches!(r.target, ClientNavigatorTarget::Pane { .. });
+ let connector = if !is_pane {
+ ""
+ } else if rows
+ .get(ix + 1)
+ .is_some_and(|next| matches!(next.target, ClientNavigatorTarget::Pane { .. }))
+ {
+ "├─ "
+ } else {
+ "└─ "
};
+ let padding = u16::from(r.depth.saturating_sub(u8::from(is_pane))) * 2 + 1;
+ let connector_x = rect.x + padding;
+ let indent = format!("{:width$}{connector}", "", width = usize::from(padding));
let current = if r.current { "◆ " } else { "" };
let status = r.status.map(status_dot).unwrap_or_default();
let status_separator = if status.is_empty() { "" } else { " " };
- let label = format!(" {tree} {current}{status}{status_separator}{}", r.label);
- put_text(b, rect.x, rect.y, rect.width, &label, st);
+ let label = format!("{indent}{current}{status}{status_separator}{}", r.label);
+ let st = if r.status.is_none() {
+ st.add_modifier(Modifier::BOLD)
+ } else {
+ st
+ };
+ b.set_style(rect, st);
+ let columns = if r.status.is_some() {
+ if rect.width >= 64 {
+ 24
+ } else if rect.width >= 36 {
+ 12
+ } else {
+ 0
+ }
+ } else {
+ 0
+ };
+ put_text(
+ b,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(columns),
+ &label,
+ st,
+ );
+ if is_pane {
+ put_text(
+ b,
+ connector_x,
+ rect.y,
+ rect.right().saturating_sub(connector_x).min(2),
+ connector,
+ if r.stale || ix == selected {
+ st
+ } else {
+ st.fg(p.overlay0)
+ },
+ );
+ }
if let Some(status) = r.status {
- let prefix = format!(" {tree} {current}");
+ let prefix = format!("{indent}{current}");
let status_style = if r.stale || ix == selected {
st
} else {
@@ -878,6 +918,35 @@ fn render_navigator_overlay(
status_dot(status),
status_style,
);
+ let meta_style = if r.stale || ix == selected {
+ st
+ } else {
+ st.fg(p.overlay0)
+ };
+ if columns > 0 {
+ put_text(
+ b,
+ rect.right() - columns + 1,
+ rect.y,
+ 11,
+ r.agent.as_deref().unwrap_or("terminal"),
+ meta_style,
+ );
+ }
+ if columns == 24 {
+ put_text(
+ b,
+ rect.right() - 11,
+ rect.y,
+ 11,
+ if r.agent.is_some() {
+ status_text(status)
+ } else {
+ "shell"
+ },
+ meta_style,
+ );
+ }
}
let machine_status = match &r.target {
ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints
@@ -906,7 +975,7 @@ fn render_navigator_overlay(
})
};
put_right_text(b, rect, rect.y, &signal, signal_style);
- } else if !r.meta.is_empty() {
+ } else if r.status.is_none() && !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),
@@ -917,16 +986,26 @@ fn render_navigator_overlay(
put_right_text(b, meta, rect.y, &r.meta, st)
}
}
- let dy = i.bottom() - 2;
+ if let Some(track) = scrollbar {
+ crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐");
+ }
if let Some(r) = rows.get(selected) {
put_text(
b,
i.x,
- dy,
+ i.bottom() - 3,
+ i.width,
+ &format!(" {}", r.detail),
+ Style::default().fg(p.subtext0).bg(p.panel_bg),
+ );
+ put_text(
+ b,
+ i.x,
+ i.bottom() - 2,
i.width,
- &format!(" {} · {}", r.label, r.meta),
+ &format!(" {}", r.meta),
Style::default().fg(p.overlay0).bg(p.panel_bg),
- )
+ );
}
put_text(
b,
@@ -936,7 +1015,7 @@ fn render_navigator_overlay(
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"
+ " ↑↓/j/k rows · ←→ workspace · / search · a/b/w/i/d filter · enter open · esc close"
},
Style::default().fg(p.overlay0).bg(p.panel_bg),
);
@@ -948,6 +1027,8 @@ fn render_navigator_overlay(
navigator_popup: q,
navigator_search: Rect::new(i.x, i.y, i.width, 1),
navigator_rows: row_hits,
+ navigator_scrollbar: scrollbar.unwrap_or_default(),
+ navigator_scroll_metrics: Some(metrics),
worktree_search: Rect::default(),
worktree_rows: Vec::new(),
cursor,
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index b975b2f6..7128db57 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -121,6 +121,8 @@ pub(super) struct ShellHitMap {
pub(super) navigator_popup: Rect,
pub(super) navigator_search: Rect,
pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
+ pub(super) navigator_scrollbar: Rect,
+ pub(super) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>,
pub(super) worktree_search: Rect,
pub(super) worktree_rows: Vec<(Rect, usize)>,
pub(super) help_popup: Rect,
@@ -196,6 +198,9 @@ pub(super) enum ClientChromeDrag {
HelpScrollbar {
grab_row_offset: u16,
},
+ NavigatorScrollbar {
+ grab_row_offset: u16,
+ },
ProductAnnouncementScrollbar {
grab_row_offset: u16,
},
@@ -336,10 +341,6 @@ pub(super) enum ClientNavigatorTarget {
endpoint_id: ClientEndpointId,
workspace_id: String,
},
- Tab {
- endpoint_id: ClientEndpointId,
- tab_id: String,
- },
Pane {
endpoint_id: ClientEndpointId,
pane_id: String,
@@ -351,6 +352,8 @@ pub(super) struct ClientNavigatorRow {
pub(super) depth: u8,
pub(super) label: String,
pub(super) meta: String,
+ pub(super) detail: String,
+ pub(super) agent: Option<String>,
pub(super) status: Option<crate::api::schema::AgentStatus>,
pub(super) stale: bool,
pub(super) current: bool,
@@ -364,7 +367,6 @@ pub(super) struct ClientNavigatorOverlay {
pub(super) selected: Option<ClientNavigatorTarget>,
pub(super) scroll: usize,
pub(super) filter: Option<ClientNavigatorFilter>,
- pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>,
}
#[derive(Debug)]
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index eda4da89..bdd192a6 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -940,7 +940,34 @@ fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() {
}
#[test]
-fn navigator_renders_connected_siblings_and_ancestor_lines() {
+fn navigator_workspace_headings_use_the_active_themes_primary_text() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.config.theme_runtime.auto_switch = false;
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.open_navigator_overlay();
+ for palette in [
+ Palette::catppuccin(),
+ Palette::catppuccin_latte(),
+ Palette::terminal(),
+ ] {
+ state.config.palette = palette;
+ let frame = state.compose(106, 30).expect("navigator");
+ let (rect, _) = state
+ .hits
+ .navigator_rows
+ .iter()
+ .find(|(_, target)| matches!(target, ClientNavigatorTarget::Workspace { .. }))
+ .expect("workspace heading");
+ let position = cell_symbol_position(&frame, *rect, "client-shell");
+ let buffer = frame.to_ratatui_buffer().expect("buffer");
+ assert_eq!(buffer[position].fg, state.config.palette.text);
+ assert!(buffer[position].modifier.contains(Modifier::BOLD));
+ }
+}
+
+#[test]
+fn navigator_renders_every_terminal_in_workspace_sections() {
let mut snapshot = snapshot();
snapshot.focused_pane_id = None;
snapshot.tabs[0].label = "editor".into();
@@ -986,39 +1013,46 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() {
state.set_snapshot(Box::new(snapshot));
state.set_pane_surface(surface());
state.open_navigator_overlay();
- let prefixes = |state: &mut ClientShellState, height| {
+ let visible_rows = |state: &mut ClientShellState, height| {
let frame = state.compose(106, height).expect("navigator frame");
state
.hits
.navigator_rows
.iter()
.map(|(rect, _)| {
- frame.cells[rect.y as usize * frame.width as usize + rect.x as usize + 1..]
+ frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..]
.iter()
- .take(6)
+ .take(rect.width as usize)
.map(|cell| cell.symbol.as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
};
- assert_eq!(
- prefixes(&mut state, 30),
- [
- "▾ clie",
- "├── ed",
- "│ ├──",
- "│ └──",
- "├── no",
- "│ └──",
- "└── lo",
- " └──",
- "▾ seco",
- "└── la",
- " └──"
- ]
- );
+ let visible = visible_rows(&mut state, 30);
+ assert_eq!(visible.len(), 7);
+ for (row, prefix) in visible
+ .iter()
+ .zip([" client", " ├─ ", " ├─ ", " ├─ ", " └─ ", " second", " └─ "])
+ {
+ assert!(
+ row.starts_with(prefix),
+ "{row:?} should start with {prefix:?}"
+ );
+ }
+ for (row, label) in visible.iter().zip([
+ "client-shell",
+ "editor · agent · 1",
+ "editor · shell · 2",
+ "notes",
+ "logs",
+ "second",
+ "agent",
+ ]) {
+ assert!(row.contains(label), "{row:?} should contain {label}");
+ assert!(!row.contains("/repo"));
+ assert!(!row.contains("──"));
+ }
- // The editor ancestor is above this viewport; the logs sibling is below it.
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
panic!("expected navigator");
};
@@ -1027,24 +1061,477 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() {
endpoint_id: state.active_endpoint_id.clone(),
pane_id: "pane_shell".into(),
});
- assert_eq!(
- prefixes(&mut state, 12),
- ["│ ├──", "│ └──", "├── no", "│ └──"]
- );
+ let visible = visible_rows(&mut state, 11);
+ assert_eq!(visible.len(), 2);
+ assert!(visible.iter().any(|row| row.contains("editor · shell · 2")));
+ assert!(visible[0].starts_with(" ├─ "));
+ assert!(visible[1].starts_with(" ├─ "));
- // Excluded siblings must not leave dangling continuation lines.
+ // Filtering retains the section and the exact split destination.
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
panic!("expected navigator");
};
- navigator.query = "shell".into();
+ navigator.query = "pane_shell".into();
navigator.scroll = 0;
- assert_eq!(prefixes(&mut state, 30), ["▾ clie", "└── ed", " └──"]);
+ let visible = visible_rows(&mut state, 30);
+ assert_eq!(visible.len(), 2);
+ assert!(visible[1].starts_with(" └─ "));
+ assert!(visible.iter().any(|row| row.contains("editor · shell · 2")));
+ assert!(visible.iter().all(|row| !row.contains("second")));
+}
+
+#[test]
+fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() {
+ let mut projected = snapshot();
+ projected.tabs[0].label = "review".into();
+ projected.tabs[0].custom_label = true;
+ let mut second = projected.panes[0].clone();
+ second.pane_id = "pane_2".into();
+ second.focused = false;
+ second.foreground_cwd = Some("/repo/subproject".into());
+ projected.panes.push(second);
+ let first_agent = ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("writer".into()),
+ display_agent: None,
+ agent: Some("pi".into()),
+ title: Some("implementing navigation".into()),
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: AgentStatus::Working,
+ state_change_seq: 1,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ };
+ let mut second_agent = first_agent.clone();
+ second_agent.pane_id = "pane_2".into();
+ second_agent.name = Some("reviewer".into());
+ second_agent.agent = Some("claude".into());
+ second_agent.title = Some("checking navigation".into());
+ second_agent.agent_status = AgentStatus::Blocked;
+ second_agent.focused = false;
+ projected.agents = vec![first_agent, second_agent];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(projected));
+ state.set_pane_surface(surface());
+ state.open_navigator_overlay();
+ for (query, filter, expected) in [
+ ("", None, vec!["pane_1", "pane_2"]),
+ ("review", None, vec!["pane_1", "pane_2"]),
+ ("client-shell", None, vec!["pane_1", "pane_2"]),
+ ("main", None, vec!["pane_1", "pane_2"]),
+ ("claude", None, vec!["pane_2"]),
+ ("checking navigation", None, vec!["pane_2"]),
+ ("/repo/subproject", None, vec!["pane_2"]),
+ (
+ "review",
+ Some(ClientNavigatorFilter::Blocked),
+ vec!["pane_2"],
+ ),
+ ("", Some(ClientNavigatorFilter::Working), vec!["pane_1"]),
+ ("no such agent", None, vec![]),
+ ] {
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ navigator.query = query.into();
+ navigator.filter = filter;
+ navigator.selected = None;
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let pane_ids = rows
+ .iter()
+ .filter_map(|row| match &row.target {
+ ClientNavigatorTarget::Pane { pane_id, .. } => Some(pane_id.as_str()),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(pane_ids, expected, "query={query:?} filter={filter:?}");
+ assert_eq!(
+ rows.len(),
+ if expected.is_empty() {
+ 0
+ } else {
+ expected.len() + 1
+ }
+ );
+ if !expected.is_empty() {
+ let selected =
+ super::super::aggregate_navigation::navigator_selected_index(&rows, navigator)
+ .expect("search destination");
+ assert!(matches!(
+ rows[selected].target,
+ ClientNavigatorTarget::Pane { .. }
+ ));
+ }
+ }
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
- panic!("expected navigator");
+ panic!("navigator");
};
navigator.query.clear();
- navigator.expanded_workspaces.clear();
- assert_eq!(prefixes(&mut state, 30), ["▸ clie", "▸ seco"]);
+ let frame = state.compose(160, 48).expect("navigator");
+ assert_eq!(state.hits.navigator_popup.width, 116);
+ let pane_rows = state
+ .hits
+ .navigator_rows
+ .iter()
+ .filter(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { .. }))
+ .collect::<Vec<_>>();
+ assert_eq!(pane_rows.len(), 2);
+ for ((rect, _), (name, kind, status)) in pane_rows.iter().zip([
+ ("writer", "pi", "working"),
+ ("reviewer", "claude", "blocked"),
+ ]) {
+ cell_symbol_position(&frame, *rect, name);
+ cell_symbol_position(&frame, *rect, kind);
+ cell_symbol_position(&frame, *rect, status);
+ }
+ let rect = pane_rows[1].0;
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: rect.right() - 1,
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(
+ matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }]
+ if matches!(&request.method, crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"))
+ );
+}
+
+#[test]
+fn navigator_distinguishes_unnamed_terminals_on_numbered_tabs() {
+ let mut projected = snapshot();
+ for (number, label) in [(2, "2"), (3, "logs")] {
+ let mut tab = projected.tabs[0].clone();
+ tab.tab_id = format!("tab_{number}");
+ tab.number = number;
+ tab.label = label.into();
+ tab.custom_label = number == 3;
+ tab.focused = false;
+ let mut pane = projected.panes[0].clone();
+ pane.pane_id = format!("pane_{number}");
+ pane.tab_id = tab.tab_id.clone();
+ pane.focused = false;
+ projected.tabs.push(tab);
+ projected.panes.push(pane);
+ }
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(projected));
+ state.open_navigator_overlay();
+ let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
+ panic!("navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let labels = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .map(|row| row.label.as_str())
+ .collect::<Vec<_>>();
+ assert_eq!(labels, ["terminal · 1", "terminal · 2", "logs"]);
+}
+
+#[test]
+fn navigator_keeps_empty_workspaces_searchable_without_status_filters() {
+ let mut projected = snapshot();
+ projected.tabs.clear();
+ projected.panes.clear();
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(projected));
+ state.open_navigator_overlay();
+ for (query, filter, expected) in [
+ ("", None, true),
+ ("client-shell", None, true),
+ ("main", None, true),
+ ("missing", None, false),
+ ("main", Some(ClientNavigatorFilter::Idle), false),
+ ("", Some(ClientNavigatorFilter::Working), false),
+ ] {
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ navigator.query = query.into();
+ navigator.filter = filter;
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ assert_eq!(
+ rows.len(),
+ usize::from(expected),
+ "query={query:?}, filter={filter:?}"
+ );
+ let target =
+ super::super::aggregate_navigation::selected_navigator_target(&rows, navigator);
+ assert_eq!(
+ target,
+ expected.then(|| ClientNavigatorTarget::Workspace {
+ endpoint_id: ClientEndpointId::Local,
+ workspace_id: "ws_1".into(),
+ })
+ );
+ }
+}
+
+#[test]
+fn navigator_horizontal_arrows_jump_sections_but_edit_the_search_cursor() {
+ let mut projected = snapshot();
+ projected.panes[0].label = Some("needle-first".into());
+ let mut sibling = projected.panes[0].clone();
+ sibling.pane_id = "pane_sibling".into();
+ sibling.label = Some("other".into());
+ sibling.focused = false;
+ projected.panes.push(sibling);
+ let mut empty = projected.workspaces[0].clone();
+ empty.workspace_id = "ws_empty".into();
+ empty.label = "empty".into();
+ empty.focused = false;
+ projected.workspaces.push(empty);
+ let mut last = projected.workspaces[0].clone();
+ last.workspace_id = "ws_last".into();
+ last.label = "last".into();
+ last.active_tab_id = "tab_last".into();
+ last.focused = false;
+ let mut tab = projected.tabs[0].clone();
+ tab.workspace_id = last.workspace_id.clone();
+ tab.tab_id = last.active_tab_id.clone();
+ tab.focused = false;
+ for (id, label) in [
+ ("pane_last", "needle-last"),
+ ("pane_last_sibling", "other-last"),
+ ] {
+ let mut pane = projected.panes[0].clone();
+ pane.pane_id = id.into();
+ pane.label = Some(label.into());
+ pane.workspace_id = last.workspace_id.clone();
+ pane.tab_id = tab.tab_id.clone();
+ pane.focused = false;
+ projected.panes.push(pane);
+ }
+ projected.workspaces.push(last);
+ projected.tabs.push(tab);
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(projected));
+ state.set_pane_surface(surface());
+ state.open_navigator_overlay();
+ let press = |state: &mut ClientShellState, code| {
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
+ crate::input::TerminalKey::new(code, KeyModifiers::empty()),
+ )]);
+ assert!(outcome.actions.is_empty());
+ };
+ let selected = |state: &ClientShellState| {
+ let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
+ panic!("navigator");
+ };
+ navigator.selected.clone()
+ };
+ let target = |id: &str| {
+ Some(ClientNavigatorTarget::Pane {
+ endpoint_id: ClientEndpointId::Local,
+ pane_id: id.into(),
+ })
+ };
+ press(&mut state, KeyCode::Left);
+ assert_eq!(selected(&state), target("pane_1"));
+ press(&mut state, KeyCode::Right);
+ assert_eq!(selected(&state), target("pane_last"));
+ press(&mut state, KeyCode::Down);
+ assert_eq!(selected(&state), target("pane_last_sibling"));
+ press(&mut state, KeyCode::Right);
+ assert_eq!(selected(&state), target("pane_last_sibling"));
+ press(&mut state, KeyCode::Left);
+ assert_eq!(selected(&state), target("pane_1"));
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ navigator.query = "needle".into();
+ press(&mut state, KeyCode::Right);
+ assert_eq!(selected(&state), target("pane_last"));
+ press(&mut state, KeyCode::Left);
+ assert_eq!(selected(&state), target("pane_1"));
+ press(&mut state, KeyCode::Char('/'));
+ press(&mut state, KeyCode::Left);
+ press(&mut state, KeyCode::Right);
+ assert_eq!(selected(&state), target("pane_1"));
+ press(&mut state, KeyCode::Left);
+ press(&mut state, KeyCode::Char('X'));
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ assert_eq!(navigator.query.as_str(), "needlXe");
+ navigator.search_focused = false;
+ navigator.selected = None;
+ press(&mut state, KeyCode::Left);
+ press(&mut state, KeyCode::Right);
+ assert_eq!(selected(&state), None);
+}
+
+#[test]
+fn navigator_scrollbar_click_and_drag_scroll_without_opening_a_destination() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.open_navigator_overlay();
+ state.compose(106, 24).expect("small navigator");
+ assert!(state.hits.navigator_scrollbar.is_empty());
+
+ let mut projected = snapshot();
+ for index in 2..=60 {
+ let mut pane = projected.panes[0].clone();
+ pane.pane_id = format!("pane_{index}");
+ pane.label = Some(format!("agent {index}"));
+ pane.focused = false;
+ projected.panes.push(pane);
+ }
+ state.set_snapshot(Box::new(projected));
+ let frame = state.compose(106, 24).expect("overflowing navigator");
+ let track = state.hits.navigator_scrollbar;
+ let metrics = state.hits.navigator_scroll_metrics.expect("scroll metrics");
+ assert!(!track.is_empty());
+ assert_eq!(metrics.offset_from_bottom, metrics.max_offset_from_bottom);
+ assert!(track.y > state.hits.navigator_search.y);
+ assert!(track.bottom() < state.hits.navigator_popup.bottom() - 3);
+ assert!(state
+ .hits
+ .navigator_rows
+ .iter()
+ .all(|(rect, _)| rect.right() == track.x));
+ let buffer = frame.to_ratatui_buffer().expect("buffer");
+ assert_eq!(buffer[(track.x, track.y)].fg, state.config.palette.overlay1);
+ assert_eq!(
+ buffer[(track.x, track.bottom() - 1)].fg,
+ state.config.palette.overlay0
+ );
+ let mouse = |state: &mut ClientShellState, kind, row| {
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind,
+ column: track.x,
+ row,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(outcome.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Navigator(_))
+ ));
+ };
+ mouse(
+ &mut state,
+ MouseEventKind::Down(MouseButton::Left),
+ track.bottom() - 1,
+ );
+ state.compose(106, 24).expect("track jump");
+ assert_eq!(
+ state
+ .hits
+ .navigator_scroll_metrics
+ .expect("metrics")
+ .offset_from_bottom,
+ 0
+ );
+ assert!(state.hits.navigator_rows.iter().any(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { pane_id, .. } if pane_id == "pane_60")));
+ mouse(&mut state, MouseEventKind::Down(MouseButton::Left), track.y);
+ state.compose(106, 24).expect("jump back to top");
+ assert_eq!(
+ state
+ .hits
+ .navigator_scroll_metrics
+ .expect("metrics")
+ .offset_from_bottom,
+ metrics.max_offset_from_bottom
+ );
+ let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb");
+ let grab = thumb.len - 1;
+ mouse(
+ &mut state,
+ MouseEventKind::Down(MouseButton::Left),
+ thumb.top + grab,
+ );
+ assert!(
+ matches!(state.chrome_drag, Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) if grab_row_offset == grab)
+ );
+ mouse(
+ &mut state,
+ MouseEventKind::Drag(MouseButton::Left),
+ thumb.top + grab,
+ );
+ state.compose(106, 24).expect("grab does not move viewport");
+ assert_eq!(
+ state
+ .hits
+ .navigator_scroll_metrics
+ .expect("metrics")
+ .offset_from_bottom,
+ metrics.max_offset_from_bottom
+ );
+ mouse(
+ &mut state,
+ MouseEventKind::Drag(MouseButton::Left),
+ track.bottom() + 5,
+ );
+ state.compose(106, 24).expect("drag to bottom");
+ assert_eq!(
+ state
+ .hits
+ .navigator_scroll_metrics
+ .expect("metrics")
+ .offset_from_bottom,
+ 0
+ );
+ mouse(
+ &mut state,
+ MouseEventKind::Up(MouseButton::Left),
+ track.bottom() + 5,
+ );
+ assert!(state.chrome_drag.is_none());
+ state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Up,
+ KeyModifiers::empty(),
+ ))]);
+ state.compose(106, 24).expect("keyboard resumes after drag");
+ assert_eq!(
+ state
+ .hits
+ .navigator_scroll_metrics
+ .expect("metrics")
+ .offset_from_bottom,
+ 1
+ );
+
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ navigator.query = "pane_60".into();
+ navigator.selected = None;
+ state.compose(106, 24).expect("filtered navigator");
+ assert!(state.hits.navigator_scrollbar.is_empty());
+ assert_eq!(state.hits.navigator_rows.len(), 2);
+ state.compose(106, 90).expect("tall filtered navigator");
+ assert!(state.hits.navigator_scrollbar.is_empty());
+}
+
+#[test]
+fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.open_navigator_overlay();
+ for (width, height) in [(24, 12), (50, 24), (106, 30)] {
+ let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
+ panic!("navigator");
+ };
+ navigator.search_focused = true;
+ navigator.query = "界".repeat(100).as_str().into();
+ let frame = state.compose(width, height).expect("navigator frame");
+ let popup = state.hits.navigator_popup;
+ let cursor = frame.cursor.expect("search cursor");
+ assert!(super::super::contains(popup, (cursor.x, cursor.y)));
+ assert!(state.hits.navigator_rows.is_empty());
+ assert!(popup.right() <= width && popup.bottom() <= height);
+ }
}
#[test]
@@ -1098,7 +1585,8 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
.collect::<Vec<_>>()
.join("\n");
assert!(navigator_text.contains("client-shell"));
- assert!(navigator_text.contains("pane 1"));
+ assert!(navigator_text.contains("terminal"));
+ assert!(!navigator_text.contains("pane 1"));
let search = state.hits.navigator_search;
let focus_search =
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs
index e4914971..4434e119 100644
--- a/src/client/shell/tests/endpoints.rs
+++ b/src/client/shell/tests/endpoints.rs
@@ -2056,18 +2056,16 @@ fn navigator_uses_machine_parents_only_for_federated_clients() {
assert!(rows.iter().all(|row| match row.target {
ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
- ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(),
- ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(),
+ ClientNavigatorTarget::Pane { .. } => row.depth == 2 && row.status.is_some(),
}));
assert_eq!(rows.iter().filter(|row| row.current).count(), 1);
let frame = state.compose(106, 30).expect("federated navigator");
for (rect, target) in &state.hits.navigator_rows {
let expected = match target {
- ClientNavigatorTarget::Machine { .. } => " ▾ ",
- ClientNavigatorTarget::Workspace { .. } => " ▾ ",
- ClientNavigatorTarget::Tab { .. } => " └── ",
- ClientNavigatorTarget::Pane { .. } => " └── ",
+ ClientNavigatorTarget::Machine { .. } => " ",
+ ClientNavigatorTarget::Workspace { .. } => " ",
+ ClientNavigatorTarget::Pane { .. } => " └─ ",
};
let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..]
.iter()
@@ -2105,8 +2103,7 @@ fn navigator_uses_machine_parents_only_for_federated_clients() {
.all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
assert!(rows.iter().all(|row| match row.target {
ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
- ClientNavigatorTarget::Tab { .. } => row.depth == 1,
- ClientNavigatorTarget::Pane { .. } => row.depth == 2,
+ ClientNavigatorTarget::Pane { .. } => row.depth == 1,
ClientNavigatorTarget::Machine { .. } => false,
}));
}
@@ -2143,10 +2140,6 @@ fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
endpoint_id: target,
..
}
- | ClientNavigatorTarget::Tab {
- endpoint_id: target,
- ..
- }
| ClientNavigatorTarget::Pane {
endpoint_id: target,
..
@@ -2497,7 +2490,32 @@ fn collapsed_aggregate_workspace_status_uses_its_status_color() {
}
#[test]
-fn navigator_foreign_tab_selection_keeps_the_tab_target() {
+fn navigator_workspace_arrows_cross_machine_headings_without_activating_them() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ for (key, expected_endpoint) in [
+ (KeyCode::Right, endpoint_id),
+ (KeyCode::Left, ClientEndpointId::Local),
+ ] {
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
+ crate::input::TerminalKey::new(key, KeyModifiers::empty()),
+ )]);
+ assert!(outcome.actions.is_empty());
+ let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
+ panic!("navigator");
+ };
+ assert_eq!(
+ navigator.selected,
+ Some(ClientNavigatorTarget::Pane {
+ endpoint_id: expected_endpoint,
+ pane_id: "pane_1".into(),
+ })
+ );
+ }
+}
+
+#[test]
+fn navigator_foreign_workspace_heading_keeps_the_workspace_target() {
let (mut state, endpoint_id) = state_with_remote();
state.open_navigator_overlay();
let selected = {
@@ -2510,15 +2528,14 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() {
.find(|row| {
matches!(
&row.target,
- ClientNavigatorTarget::Tab {
+ ClientNavigatorTarget::Workspace {
endpoint_id: target_endpoint,
- tab_id,
- ..
- } if target_endpoint == &endpoint_id && tab_id == "tab_1"
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
)
})
.map(|row| row.target.clone())
- .expect("remote tab row")
+ .expect("remote workspace heading")
};
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
navigator.selected = Some(selected);
@@ -2531,7 +2548,7 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() {
outcome.actions.as_slice(),
[ClientShellAction::ActivateEndpoint {
endpoint_id: activated,
- target: Some(ClientEndpointFocusTarget::Tab(tab_id)),
- }] if activated == &endpoint_id && tab_id == "tab_1"
+ target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
+ }] if activated == &endpoint_id && workspace_id == "ws_1"
));
}
diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs
index c5fa1f4a..98fc62fa 100644
--- a/src/client/shell/tests/graphics.rs
+++ b/src/client/shell/tests/graphics.rs
@@ -230,7 +230,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
selected: None,
scroll: 0,
filter: None,
- expanded_workspaces: HashSet::new(),
}),
ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay {
source_workspace_id: "ws_1".into(),
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 7f3ea137..c9f02278 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -83,7 +83,6 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
selected: None,
scroll: 0,
filter: None,
- expanded_workspaces: HashSet::new(),
}));
assert!(!state.modal_paste_target_active());
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {