parent
a132a7c3b9
commit
80a314a908
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}…")
|
||||
}
|
||||
Loading…
Reference in New Issue