aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:57:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:59:39 +0300
commit80a314a9089b28c1bfdc2cdf5196e9a2eb9f11ba (patch)
treefca593cafa027f4283adcec80c2f8908ef79708e /src/app/actions.rs
parenta132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff)
feat: add session navigator
refs #157
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs703
1 files changed, 702 insertions, 1 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index ba71cc87..9132d7bd 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -9,7 +9,10 @@ use crate::layout::{find_in_direction, NavDirection, PaneId};
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
use crate::workspace::WorkspaceGitStatus;
-use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout};
+use super::state::{
+ AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind,
+ ToastNotification, ToastTarget, ViewLayout,
+};
fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool {
matches!(new_state, AgentState::Idle)
@@ -95,6 +98,489 @@ pub struct PaneStateUpdate {
}
// ---------------------------------------------------------------------------
+// Navigator operations
+// ---------------------------------------------------------------------------
+
+impl AppState {
+ pub(crate) fn open_navigator(&mut self) {
+ self.navigator.query.clear();
+ self.navigator.search_focused = false;
+ self.navigator.state_filter = None;
+ self.navigator.scroll = 0;
+ self.navigator.expanded_workspaces.clear();
+
+ for ws in &self.workspaces {
+ self.navigator.expanded_workspaces.insert(ws.id.clone());
+ }
+
+ self.mode = Mode::Navigator;
+ self.navigator.selected = self.current_navigator_row_index().unwrap_or(0);
+ self.ensure_navigator_selection_visible();
+ }
+
+ pub(crate) fn navigator_rows(&self) -> Vec<NavigatorRow> {
+ let query = self.navigator.query.trim().to_lowercase();
+ let query_kind = navigator_query_kind(&query, self.navigator.state_filter);
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let mut rows = Vec::new();
+ for (ws_idx, ws) in self.workspaces.iter().enumerate() {
+ let workspace_label = ws.display_name_from(&self.terminals, &terminal_runtimes);
+ let activity = workspace_activity_summary(ws, &self.terminals);
+ let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase();
+ let workspace_matches = match query_kind {
+ NavigatorQueryKind::Empty => true,
+ NavigatorQueryKind::State(filter) => {
+ let (state, seen) = ws.aggregate_state(&self.terminals);
+ navigator_state_filter_matches(filter, state, seen)
+ }
+ NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text),
+ };
+
+ let child_rows = self.navigator_child_rows(ws_idx, query_kind, &query);
+ if !workspace_matches && child_rows.is_empty() {
+ continue;
+ }
+
+ let expanded = !matches!(query_kind, NavigatorQueryKind::Empty)
+ || self.navigator.expanded_workspaces.contains(&ws.id);
+ let (state, seen) = ws.aggregate_state(&self.terminals);
+ let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>();
+ rows.push(NavigatorRow {
+ target: NavigatorTarget::Workspace { ws_idx },
+ depth: 0,
+ label: format!("{workspace_label} ({pane_count})"),
+ meta: activity,
+ status: state,
+ seen,
+ is_current: self.active == Some(ws_idx),
+ is_workspace: true,
+ is_tab: false,
+ expanded,
+ search_text: workspace_search_text,
+ });
+ if expanded {
+ rows.extend(child_rows);
+ }
+ }
+ rows
+ }
+
+ fn navigator_child_rows(
+ &self,
+ ws_idx: usize,
+ query_kind: NavigatorQueryKind,
+ query: &str,
+ ) -> Vec<NavigatorRow> {
+ let Some(ws) = self.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ let multi_tab = ws.tabs.len() > 1;
+ let mut rows = Vec::new();
+ for tab_idx in 0..ws.tabs.len() {
+ let tab_row = multi_tab.then(|| self.navigator_tab_row(ws_idx, tab_idx));
+ let tab_matches = tab_row.as_ref().is_some_and(|row| match query_kind {
+ NavigatorQueryKind::Empty => true,
+ NavigatorQueryKind::State(filter) => {
+ navigator_state_filter_matches(filter, row.status, row.seen)
+ }
+ NavigatorQueryKind::Text => navigator_matches(query, &row.search_text),
+ });
+ let pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab);
+ let filtered_panes = match query_kind {
+ NavigatorQueryKind::Empty => pane_rows,
+ NavigatorQueryKind::State(filter) => pane_rows
+ .into_iter()
+ .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen))
+ .collect::<Vec<_>>(),
+ NavigatorQueryKind::Text if tab_matches => pane_rows,
+ NavigatorQueryKind::Text => pane_rows
+ .into_iter()
+ .filter(|row| navigator_matches(query, &row.search_text))
+ .collect::<Vec<_>>(),
+ };
+
+ if let Some(tab_row) = tab_row {
+ if tab_matches || !filtered_panes.is_empty() {
+ rows.push(tab_row);
+ }
+ }
+ rows.extend(filtered_panes);
+ }
+ rows
+ }
+
+ fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow {
+ let ws = &self.workspaces[ws_idx];
+ let tab = &ws.tabs[tab_idx];
+ let label = tab.display_name();
+ let (status, seen) = tab_aggregate_state(tab, &self.terminals);
+ let activity = tab_activity_summary(tab, &self.terminals);
+ let pane_count = tab.panes.len();
+ let meta = if activity.is_empty() {
+ format!("{pane_count} panes")
+ } else {
+ format!("{pane_count} panes · {activity}")
+ };
+ let search_text = format!("{label} {meta}").to_lowercase();
+ NavigatorRow {
+ target: NavigatorTarget::Tab { ws_idx, tab_idx },
+ depth: 1,
+ label,
+ meta,
+ status,
+ seen,
+ is_current: false,
+ is_workspace: false,
+ is_tab: true,
+ expanded: true,
+ search_text,
+ }
+ }
+
+ fn navigator_pane_rows_for_tab(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ multi_tab: bool,
+ ) -> Vec<NavigatorRow> {
+ let Some(ws) = self.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ let Some(tab) = ws.tabs.get(tab_idx) else {
+ return Vec::new();
+ };
+ let mut rows = Vec::new();
+ for pane_id in tab.layout.pane_ids() {
+ let Some(pane) = tab.panes.get(&pane_id) else {
+ continue;
+ };
+ let terminal = self.terminals.get(&pane.attached_terminal_id);
+ let pane_number = ws.public_pane_number(pane_id).unwrap_or(0);
+ let label = terminal
+ .and_then(|terminal| terminal.manual_label.as_deref())
+ .or_else(|| terminal.and_then(|terminal| terminal.agent_name.as_deref()))
+ .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()))
+ .map(str::to_string)
+ .or_else(|| {
+ launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref()))
+ })
+ .unwrap_or_else(|| format!("pane {pane_number}"));
+ let agent_label = terminal
+ .and_then(|terminal| terminal.agent_name.as_deref())
+ .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()));
+ let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status());
+ let state = terminal
+ .map(|terminal| terminal.state)
+ .unwrap_or(AgentState::Unknown);
+ let status = custom_status
+ .map(str::to_string)
+ .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string()));
+ let meta = match (agent_label, status.as_deref()) {
+ (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"),
+ (Some(agent_label), None) => agent_label.to_string(),
+ (None, _) => "shell".to_string(),
+ };
+ let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id);
+ let search_text = format!("{label} {meta}").to_lowercase();
+ rows.push(NavigatorRow {
+ target: NavigatorTarget::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ },
+ depth: if multi_tab { 2 } else { 1 },
+ label,
+ meta,
+ status: state,
+ seen: pane.seen,
+ is_current,
+ is_workspace: false,
+ is_tab: false,
+ expanded: false,
+ search_text,
+ });
+ }
+ rows
+ }
+
+ fn current_navigator_row_index(&self) -> Option<usize> {
+ let rows = self.navigator_rows();
+ rows.iter()
+ .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current)
+ .or_else(|| rows.iter().position(|row| row.is_current))
+ }
+
+ pub(crate) fn ensure_navigator_selection_visible(&mut self) {
+ let body = self.navigator_body_rect();
+ let viewport = body.height as usize;
+ if viewport == 0 {
+ self.navigator.scroll = 0;
+ return;
+ }
+ let max_scroll = self.navigator_max_scroll(viewport);
+ if self.navigator.selected < self.navigator.scroll {
+ self.navigator.scroll = self.navigator.selected;
+ } else if self.navigator.selected >= self.navigator.scroll.saturating_add(viewport) {
+ self.navigator.scroll = self
+ .navigator
+ .selected
+ .saturating_add(1)
+ .saturating_sub(viewport);
+ }
+ self.navigator.scroll = self.navigator.scroll.min(max_scroll);
+ }
+
+ pub(crate) fn navigator_max_scroll(&self, viewport: usize) -> usize {
+ if viewport == 0 {
+ return 0;
+ }
+ self.navigator_rows().len().saturating_sub(viewport)
+ }
+
+ pub(crate) fn move_navigator_selection(&mut self, delta: isize) {
+ let count = self.navigator_rows().len();
+ if count == 0 {
+ self.navigator.selected = 0;
+ self.navigator.scroll = 0;
+ return;
+ }
+ let current = self.navigator.selected.min(count - 1) as isize;
+ self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize;
+ self.ensure_navigator_selection_visible();
+ }
+
+ pub(crate) fn clamp_navigator_selection(&mut self) {
+ let count = self.navigator_rows().len();
+ self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1));
+ self.ensure_navigator_selection_visible();
+ }
+
+ pub(crate) fn toggle_selected_navigator_workspace(&mut self) {
+ let Some(row) = self.navigator_rows().get(self.navigator.selected).cloned() else {
+ return;
+ };
+ let NavigatorTarget::Workspace { ws_idx } = row.target else {
+ return;
+ };
+ let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else {
+ return;
+ };
+ if self.navigator.expanded_workspaces.contains(&workspace_id) {
+ self.navigator.expanded_workspaces.remove(&workspace_id);
+ } else {
+ self.navigator.expanded_workspaces.insert(workspace_id);
+ }
+ self.clamp_navigator_selection();
+ }
+
+ pub(crate) fn accept_navigator_selection(&mut self) -> bool {
+ let Some(row) = self.navigator_rows().get(self.navigator.selected).cloned() else {
+ return false;
+ };
+ self.focus_navigator_target(row.target)
+ }
+
+ pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool {
+ match target {
+ NavigatorTarget::Workspace { ws_idx } => {
+ if ws_idx >= self.workspaces.len() {
+ return false;
+ }
+ self.switch_workspace(ws_idx);
+ self.mode = Mode::Terminal;
+ true
+ }
+ NavigatorTarget::Tab { ws_idx, tab_idx } => {
+ if ws_idx >= self.workspaces.len() {
+ return false;
+ }
+ let tab_exists = self
+ .workspaces
+ .get(ws_idx)
+ .is_some_and(|ws| tab_idx < ws.tabs.len());
+ if !tab_exists {
+ return false;
+ }
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.mode = Mode::Terminal;
+ true
+ }
+ NavigatorTarget::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ } => {
+ if ws_idx >= self.workspaces.len() {
+ return false;
+ }
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ if let Some(tab) = self
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ if tab.panes.contains_key(&pane_id) {
+ tab.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ self.mode = Mode::Terminal;
+ return true;
+ }
+ }
+ false
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum NavigatorQueryKind {
+ Empty,
+ Text,
+ State(NavigatorStateFilter),
+}
+
+fn navigator_query_kind(
+ query: &str,
+ state_filter: Option<NavigatorStateFilter>,
+) -> NavigatorQueryKind {
+ if let Some(filter) = state_filter {
+ return NavigatorQueryKind::State(filter);
+ }
+ if query.is_empty() {
+ NavigatorQueryKind::Empty
+ } else {
+ NavigatorQueryKind::Text
+ }
+}
+
+fn navigator_state_filter_matches(
+ filter: NavigatorStateFilter,
+ state: AgentState,
+ seen: bool,
+) -> bool {
+ match filter {
+ NavigatorStateFilter::Blocked => state == AgentState::Blocked,
+ NavigatorStateFilter::Working => state == AgentState::Working,
+ NavigatorStateFilter::Idle => state == AgentState::Idle && seen,
+ NavigatorStateFilter::Done => state == AgentState::Idle && !seen,
+ }
+}
+
+fn navigator_matches(query: &str, text: &str) -> bool {
+ query
+ .split_whitespace()
+ .all(|part| text.contains(&part.to_lowercase()))
+}
+
+fn launch_label(argv: Option<&Vec<String>>) -> Option<String> {
+ let argv = argv?;
+ let command = argv.first()?;
+ std::path::Path::new(command)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .map(str::to_string)
+ .or_else(|| Some(command.clone()))
+}
+
+fn state_label_text(state: AgentState, seen: bool) -> &'static str {
+ match (state, seen) {
+ (AgentState::Blocked, _) => "blocked",
+ (AgentState::Working, _) => "working",
+ (AgentState::Idle, false) => "done",
+ (AgentState::Idle, true) => "idle",
+ (AgentState::Unknown, _) => "unknown",
+ }
+}
+
+fn tab_aggregate_state(
+ tab: &crate::workspace::Tab,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+) -> (AgentState, bool) {
+ let mut aggregate = AgentState::Unknown;
+ let mut seen = true;
+ for pane in tab.panes.values() {
+ let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
+ continue;
+ };
+ if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) {
+ aggregate = terminal.state;
+ seen = pane.seen;
+ }
+ }
+ (aggregate, seen)
+}
+
+fn state_priority(state: AgentState, seen: bool) -> u8 {
+ match (state, seen) {
+ (AgentState::Blocked, _) => 5,
+ (AgentState::Working, _) => 4,
+ (AgentState::Idle, false) => 3,
+ (AgentState::Idle, true) => 2,
+ (AgentState::Unknown, _) => 1,
+ }
+}
+
+fn tab_activity_summary(
+ tab: &crate::workspace::Tab,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+) -> String {
+ activity_summary_for_panes(tab.panes.values(), terminals)
+}
+
+fn workspace_activity_summary(
+ ws: &crate::workspace::Workspace,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+) -> String {
+ activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals)
+}
+
+fn activity_summary_for_panes<'a>(
+ panes: impl Iterator<Item = &'a crate::pane::PaneState>,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+) -> String {
+ let mut blocked = 0usize;
+ let mut working = 0usize;
+ let mut done = 0usize;
+ for pane in panes {
+ let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
+ continue;
+ };
+ match (terminal.state, pane.seen) {
+ (AgentState::Blocked, _) => blocked += 1,
+ (AgentState::Working, _) => working += 1,
+ (AgentState::Idle, false) => done += 1,
+ _ => {}
+ }
+ }
+
+ let mut parts = Vec::new();
+ if blocked > 0 {
+ parts.push(format!("{blocked} blocked"));
+ }
+ if working > 0 {
+ parts.push(format!("{working} working"));
+ }
+ if done > 0 {
+ parts.push(format!("{done} done"));
+ }
+ parts.join(" · ")
+}
+
+// ---------------------------------------------------------------------------
// Workspace operations
// ---------------------------------------------------------------------------
@@ -1140,6 +1626,221 @@ mod tests {
}
#[test]
+ fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
+ let mut state = app_with_workspaces(&["single", "multi"]);
+ state.workspaces[1].test_add_tab(Some("tests"));
+ state.ensure_test_terminals();
+
+ state.open_navigator();
+ let rows = state.navigator_rows();
+
+ assert!(!rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. }
+ )));
+ assert!(rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Tab {
+ ws_idx: 1,
+ tab_idx: 0
+ }
+ )));
+ assert!(rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Tab {
+ ws_idx: 1,
+ tab_idx: 1
+ }
+ )));
+ }
+
+ #[test]
+ fn navigator_rows_include_shell_and_agent_panes() {
+ let mut state = app_with_workspaces(&["one"]);
+ let shell = state.workspaces[0].tabs[0].root_pane;
+ let agent = state.workspaces[0].test_split(Direction::Horizontal);
+ state.ensure_test_terminals();
+
+ let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
+ let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
+
+ state.open_navigator();
+ let rows = state.navigator_rows();
+
+ assert!(rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
+ )));
+ assert!(rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
+ ) && row.meta.contains("claude")));
+ }
+
+ #[test]
+ fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() {
+ let mut state = app_with_workspaces(&["one", "two"]);
+ let blocked = state.workspaces[1].tabs[0].root_pane;
+ let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap();
+ state
+ .terminals
+ .get_mut(&blocked_terminal_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Codex), AgentState::Blocked);
+
+ state.open_navigator();
+ let selected = state.navigator_rows()[state.navigator.selected].clone();
+
+ assert!(selected.is_current);
+ assert!(state
+ .navigator
+ .expanded_workspaces
+ .contains(&state.workspaces[0].id));
+ assert!(state
+ .navigator
+ .expanded_workspaces
+ .contains(&state.workspaces[1].id));
+ }
+
+ #[test]
+ fn accepting_navigator_pane_switches_workspace_tab_and_focus() {
+ let mut state = app_with_workspaces(&["one", "two"]);
+ let target = state.workspaces[1].tabs[0].root_pane;
+ state.open_navigator();
+ state
+ .navigator
+ .expanded_workspaces
+ .insert(state.workspaces[1].id.clone());
+ state.navigator.selected = state
+ .navigator_rows()
+ .iter()
+ .position(|row| {
+ matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target
+ )
+ })
+ .unwrap();
+
+ assert!(state.accept_navigator_selection());
+
+ assert_eq!(state.active, Some(1));
+ assert_eq!(state.workspaces[1].focused_pane_id(), Some(target));
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn navigator_idle_search_matches_idle_agents_not_plain_shells() {
+ let mut state = app_with_workspaces(&["one"]);
+ let shell = state.workspaces[0].tabs[0].root_pane;
+ let agent = state.workspaces[0].test_split(Direction::Horizontal);
+ state.ensure_test_terminals();
+
+ let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
+ state
+ .terminals
+ .get_mut(&agent_terminal_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Claude), AgentState::Idle);
+
+ state.open_navigator();
+ state.navigator.query = "idle".into();
+ let rows = state.navigator_rows();
+
+ assert!(rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
+ )));
+ assert!(!rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
+ )));
+ }
+
+ #[test]
+ fn navigator_search_only_matches_visible_row_text() {
+ let mut state = app_with_workspaces(&["one"]);
+ state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into();
+
+ state.open_navigator();
+ state.navigator.query = "work".into();
+
+ assert!(state.navigator_rows().is_empty());
+ }
+
+ #[test]
+ fn navigator_state_filter_is_separate_from_text_search() {
+ let mut state = app_with_workspaces(&["one"]);
+ let shell = state.workspaces[0].tabs[0].root_pane;
+ let working = state.workspaces[0].test_split(Direction::Horizontal);
+ state.ensure_test_terminals();
+
+ let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap();
+ state
+ .terminals
+ .get_mut(&shell_terminal_id)
+ .unwrap()
+ .set_manual_label("wheel notes".into());
+ let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap();
+ state
+ .terminals
+ .get_mut(&working_terminal_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Codex), AgentState::Working);
+
+ state.open_navigator();
+ state.navigator.state_filter = Some(NavigatorStateFilter::Working);
+ let state_rows = state.navigator_rows();
+
+ assert!(state_rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
+ )));
+ assert!(!state_rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
+ )));
+
+ state.navigator.state_filter = None;
+ state.navigator.query = "w".into();
+ let text_rows = state.navigator_rows();
+
+ assert!(text_rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
+ )));
+ assert!(
+ text_rows.iter().any(|row| matches!(
+ row.target,
+ crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
+ )),
+ "literal one-letter search may still match visible state text"
+ );
+ }
+
+ #[test]
+ fn navigator_search_filters_panes_but_keeps_workspace_context() {
+ let mut state = app_with_workspaces(&["one"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap();
+ state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_manual_label("weekly review".into());
+ state.open_navigator();
+ state.navigator.query = "weekly".into();
+
+ let rows = state.navigator_rows();
+
+ assert!(rows.iter().any(|row| row.is_workspace));
+ assert!(rows
+ .iter()
+ .any(|row| !row.is_workspace && row.label.contains("weekly")));
+ }
+
+ #[test]
fn apply_workspace_git_statuses_updates_matching_workspace() {
let mut state = app_with_workspaces(&["one", "two"]);
let first_id = state.workspaces[0].id.clone();