aboutsummaryrefslogtreecommitdiff
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
parenta132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff)
feat: add session navigator
refs #157
-rw-r--r--docs/next/CHANGELOG.md3
-rw-r--r--docs/next/README.md5
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx4
-rwxr-xr-xscripts/seed_navigator_demo.sh177
-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
-rw-r--r--src/config/keybinds.rs14
-rw-r--r--src/config/model.rs3
-rw-r--r--src/main.rs3
-rw-r--r--src/ui.rs3
-rw-r--r--src/ui/keybind_help.rs1
-rw-r--r--src/ui/menus.rs3
-rw-r--r--src/ui/navigator.rs480
18 files changed, 1739 insertions, 17 deletions
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 <<EOF
+Seeded navigator demo data via $HERDR_SOCKET_PATH
+
+Workspaces:
+ $WS1 nav-${stamp}-claude-review claude blocked + claude working + shell
+ $WS2 nav-${stamp}-codex-build codex working x2 + idle + done, tab $T2B
+ $WS3 nav-${stamp}-finished claude done x2 + codex idle
+ $WS4 nav-${stamp}-quiet shell x2 + pi idle, tab $T4B
+ $WS5 nav-${stamp}-mixed blocked x2 + working x2 + idle, tab $T5B
+ $WS6 nav-${stamp}-dense blocked + working x4 + idle + done, tab $T6B
+
+Sparse naming is intentional. Most panes have no manual names.
+Test in navigator: / then b, w, i, d, a, or normal text.
+EOF
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,
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!(
@@ -1099,6 +1101,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();
assert!(kb.open_worktree.bindings.is_empty());
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::<usize>();
+ let mut spans = vec![Span::styled(" / ", focus_style)];
+ let query = app.navigator.query.trim();
+ match app.navigator.state_filter {
+ Some(NavigatorStateFilter::Blocked) => push_state_chip(
+ &mut spans,
+ crate::detect::AgentState::Blocked,
+ true,
+ 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<Span<'static>>,
+ 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::<usize>();
+ 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::<Vec<_>>()
+ .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}…")
+}