diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index d7788f32..1fff74cf 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157) + ## [0.6.2] - 2026-05-23 ### Added diff --git a/docs/next/README.md b/docs/next/README.md index cf99dc28..e82057d8 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -214,6 +214,7 @@ press `ctrl+b` to enter prefix mode. default actions are prefix-first and tmux-l | `prefix+n` / `prefix+p` | next / previous tab | | `prefix+1..9` | switch tab | | `prefix+w` | workspace navigation | +| `prefix+g` | session navigator | | `prefix+shift+n` | new workspace | | `prefix+shift+g` | new worktree | | `prefix+shift+w` | rename workspace | @@ -228,11 +229,13 @@ press `ctrl+b` to enter prefix mode. default actions are prefix-first and tmux-l resize mode: `h`/`l` resize width, `j`/`k` resize height, `esc` exit. +session navigator opens a searchable workspace, tab, and pane tree. Use `/` for text search, `b`/`w`/`i`/`d` for blocked, working, idle, and done filters, `a` or Backspace to clear a state filter, and Enter to switch to the highlighted row. + custom command keybindings can launch detached shell helpers or temporary panes: ```toml [[keys.command]] -key = "prefix+g" +key = "prefix+alt+g" type = "pane" # "shell" or "pane" command = "lazygit" ``` diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 87de1102..23debd45 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -91,6 +91,7 @@ A small keybinding override looks like this: ```toml [keys] prefix = "ctrl+b" +goto = "prefix+g" new_tab = "prefix+c" next_tab = "prefix+n" previous_tab = "prefix+p" @@ -104,6 +105,7 @@ The default keymap is prefix-first and avoids direct shortcuts that can steal in [keys] detach = "prefix+q" workspace_picker = "prefix+w" +goto = "prefix+g" new_workspace = "prefix+shift+n" new_worktree = "prefix+shift+g" rename_workspace = "prefix+shift+w" @@ -160,7 +162,7 @@ Custom commands use the same keybinding syntax. ```toml [[keys.command]] -key = "prefix+g" +key = "prefix+alt+g" type = "pane" command = "lazygit" ``` diff --git a/scripts/seed_navigator_demo.sh b/scripts/seed_navigator_demo.sh new file mode 100755 index 00000000..9275e40a --- /dev/null +++ b/scripts/seed_navigator_demo.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +usage: scripts/seed_navigator_demo.sh [--allow-main] + +Seeds a running herdr dev server with navigator demo workspaces, tabs, panes, +and fake agent states. Most panes are intentionally unnamed. + +Environment: + HERDR_NAV_SOCKET_PATH API socket to target. Defaults to $HOME/.config/herdr-dev/herdr.sock. + HERDR_NAV_CWD Workspace cwd for created panes. Defaults to the repo root. +USAGE +} + +allow_main=0 +while (($#)); do + case "$1" in + --allow-main) + allow_main=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd -- "$script_dir/.." && pwd)" +workspace_cwd="${HERDR_NAV_CWD:-$repo_dir}" +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +dev_socket="$config_home/herdr-dev/herdr.sock" +main_socket="$config_home/herdr/herdr.sock" +export HERDR_SOCKET_PATH="${HERDR_NAV_SOCKET_PATH:-$dev_socket}" + +if [[ "$allow_main" != 1 && "$HERDR_SOCKET_PATH" == "$main_socket" ]]; then + echo "refusing to seed main herdr session: $HERDR_SOCKET_PATH" >&2 + echo "use HERDR_NAV_SOCKET_PATH for a dev socket, or pass --allow-main intentionally" >&2 + exit 1 +fi + +if [[ ! -S "$HERDR_SOCKET_PATH" ]]; then + echo "herdr socket not found: $HERDR_SOCKET_PATH" >&2 + echo "start a dev server first, or set HERDR_NAV_SOCKET_PATH" >&2 + exit 1 +fi + +cd "$repo_dir" + +run() { cargo run --quiet -- "$@"; } + +mkws() { + local label="$1" + run workspace create --label "$label" --cwd "$workspace_cwd" --no-focus \ + | jq -r '.result.workspace.workspace_id + " " + .result.root_pane.pane_id + " " + .result.tab.tab_id' +} + +mktab() { + local ws="$1" label="$2" + run tab create --workspace "$ws" --label "$label" --cwd "$workspace_cwd" --no-focus \ + | jq -r '.result.tab.tab_id + " " + .result.root_pane.pane_id' +} + +split() { + local pane="$1" direction="$2" + run pane split "$pane" --direction "$direction" --no-focus \ + | jq -r '.result.pane.pane_id' +} + +rename_sparse() { + local pane="$1" label="$2" + run pane rename "$pane" "$label" >/dev/null +} + +report() { + local pane="$1" agent="$2" state="$3" status="$4" seq="$5" + run pane report-agent "$pane" \ + --source nav-seed \ + --agent "$agent" \ + --state "$state" \ + --custom-status "$status" \ + --seq "$seq" >/dev/null +} + +stamp="$(date +%H%M%S)" +done_panes=() + +read WS1 P1 T1 < <(mkws "nav-${stamp}-claude-review") +P2="$(split "$P1" down)" +P3="$(split "$P1" right)" +rename_sparse "$P2" "approval needed" +report "$P1" claude blocked blocked 1 +report "$P2" claude working working 1 +# P3 stays shell/unknown. + +read WS2 P4 T2 < <(mkws "nav-${stamp}-codex-build") +P5="$(split "$P4" right)" +read T2B P6 < <(mktab "$WS2" tests) +P7="$(split "$P6" down)" +report "$P4" codex working working 1 +report "$P5" codex working working 1 +report "$P6" codex idle idle 1 +report "$P7" codex working working 1 +done_panes+=("$P7:codex") + +read WS3 P8 T3 < <(mkws "nav-${stamp}-finished") +P9="$(split "$P8" down)" +P10="$(split "$P8" right)" +rename_sparse "$P10" "release notes" +report "$P8" claude working working 1 +report "$P9" claude working working 1 +report "$P10" codex idle idle 1 +done_panes+=("$P8:claude" "$P9:claude") + +read WS4 P11 T4 < <(mkws "nav-${stamp}-quiet") +read T4B P12 < <(mktab "$WS4" notes) +P13="$(split "$P12" right)" +report "$P12" pi idle idle 1 +# P11 and P13 stay shell/unknown. + +read WS5 P14 T5 < <(mkws "nav-${stamp}-mixed") +P15="$(split "$P14" down)" +P16="$(split "$P14" right)" +read T5B P17 < <(mktab "$WS5" agents) +P18="$(split "$P17" down)" +rename_sparse "$P14" "prod decision" +report "$P14" claude blocked blocked 1 +report "$P15" codex blocked blocked 1 +report "$P16" claude working working 1 +report "$P17" codex working working 1 +report "$P18" claude idle idle 1 + +read WS6 P19 T6 < <(mkws "nav-${stamp}-dense") +P20="$(split "$P19" down)" +P21="$(split "$P19" right)" +read T6B P22 < <(mktab "$WS6" long-run) +P23="$(split "$P22" down)" +P24="$(split "$P22" right)" +report "$P19" codex working working 1 +report "$P20" claude idle idle 1 +report "$P21" codex blocked blocked 1 +report "$P22" claude working working 1 +report "$P23" codex working working 1 +report "$P24" claude working working 1 +done_panes+=("$P24:claude") + +run workspace focus "$WS1" >/dev/null +seq=2 +for item in "${done_panes[@]}"; do + pane="${item%%:*}" + agent="${item##*:}" + report "$pane" "$agent" idle done "$seq" + seq=$((seq + 1)) +done + +cat < bool { matches!(new_state, AgentState::Idle) @@ -94,6 +97,489 @@ pub struct PaneStateUpdate { pub custom_status: Option, } +// --------------------------------------------------------------------------- +// 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 { + 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::(); + 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 { + 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::>(), + NavigatorQueryKind::Text if tab_matches => pane_rows, + NavigatorQueryKind::Text => pane_rows + .into_iter() + .filter(|row| navigator_matches(query, &row.search_text)) + .collect::>(), + }; + + 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 { + 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 { + 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, +) -> 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>) -> Option { + 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, + 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 // --------------------------------------------------------------------------- @@ -1139,6 +1625,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"]); 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); @@ -929,6 +932,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"]); @@ -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 { + 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 { 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, + pub expanded_workspaces: std::collections::HashSet, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -1021,6 +1072,7 @@ pub struct AppState { pub release_notes: Option, pub product_announcement: Option, 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, diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 014bab7a..e6109e30 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -252,6 +252,7 @@ pub struct Keybinds { pub rename_workspace: ActionKeybinds, pub close_workspace: ActionKeybinds, pub workspace_picker: ActionKeybinds, + pub goto: ActionKeybinds, pub detach: ActionKeybinds, pub reload_config: ActionKeybinds, pub open_notification_target: ActionKeybinds, @@ -384,6 +385,7 @@ impl Config { rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker), + goto: action!("keys.goto", &self.keys.goto), detach: action!("keys.detach", &self.keys.detach), reload_config: action!("keys.reload_config", &self.keys.reload_config), open_notification_target: action!( @@ -1098,6 +1100,18 @@ next_tab = "prefix+n" ); } + #[test] + fn goto_defaults_to_prefix_g() { + let kb = Config::default().keybinds(); + assert_eq!( + binding_triggers(&kb.goto), + vec![BindingTrigger::Prefix(( + KeyCode::Char('g'), + KeyModifiers::empty() + ))] + ); + } + #[test] fn open_and_remove_worktree_keybinds_are_unset_by_default() { let kb = Config::default().keybinds(); diff --git a/src/config/model.rs b/src/config/model.rs index 8894b300..b6b7a2fb 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -151,6 +151,8 @@ pub struct KeysConfig { pub close_workspace: BindingConfig, /// Open the workspace navigation surface. Default: "prefix+w" pub workspace_picker: BindingConfig, + /// Open the session navigator. Default: "prefix+g" + pub goto: BindingConfig, /// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q". pub detach: BindingConfig, /// Reload config.toml in the running app/server. Default: "prefix+shift+r". @@ -343,6 +345,7 @@ impl Default for KeysConfig { rename_workspace: BindingConfig::one("prefix+shift+w"), close_workspace: BindingConfig::one("prefix+shift+d"), workspace_picker: BindingConfig::one("prefix+w"), + goto: BindingConfig::one("prefix+g"), detach: BindingConfig::one("prefix+q"), reload_config: BindingConfig::one("prefix+shift+r"), open_notification_target: BindingConfig::one("prefix+o"), diff --git a/src/main.rs b/src/main.rs index 71631ed4..a49b76df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,6 +106,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # reload_config = "prefix+shift+r" # open_notification_target = "prefix+o" # workspace_picker = "prefix+w" +# goto = "prefix+g" # new_workspace = "prefix+shift+n" # new_worktree = "prefix+shift+g" # open_worktree = "" # optional, unset by default @@ -143,7 +144,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. # [[keys.command]] -# key = "prefix+g" +# key = "prefix+alt+g" # type = "pane" # command = "lazygit" diff --git a/src/ui.rs b/src/ui.rs index 0ddaa71c..28504b9e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,6 +9,7 @@ mod dialogs; mod keybind_help; mod menus; mod mobile; +mod navigator; mod onboarding; mod panes; mod release_notes; @@ -33,6 +34,7 @@ use self::mobile::{ mobile_toast_banner_rect, render_mobile_header, render_mobile_panel, render_mobile_toast_banner, }; +use self::navigator::render_navigator_overlay; pub(crate) use self::onboarding::onboarding_welcome_continue_rect; use self::onboarding::render_onboarding_overlay; use self::panes::{compute_pane_infos, render_panes, resize_tab_panes}; @@ -393,6 +395,7 @@ pub fn render_with_runtime_registry( Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()), Mode::GlobalMenu => render_global_launcher_menu(app, frame), Mode::KeybindHelp => render_keybind_help_overlay(app, frame), + Mode::Navigator => render_navigator_overlay(app, frame), Mode::Terminal => {} } } diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 6fc778ea..c1f0fa74 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -79,6 +79,7 @@ pub(super) fn keybind_help_groups( let workspace_tab = vec![ (keybind_label(&kb.workspace_picker), "workspace navigation"), + (keybind_label(&kb.goto), "session navigator"), (keybind_label(&kb.new_workspace), "new workspace"), (keybind_label(&kb.new_worktree), "new worktree"), (keybind_label(&kb.open_worktree), "open worktree"), diff --git a/src/ui/menus.rs b/src/ui/menus.rs index c83b569f..c37d08d6 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -76,6 +76,7 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R let resize = prefix_rhs_label(&kb.resize_mode); let help = prefix_rhs_label(&kb.help); let settings = prefix_rhs_label(&kb.settings); + let goto = prefix_rhs_label(&kb.goto); let detach = prefix_rhs_label(&kb.detach); let line = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), @@ -86,6 +87,8 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R Span::styled(" ws ", dim), Span::styled("⇥", key), Span::styled(" pane ", dim), + Span::styled(goto, key), + Span::styled(" navigator ", dim), Span::styled(new_tab, key), Span::styled(" new tab ", dim), Span::styled(split_vertical, key), diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs new file mode 100644 index 00000000..5cb208ae --- /dev/null +++ b/src/ui/navigator.rs @@ -0,0 +1,480 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; + +use super::{ + scrollbar::{render_scrollbar, should_show_scrollbar}, + status::{agent_icon, state_label_color}, + widgets::{panel_contrast_fg, render_panel_shell}, +}; +use crate::app::state::{AppState, NavigatorRow, NavigatorStateFilter, NavigatorTarget}; + +pub(super) fn render_navigator_overlay(app: &AppState, frame: &mut Frame) { + let popup = app.navigator_popup_rect(); + let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg) + else { + return; + }; + + let search = app.navigator_search_rect(); + let body = app.navigator_body_rect(); + let detail = app.navigator_detail_rect(); + let footer = app.navigator_footer_rect(); + render_search(app, frame, search); + + if body.height > 0 { + render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); + render_rows(app, frame, body); + render_navigator_scrollbar(app, frame, body); + } + render_detail(app, frame, detail); + render_footer(app, frame, footer); +} + +fn render_search(app: &AppState, frame: &mut Frame, area: Rect) { + let p = &app.palette; + let focus_style = if app.navigator.search_focused { + Style::default().fg(p.accent).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.overlay0) + }; + let count = app + .workspaces + .iter() + .flat_map(|workspace| workspace.tabs.iter()) + .map(|tab| tab.panes.len()) + .sum::(); + let mut spans = vec![Span::styled(" / ", focus_style)]; + let query = app.navigator.query.trim(); + match app.navigator.state_filter { + Some(NavigatorStateFilter::Blocked) => push_state_chip( + &mut spans, + crate::detect::AgentState::Blocked, + true, + app.spinner_tick, + "blocked", + app, + ), + Some(NavigatorStateFilter::Working) => push_state_chip( + &mut spans, + crate::detect::AgentState::Working, + true, + app.spinner_tick, + "working", + app, + ), + Some(NavigatorStateFilter::Idle) => push_state_chip( + &mut spans, + crate::detect::AgentState::Idle, + true, + app.spinner_tick, + "idle", + app, + ), + Some(NavigatorStateFilter::Done) => push_state_chip( + &mut spans, + crate::detect::AgentState::Idle, + false, + app.spinner_tick, + "done", + app, + ), + None if query.is_empty() => spans.push(Span::styled( + "search panes", + Style::default().fg(p.overlay0), + )), + None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))), + } + spans.push(Span::styled( + format!( + "{count:>width$} panes", + width = area.width.saturating_sub(16) as usize + ), + Style::default().fg(p.overlay0), + )); + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn push_state_chip( + spans: &mut Vec>, + state: crate::detect::AgentState, + seen: bool, + tick: u32, + label: &'static str, + app: &AppState, +) { + let (icon, icon_style) = agent_icon(state, seen, tick, &app.palette); + spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + label, + Style::default() + .fg(state_label_color(state, seen, &app.palette)) + .add_modifier(Modifier::BOLD), + )); +} + +fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { + if area.height == 0 || area.width == 0 { + return; + } + let line = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(line).style(Style::default().fg(app.palette.surface1)), + area, + ); +} + +fn render_rows(app: &AppState, frame: &mut Frame, body: Rect) { + let rows = app.navigator_rows(); + let start = app.navigator.scroll.min(rows.len()); + let end = rows.len().min(start.saturating_add(body.height as usize)); + for (visible_idx, row) in rows[start..end].iter().enumerate() { + let idx = start + visible_idx; + let y = body.y + visible_idx as u16; + let rect = Rect::new(body.x, y, body.width, 1); + let selected = idx == app.navigator.selected; + render_row(app, frame, rect, row, selected); + } +} + +fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, selected: bool) { + let p = &app.palette; + frame.render_widget(Clear, rect); + let base_style = if selected { + Style::default().bg(p.accent).fg(panel_contrast_fg(p)) + } else { + Style::default().bg(p.panel_bg).fg(p.text) + }; + let dim_style = if selected { + base_style + } else { + Style::default().fg(p.overlay0).bg(p.panel_bg) + }; + let text_style = if selected { + base_style.add_modifier(Modifier::BOLD) + } else if row.is_current { + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0).bg(p.panel_bg) + }; + let (status_icon, status_style) = agent_icon(row.status, row.seen, app.spinner_tick, p); + let status_style = if selected { + base_style.add_modifier(Modifier::BOLD) + } else { + status_style.bg(p.panel_bg) + }; + + let prefix = if row.is_workspace { + if row.expanded { + "▾" + } else { + "▸" + } + } else if row.depth > 0 { + "├─" + } else { + " " + }; + let current = if row.is_current { "◆" } else { " " }; + let marker = if selected { "→" } else { " " }; + let indent = " ".repeat(row.depth as usize); + let left_fixed = format!(" {indent}{prefix} {marker} {current} "); + let meta_width = metadata_width(rect.width); + let left_budget = rect + .width + .saturating_sub(meta_width) + .saturating_sub(left_fixed.chars().count() as u16) + .saturating_sub(3) as usize; + let title = truncate_text(&row.label, left_budget); + + let spans = vec![ + Span::styled(left_fixed, dim_style), + Span::styled(status_icon, status_style), + Span::raw(" "), + Span::styled(title, text_style), + ]; + frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect); + + if meta_width > 0 { + let meta_rect = Rect::new( + rect.x + rect.width.saturating_sub(meta_width), + rect.y, + meta_width, + 1, + ); + let meta = truncate_text(&row.meta, meta_width.saturating_sub(2) as usize); + let meta_style = if selected { + base_style + } else if row.is_workspace || row.is_tab { + Style::default().fg(p.overlay0).bg(p.panel_bg) + } else { + Style::default() + .fg(state_label_color(row.status, row.seen, p)) + .bg(p.panel_bg) + }; + frame.render_widget( + Paragraph::new(format!(" {meta}")).style(meta_style), + meta_rect, + ); + } +} + +fn render_navigator_scrollbar(app: &AppState, frame: &mut Frame, body: Rect) { + if body.width <= 1 || body.height == 0 { + return; + } + let rows = app.navigator_rows().len(); + let viewport = body.height as usize; + if rows <= viewport { + return; + } + let metrics = crate::pane::ScrollMetrics { + viewport_rows: viewport, + offset_from_bottom: rows + .saturating_sub(viewport) + .saturating_sub(app.navigator.scroll), + max_offset_from_bottom: rows.saturating_sub(viewport), + }; + if !should_show_scrollbar(metrics) { + return; + } + let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height); + render_scrollbar( + frame, + metrics, + track, + app.palette.surface_dim, + app.palette.overlay0, + "▕", + ); +} + +fn metadata_width(width: u16) -> u16 { + if width >= 90 { + 28 + } else if width >= 68 { + 20 + } else if width >= 52 { + 14 + } else { + 0 + } +} + +fn render_detail(app: &AppState, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width == 0 { + return; + } + render_separator(frame, area, app); + let detail = selected_detail(app); + if detail.is_empty() { + return; + } + let text = middle_elide(&detail, area.width.saturating_sub(2) as usize); + frame.render_widget( + Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)), + area, + ); +} + +fn selected_detail(app: &AppState) -> String { + let rows = app.navigator_rows(); + let Some(row) = rows.get(app.navigator.selected) else { + return String::new(); + }; + match row.target { + NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, ws_idx), + NavigatorTarget::Tab { ws_idx, tab_idx } => tab_detail(app, ws_idx, tab_idx), + NavigatorTarget::Pane { + ws_idx, + tab_idx, + pane_id, + } => pane_detail(app, ws_idx, tab_idx, pane_id), + } +} + +fn workspace_detail(app: &AppState, ws_idx: usize) -> String { + let Some(ws) = app.workspaces.get(ws_idx) else { + return String::new(); + }; + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let label = ws.display_name_from(&app.terminals, &terminal_runtimes); + let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); + let mut parts = vec![label, format!("{pane_count} panes")]; + if !rowless_workspace_activity(app, ws_idx).is_empty() { + parts.push(rowless_workspace_activity(app, ws_idx)); + } + parts.join(" · ") +} + +fn tab_detail(app: &AppState, ws_idx: usize, tab_idx: usize) -> String { + let Some(ws) = app.workspaces.get(ws_idx) else { + return String::new(); + }; + let Some(tab) = ws.tabs.get(tab_idx) else { + return String::new(); + }; + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let mut parts = vec![ + ws.display_name_from(&app.terminals, &terminal_runtimes), + format!("tab: {}", tab.display_name()), + format!("{} panes", tab.panes.len()), + ]; + let rows = app.navigator_rows(); + if let Some(meta) = rows + .into_iter() + .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx)) + .map(|row| row.meta) + .filter(|meta| !meta.is_empty()) + { + parts.push(meta); + } + parts.join(" · ") +} + +fn pane_detail( + app: &AppState, + ws_idx: usize, + tab_idx: usize, + pane_id: crate::layout::PaneId, +) -> String { + let Some(ws) = app.workspaces.get(ws_idx) else { + return String::new(); + }; + let Some(tab) = ws.tabs.get(tab_idx) else { + return String::new(); + }; + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let mut parts = vec![ws.display_name_from(&app.terminals, &terminal_runtimes)]; + if ws.tabs.len() > 1 { + parts.push(format!("tab: {}", tab.display_name())); + } + if let Some(pane_number) = ws.public_pane_number(pane_id) { + parts.push(format!("pane {pane_number}")); + } + if let Some(terminal_id) = tab.terminal_id(pane_id) { + if let Some(terminal) = app.terminals.get(terminal_id) { + if let Some(agent) = terminal + .agent_name + .as_deref() + .or_else(|| terminal.effective_agent_label()) + { + parts.push(agent.to_string()); + let seen = tab + .panes + .get(&pane_id) + .map(|pane| pane.seen) + .unwrap_or(true); + parts.push( + display_state(row_state(app, ws_idx, tab_idx, pane_id), seen).to_string(), + ); + } else { + parts.push("shell".to_string()); + } + if let Some(status) = terminal.effective_custom_status() { + parts.push(status.to_string()); + } + } + } + parts.join(" · ") +} + +fn rowless_workspace_activity(app: &AppState, ws_idx: usize) -> String { + app.navigator_rows() + .into_iter() + .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx)) + .map(|row| row.meta) + .unwrap_or_default() +} + +fn row_state( + app: &AppState, + ws_idx: usize, + tab_idx: usize, + pane_id: crate::layout::PaneId, +) -> crate::detect::AgentState { + app.workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .and_then(|tab| tab.terminal_id(pane_id)) + .and_then(|terminal_id| app.terminals.get(terminal_id)) + .map(|terminal| terminal.state) + .unwrap_or(crate::detect::AgentState::Unknown) +} + +fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { + match (state, seen) { + (crate::detect::AgentState::Blocked, _) => "blocked", + (crate::detect::AgentState::Working, _) => "working", + (crate::detect::AgentState::Idle, false) => "done", + (crate::detect::AgentState::Idle, true) => "idle", + (crate::detect::AgentState::Unknown, _) => "unknown", + } +} + +fn middle_elide(text: &str, max_width: usize) -> String { + let len = text.chars().count(); + if len <= max_width { + return text.to_string(); + } + if max_width <= 1 { + return "…".to_string(); + } + let left = max_width.saturating_sub(1) / 2; + let right = max_width.saturating_sub(1).saturating_sub(left); + let prefix: String = text.chars().take(left).collect(); + let suffix: String = text + .chars() + .rev() + .take(right) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}…{suffix}") +} + +fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { + if area.height == 0 { + return; + } + let p = &app.palette; + let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); + let dim = Style::default().fg(p.overlay0); + let line = Line::from(vec![ + Span::styled(" enter", key), + Span::styled(" switch ", dim), + Span::styled("/", key), + Span::styled(" search ", dim), + Span::styled("b/w/i/d/a", key), + Span::styled(" states ", dim), + Span::styled("j/k/↑↓", key), + Span::styled(" move ", dim), + Span::styled("esc", key), + Span::styled(" close", dim), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn truncate_text(text: &str, max_width: usize) -> String { + let len = text.chars().count(); + if len <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect(); + format!("{prefix}…") +}