feat: add session navigator

refs #157
This commit is contained in:
Ogulcan Celik 2026-05-24 02:57:03 +03:00
parent a132a7c3b9
commit 80a314a908
18 changed files with 1739 additions and 17 deletions

View File

@ -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

View File

@ -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"
```

View File

@ -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"
```

177
scripts/seed_navigator_demo.sh Executable file
View File

@ -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

View File

@ -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)
@ -94,6 +97,489 @@ pub struct PaneStateUpdate {
pub custom_status: Option<String>,
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@ -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"]);

View File

@ -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!(),
}
}

View File

@ -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),

View File

@ -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);

View File

@ -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));

View File

@ -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

View File

@ -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,

View File

@ -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();

View File

@ -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"),

View File

@ -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"

View File

@ -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 => {}
}
}

View File

@ -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"),

View File

@ -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),

480
src/ui/navigator.rs Normal file
View File

@ -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}")
}