aboutsummaryrefslogtreecommitdiff
path: root/src/app
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
parenta132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff)
feat: add session navigator
refs #157
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs703
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/modal.rs119
-rw-r--r--src/app/input/navigate.rs27
-rw-r--r--src/app/input/overlays.rs143
-rw-r--r--src/app/mod.rs12
-rw-r--r--src/app/state.rs53
7 files changed, 1046 insertions, 14 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();
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index ef8b93f3..44491631 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -34,7 +34,7 @@ mod terminal;
pub(crate) use self::{
modal::{
handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key,
- handle_keybind_help_key, handle_rename_key, handle_resize_key,
+ handle_keybind_help_key, handle_navigator_key, handle_rename_key, handle_resize_key,
},
navigate::terminal_direct_navigation_action,
settings::open_settings_at,
@@ -83,6 +83,7 @@ impl App {
Mode::Settings => self.handle_settings_key(key_event),
Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event),
+ Mode::Navigator => handle_navigator_key(&mut self.state, key_event),
Mode::Terminal => unreachable!(),
}
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index acd481b8..6821e64a 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -2,7 +2,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Direction, Rect};
use crate::{
- app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ app::state::{
+ AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter,
+ },
input::TerminalKey,
layout::NavDirection,
};
@@ -150,6 +152,121 @@ pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
}
}
+pub(crate) fn handle_navigator_key(state: &mut AppState, key: KeyEvent) {
+ if state.navigator.search_focused {
+ match key.code {
+ KeyCode::Esc => {
+ if state.navigator.query.is_empty() {
+ state.navigator.search_focused = false;
+ leave_modal(state);
+ } else {
+ state.navigator.query.clear();
+ state.navigator.state_filter = None;
+ state.navigator.search_focused = false;
+ state.clamp_navigator_selection();
+ }
+ }
+ KeyCode::Enter => {
+ state.accept_navigator_selection();
+ }
+ KeyCode::Backspace => {
+ state.navigator.state_filter = None;
+ state.navigator.query.pop();
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Up => state.move_navigator_selection(-1),
+ KeyCode::Down => state.move_navigator_selection(1),
+ KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
+ state.move_navigator_selection(1)
+ }
+ KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
+ state.move_navigator_selection(-1)
+ }
+ KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = None;
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char(c)
+ if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
+ {
+ state.navigator.state_filter = None;
+ state.navigator.query.push(c);
+ state.clamp_navigator_selection();
+ }
+ _ => {}
+ }
+ return;
+ }
+
+ match key.code {
+ KeyCode::Esc => {
+ if state.navigator.query.is_empty() && state.navigator.state_filter.is_none() {
+ leave_modal(state);
+ } else {
+ state.navigator.query.clear();
+ state.navigator.state_filter = None;
+ state.clamp_navigator_selection();
+ }
+ }
+ KeyCode::Enter => {
+ state.accept_navigator_selection();
+ }
+ KeyCode::Char('/') => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = None;
+ state.navigator.search_focused = true;
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Backspace if state.navigator.state_filter.is_some() => {
+ state.navigator.state_filter = None;
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('a') if key.modifiers.is_empty() => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = None;
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('b') if key.modifiers.is_empty() => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = Some(NavigatorStateFilter::Blocked);
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('w') if key.modifiers.is_empty() => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = Some(NavigatorStateFilter::Working);
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('i') if key.modifiers.is_empty() => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = Some(NavigatorStateFilter::Idle);
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('d') if key.modifiers.is_empty() => {
+ state.navigator.query.clear();
+ state.navigator.state_filter = Some(NavigatorStateFilter::Done);
+ state.clamp_navigator_selection();
+ }
+ KeyCode::Char('j') | KeyCode::Down => state.move_navigator_selection(1),
+ KeyCode::Char('k') | KeyCode::Up => state.move_navigator_selection(-1),
+ KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {
+ state.move_navigator_selection((state.navigator_body_rect().height / 2).max(1) as isize)
+ }
+ KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state
+ .move_navigator_selection(-((state.navigator_body_rect().height / 2).max(1) as isize)),
+ KeyCode::Char(' ') => state.toggle_selected_navigator_workspace(),
+ KeyCode::Home => {
+ state.navigator.selected = 0;
+ state.ensure_navigator_selection_visible();
+ }
+ KeyCode::End | KeyCode::Char('G') => {
+ state.navigator.selected = state.navigator_rows().len().saturating_sub(1);
+ state.ensure_navigator_selection_visible();
+ }
+ _ => {}
+ }
+}
+
pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index da3d49bf..4e8a50b3 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -490,6 +490,7 @@ pub(crate) enum NavigateAction {
ReloadConfig,
OpenNotificationTarget,
Detach,
+ OpenNavigator,
}
fn indexed_navigation_action(
@@ -588,6 +589,7 @@ fn action_for_key(
NavigateAction::OpenNotificationTarget,
),
(&kb.detach, NavigateAction::Detach),
+ (&kb.goto, NavigateAction::OpenNavigator),
] {
if action_matches(bindings, key, dispatch) {
return Some(action);
@@ -780,6 +782,7 @@ pub(super) fn execute_navigate_action_in_context(
super::modal::request_detach(state);
leave_navigate_mode(state);
}
+ NavigateAction::OpenNavigator => state.open_navigator(),
}
finish_action_context(state, context, previous_mode);
@@ -930,6 +933,18 @@ mod tests {
}
#[test]
+ fn default_goto_key_opens_navigator() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Navigator);
+ }
+
+ #[test]
fn custom_rename_key_enters_rename_mode() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g");
@@ -1502,8 +1517,8 @@ mod tests {
output_path.display()
);
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::prefix("g"),
- label: "prefix+g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("m"),
+ label: "prefix+m".into(),
command,
action: crate::config::CustomCommandAction::Shell,
}];
@@ -1515,7 +1530,7 @@ mod tests {
.await;
assert_eq!(app.state.mode, Mode::Prefix);
- app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty()))
.await;
let content = wait_for_file(&output_path);
@@ -1561,8 +1576,8 @@ mod tests {
let output_path = unique_temp_path("custom-pane-command");
let command = format!("printf done > '{}'", output_path.display());
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::prefix("g"),
- label: "prefix+g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("m"),
+ label: "prefix+m".into(),
command,
action: crate::config::CustomCommandAction::Pane,
}];
@@ -1572,7 +1587,7 @@ mod tests {
app.state.prefix_mods,
))
.await;
- app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty()))
.await;
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs
index 365de6b6..7d2a8ff3 100644
--- a/src/app/input/overlays.rs
+++ b/src/app/input/overlays.rs
@@ -5,7 +5,7 @@ use ratatui::{
};
use crate::app::{
- state::{AppState, DragState, DragTarget, Mode},
+ state::{AppState, DragState, DragTarget, Mode, NavigatorTarget},
App,
};
@@ -14,6 +14,10 @@ use super::{
ScrollbarClickTarget,
};
+fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
+ col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
+}
+
impl App {
pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool {
if self.state.mode == Mode::ReleaseNotes {
@@ -119,6 +123,60 @@ impl App {
return true;
}
+ if self.state.mode == Mode::Navigator {
+ match mouse.kind {
+ MouseEventKind::Moved => {
+ if let Some(idx) = self.state.navigator_row_index_at(mouse.column, mouse.row) {
+ self.state.navigator.selected = idx;
+ self.state.ensure_navigator_selection_visible();
+ }
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if self
+ .state
+ .navigator_search_contains(mouse.column, mouse.row)
+ {
+ self.state.navigator.search_focused = true;
+ } else if let Some(idx) =
+ self.state.navigator_row_index_at(mouse.column, mouse.row)
+ {
+ self.state.navigator.selected = idx;
+ let target = self
+ .state
+ .navigator_rows()
+ .get(idx)
+ .map(|row| (row.target.clone(), row.is_workspace));
+ if let Some((NavigatorTarget::Workspace { .. }, true)) = target {
+ if self.state.navigator_row_caret_at(mouse.column) {
+ self.state.toggle_selected_navigator_workspace();
+ } else {
+ self.state.accept_navigator_selection();
+ }
+ } else {
+ self.state.accept_navigator_selection();
+ }
+ } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) {
+ leave_modal(&mut self.state);
+ }
+ }
+ MouseEventKind::ScrollUp => {
+ self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3);
+ self.state.navigator.selected = self.state.navigator.scroll;
+ self.state.clamp_navigator_selection();
+ }
+ MouseEventKind::ScrollDown => {
+ let viewport = self.state.navigator_body_rect().height as usize;
+ let max = self.state.navigator_max_scroll(viewport);
+ self.state.navigator.scroll =
+ self.state.navigator.scroll.saturating_add(3).min(max);
+ self.state.navigator.selected = self.state.navigator.scroll;
+ self.state.clamp_navigator_selection();
+ }
+ _ => {}
+ }
+ return true;
+ }
+
if self.state.mode == Mode::KeybindHelp {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left)
@@ -188,6 +246,89 @@ impl AppState {
self.view.sidebar_rect.union(self.view.terminal_area)
}
+ pub(crate) fn navigator_popup_rect(&self) -> Rect {
+ let area = self.onboarding_full_area();
+ let margin_x = (area.width / 16).max(2);
+ let margin_y = (area.height / 10).max(1);
+ let width = area.width.saturating_sub(margin_x.saturating_mul(2));
+ let height = area.height.saturating_sub(margin_y.saturating_mul(2));
+ Rect::new(
+ area.x + margin_x,
+ area.y + margin_y,
+ width.max(4),
+ height.max(4),
+ )
+ }
+
+ pub(crate) fn navigator_inner_rect(&self) -> Rect {
+ Block::default()
+ .borders(Borders::ALL)
+ .inner(self.navigator_popup_rect())
+ }
+
+ pub(crate) fn navigator_search_rect(&self) -> Rect {
+ let inner = self.navigator_inner_rect();
+ Rect::new(inner.x, inner.y, inner.width, inner.height.min(1))
+ }
+
+ pub(crate) fn navigator_body_rect(&self) -> Rect {
+ let inner = self.navigator_inner_rect();
+ if inner.height <= 4 {
+ return Rect::default();
+ }
+ Rect::new(
+ inner.x,
+ inner.y + 2,
+ inner.width,
+ inner.height.saturating_sub(4),
+ )
+ }
+
+ pub(crate) fn navigator_detail_rect(&self) -> Rect {
+ let inner = self.navigator_inner_rect();
+ Rect::new(
+ inner.x,
+ inner.y + inner.height.saturating_sub(2),
+ inner.width,
+ inner.height.min(1),
+ )
+ }
+
+ pub(crate) fn navigator_footer_rect(&self) -> Rect {
+ let inner = self.navigator_inner_rect();
+ Rect::new(
+ inner.x,
+ inner.y + inner.height.saturating_sub(1),
+ inner.width,
+ inner.height.min(1),
+ )
+ }
+
+ pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool {
+ rect_contains(self.navigator_popup_rect(), col, row)
+ }
+
+ pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool {
+ rect_contains(self.navigator_search_rect(), col, row)
+ }
+
+ pub(crate) fn navigator_row_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ let body = self.navigator_body_rect();
+ if !rect_contains(body, col, row) {
+ return None;
+ }
+ let idx = self
+ .navigator
+ .scroll
+ .saturating_add(row.saturating_sub(body.y) as usize);
+ (idx < self.navigator_rows().len()).then_some(idx)
+ }
+
+ pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool {
+ let body = self.navigator_body_rect();
+ col <= body.x.saturating_add(3)
+ }
+
pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> {
let area = self.onboarding_full_area();
let popup_w = popup_w.min(area.width.saturating_sub(4));
diff --git a/src/app/mod.rs b/src/app/mod.rs
index cfd5b5f8..8a981226 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -401,6 +401,7 @@ impl App {
}
}),
keybind_help: state::KeybindHelpState { scroll: 0 },
+ navigator: state::NavigatorState::default(),
workspace_scroll: 0,
agent_panel_scroll: 0,
tab_scroll: 0,
@@ -1224,6 +1225,9 @@ impl App {
Mode::Settings => {
self.handle_settings_key(key_event);
}
+ Mode::Navigator => {
+ input::handle_navigator_key(&mut self.state, key_event);
+ }
Mode::Terminal => {
// Should not be called in terminal mode.
}
@@ -1473,7 +1477,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1488,7 +1492,7 @@ mod tests {
.state
.keybinds
.new_workspace
- .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())));
+ .matches_prefix(&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())));
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
@@ -1703,7 +1707,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[keys]\nnew_workspace = \"prefix+g\"\n[ui.toast]\ndelivery = \"desktop\"\n",
+ "[keys]\nnew_workspace = \"prefix+m\"\n[ui.toast]\ndelivery = \"desktop\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1717,7 +1721,7 @@ mod tests {
.state
.keybinds
.new_workspace
- .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())));
+ .matches_prefix(&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())));
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
diff --git a/src/app/state.rs b/src/app/state.rs
index 5d4fb67d..43d6e00f 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Direction, Rect};
use ratatui::style::Color;
+use crate::detect::AgentState;
use crate::layout::{PaneId, PaneInfo, SplitBorder};
use crate::selection::Selection;
@@ -650,6 +651,56 @@ pub enum Mode {
Settings,
GlobalMenu,
KeybindHelp,
+ Navigator,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum NavigatorTarget {
+ Workspace {
+ ws_idx: usize,
+ },
+ Tab {
+ ws_idx: usize,
+ tab_idx: usize,
+ },
+ Pane {
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: PaneId,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct NavigatorRow {
+ pub target: NavigatorTarget,
+ pub depth: u8,
+ pub label: String,
+ pub meta: String,
+ pub status: AgentState,
+ pub seen: bool,
+ pub is_current: bool,
+ pub is_workspace: bool,
+ pub is_tab: bool,
+ pub expanded: bool,
+ pub search_text: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NavigatorStateFilter {
+ Blocked,
+ Working,
+ Idle,
+ Done,
+}
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct NavigatorState {
+ pub query: String,
+ pub selected: usize,
+ pub scroll: usize,
+ pub search_focused: bool,
+ pub state_filter: Option<NavigatorStateFilter>,
+ pub expanded_workspaces: std::collections::HashSet<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -1021,6 +1072,7 @@ pub struct AppState {
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
+ pub navigator: NavigatorState,
pub workspace_scroll: usize,
pub agent_panel_scroll: usize,
pub tab_scroll: usize,
@@ -1307,6 +1359,7 @@ impl AppState {
release_notes: None,
product_announcement: None,
keybind_help: KeybindHelpState { scroll: 0 },
+ navigator: NavigatorState::default(),
workspace_scroll: 0,
agent_panel_scroll: 0,
tab_scroll: 0,