Compare commits

..
Author SHA1 Message Date
Miguel Palhas 9d6b69cc49 feat(maestro): add the TUI command bar's > and ~ sigils
`>` narrows to commands from the live keymap for the current mode, matched
on the action's help_label only — the chord is displayed, never matched, so
results cannot depend on a user's rebinds. Rows dedupe by action, not by
chord: the default map binds both `enter` and `tab` to AttachHighlighted,
and AttachHighlighted and ToggleAttachHighlighted share a label because they
are the same match arm.

`~` narrows to recent dirs plus the project list. Enter takes the
fast-forward path pinned to that directory, with no recency fallback, so a
directory outside the project scan opens the pre-filled form rather than
spawning elsewhere.

Both fall out of parsing the query line, as in web/src/commandBar.ts: a
sigil is only a sigil in first position, and backspacing past one is just
the query becoming mixed again.
2026-09-03 17:17:27 +01:00
Miguel PalhasandClaude Opus 5 b2cbb23fe2 feat(maestro): open the bar with ctrl-k over a focused pane
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.

Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:03:19 +01:00
Miguel PalhasandClaude Opus 5 9fe9fe4628 feat(maestro): add the web command bar's > and ~ sigils
`>` narrows the bar to commands, matched on the label only with the chord
shown beside each row and deduped by action. `~` narrows to directories over
recent dirs and the project list, filled in from /api/dirs as a path is typed;
Enter fast-forward-spawns there, falling back to the pre-filled spawn form
when a default cannot resolve. Without a sigil the list stays mixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:56:41 +01:00
Miguel Palhas e6a254b734 chore(maestro): drop the worker task file committed with #153
TASK.md is a blitz worker's prompt, not repo content.
2026-09-03 16:47:34 +01:00
13 changed files with 1688 additions and 243 deletions
-115
View File
@@ -1,115 +0,0 @@
# Task: Gitea issue #153 — the TUI command bar (action, overlay, session results)
You are an autonomous worker in a blitz over the **Command bar** milestone of
the `naps62/maestro` Gitea repo (`https://git.naps.pt`, `$GITEA_TOKEN` is in
`~/.env.claude` — `source` it).
Both of your dependencies are merged and on your base branch.
## Read first
1. **`docs/COMMAND-BAR.md`** — the contract. "Opening", "Structure", "Empty
query" and "Keys" are yours. Implement what it says.
2. The issue body:
`curl -sS -H "Authorization: token $GITEA_TOKEN" https://git.naps.pt/api/v1/repos/naps62/maestro/issues/153 | jq -r .body`
3. `CLAUDE.md` — conventions. **TDD by default**; `cargo fmt` and
`cargo clippy --all-targets -- -D warnings` before every commit.
4. What you build on, all merged:
- `crates/maestro-tui/src/matcher.rs` (#147) — the match-and-rank function
and its candidate types. **Do not reimplement matching or reorder its
results.**
- `crates/maestro-protocol` — `Session::last_focused_at` (#145), the
daemon's recency stamp.
- `crates/maestro-tui/src/`: `action.rs`, `keymap.rs`, `config.rs`
(`DEFAULT_NORMAL` / `DEFAULT_INSERT`), `state.rs` (`OverlayKind`),
`overlays.rs`, `app.rs` (`handle_overlay_input` and the dispatcher),
`text_input.rs` (the edit buffer the rename prompt already uses).
5. **`web/src/commandBar.ts`** (#149) — the web bar implementing this same
contract, already merged. Read it, especially `orderSessionsForBar`. Where
the doc leaves room, match what it does; the two clients disagreeing is the
failure this contract exists to prevent.
## What to build
- **`Action::OpenCommandBar`** — new variant, with its `parse` arm, `Display`
arm, `help_label`, and dispatcher arm. `Action::parse` and `Display` must
round-trip, as the existing tests require of every variant.
- **Bind `ctrl-k` in both `DEFAULT_NORMAL` and `DEFAULT_INSERT`**, so it opens
from the sidebar and from inside a pane. Because it is an ordinary keymap
entry it stays rebindable, and the help overlay — which builds its lines from
the live keymap — picks it up with no second list.
- **A new `OverlayKind` variant**, routed in `handle_overlay_input` alongside
Spawn, KillConfirm, Rename and Help. Follow how `Rename` keeps its edit
buffer on the app rather than in the enum, so the overlay enum stays
comparable.
- **Empty query lists sessions, most recently focused first**, by
`last_focused_at`. The doc fixes what happens to never-focused and dead
sessions — follow it exactly, and match `orderSessionsForBar`.
- **Typing fuzzy-matches sessions** through `matcher.rs`, with matched ranges
highlighted in the rendered row. Build candidates from the fields the doc's
candidate table names, directory abbreviated as the sidebar shows it.
- **Results render under kind headers**, groups ordered by best-scoring member,
highlight starting on the globally best row. Only sessions exist as a kind
here, but build the grouping so #154 can add commands and directories without
restructuring it.
- **Enter attaches the highlighted session.** Escape closes. Up/down and
`ctrl-p`/`ctrl-n` move the highlight, which does not wrap; headers are never
landed on.
**Module name**: `palette.rs` is the colour palette. Do not put this there and
do not name your module `palette`.
## Non-goals — later issues own these, do not build them
- The `>` and `~` sigils (#154).
- Tab drilling into a session's action page (#155).
- The leader sending a literal Ctrl-K to the pane (#156).
Build the grouping and query line so those slot in without a rewrite, but do
not implement them.
## Ground rules
- You are on `command-bar/153-tui-bar`, cut from `origin/main`. **Merge
`origin/blitz/command-bar` first** (merge, never rebase) — everything you
depend on is there and not on `main`. Merge it again if it moves under you.
- Stay inside `crates/`. Do not touch `web/`, and do not modify `matcher.rs`;
if you believe it is wrong, comment on the issue instead.
- Integration tests use **real tmux** — don't mock it; isolate with a temp
`$HOME` and `TMUX_TMPDIR` the way the existing tests do. `overlays.rs` shows
how the existing overlays are tested against a `TestBackend`.
- Run the suite at most once at the end, through the gate:
`/home/naps62/.claude/skills/tracker-common/scripts/gate.sh -- cargo test --workspace`.
Exit 75 means no slot — a normal outcome, not a failure: say so and push
anyway. Exit 137 is the memory cap, not a test failure.
- Report the **aggregate test count**, not one crate's.
- Commit convention: `feat(maestro): ...`.
- Bugs or problems outside this issue's scope: **comment them on issue #153**,
do not fix them silently. Earlier workers' reports changed the scope of two
later issues before they started.
- Never wait for input.
## Exit criteria
Do not end your final reply until ALL of these are true:
1. `Action::OpenCommandBar` exists, round-trips through `parse`/`Display`, and
has a plain-language `help_label`.
2. `ctrl-k` is bound in both the Normal and Insert default keymaps and opens
the bar from each.
3. Empty query lists sessions by recency, with the doc's rules for
never-focused and dead sessions, matching `orderSessionsForBar`.
4. Typing ranks through `matcher.rs` with ranges highlighted, under kind
headers.
5. Enter attaches; Escape, arrows and `ctrl-p`/`ctrl-n` behave as the doc's key
table says.
6. `cargo fmt` clean, `cargo clippy --all-targets -- -D warnings` clean, suite
green (or the gate returned 75/137 and you said which).
7. Committed on `command-bar/153-tui-bar` and the branch **pushed** to
`origin`.
Do **not** merge into `blitz/command-bar` and do **not** close the issue — the
orchestrator does both.
If you catch yourself summarizing progress before those seven are true, you
have stopped too early — keep going.
+306 -16
View File
@@ -168,6 +168,14 @@ pub enum Effect {
/// Fast-forward spawn (#67): resolve every default from the daemon and
/// spawn with no form; unresolvable defaults open a pre-filled form.
FastForward,
/// The same fast path pinned to one directory — the command bar's `~`
/// (#154). Nothing may override the directory the operator named.
FastForwardIn(PathBuf),
/// Fetch the command bar's `~` candidates: recent dirs and the project
/// list. `bar` guards a load landing in a bar that was reopened since.
LoadBarDirectories {
bar: u64,
},
/// The form's target changed: probe `/api/gitroot` so the worktree
/// section knows its base repo (#65). `seq` guards stale answers.
ProbeGitRoot {
@@ -386,6 +394,8 @@ impl App {
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft => self.spawn_load_draft(),
Effect::FastForward => self.spawn_fast_forward(),
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
@@ -459,6 +469,16 @@ impl App {
self.dispatch(Action::AttachHighlighted),
)
}
// The bar picks the action and hands off; it never
// reimplements one.
BarOutcome::Open(BarTarget::Command(action)) => {
self.close_command_bar();
(OverlayDecision::Consumed, self.dispatch(action))
}
BarOutcome::Open(BarTarget::Directory(dir)) => {
self.close_command_bar();
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
}
}
}
Some(OverlayKind::Help) => {
@@ -506,7 +526,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenCommandBar => self.open_command_bar(),
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
@@ -628,12 +648,21 @@ impl App {
/// while a session is previewed discards the preview first, like any
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
/// so closing gives the keyboard back to whatever held it.
fn open_command_bar(&mut self) {
fn open_command_bar(&mut self) -> Vec<Effect> {
let snapshot = self.catalog.snapshot();
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
// The keymap for the mode the bar was opened from, which is the one
// the operator would be typing into behind it. Same source the help
// overlay reads, so the two can never disagree.
let keymap = self.cfg.keymap.entries(self.state.mode);
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
let id = bar.id();
self.command_bar = Some(bar);
self.pending_preview = None;
self.clear_preview();
self.state.show_overlay(OverlayKind::CommandBar);
// `~`'s candidates are a network round trip; the bar opens without
// them and fills in when they land.
vec![Effect::LoadBarDirectories { bar: id }]
}
/// Take the bar down, dropping its query. Every exit goes through here so
@@ -990,18 +1019,27 @@ impl App {
projects,
names,
focused_cwd,
pinned,
} = update
{
self.fast_forward_spawn(info, projects, names, focused_cwd)
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
.await;
return;
}
// Addressed to the bar, not to a form: a load that outlived its bar is
// dropped rather than filling in the next one.
if let DraftUpdate::BarDirectories { bar, paths } = update {
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
open.set_directories(&paths);
}
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
// Handled above; they target no particular draft.
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
};
// Match by identity: dismissing the form and opening another while a
// load is in flight must not let the old answer land in the new form.
@@ -1036,7 +1074,9 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
unreachable!()
}
}
if follow_up_branches {
self.spawn_load_branches();
@@ -1064,16 +1104,52 @@ impl App {
projects: projects.ok(),
names,
focused_cwd,
pinned: None,
});
});
}
/// The same fast path, pinned to the directory the command bar's `~` row
/// named (#154). Off the loop for the same reason as [`Self::
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
fn spawn_fast_forward_in(&self, dir: PathBuf) {
let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
let _ = tx.send(DraftUpdate::FastForward {
info: info.ok(),
projects: projects.ok(),
names,
focused_cwd: None,
pinned: Some(dir),
});
});
}
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
/// (newest first) ahead of the project list. Either failing is a shorter
/// list, not an error state.
fn spawn_load_bar_directories(&self, bar: u64) {
let daemon = self.daemon.clone();
let tx = self.draft_tx.clone();
tokio::spawn(async move {
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
let mut paths: Vec<PathBuf> = recents
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
.unwrap_or_default();
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
});
}
async fn fast_forward_spawn(
&mut self,
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
pinned: Option<PathBuf>,
) {
// Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -1087,13 +1163,21 @@ impl App {
_ => Vec::new(),
};
let discovered = projects.unwrap_or_default();
let resolved = crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
// A pinned directory is the operator's own pick, so it takes no
// fallback: a directory outside the project scan resolves no project,
// which is an unresolvable default and opens the form seeded with it.
let resolved = match pinned.as_deref() {
Some(dir) => {
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
}
None => crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
),
};
if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await;
return;
@@ -1105,8 +1189,10 @@ impl App {
};
draft.home_dir = home_dir;
// The fast path resolved its own project; its pick wins over the
// focused session's, so the seed never lands on top of it.
draft.seed_cwd = None;
// focused session's, so the seed never lands on top of it. A pinned
// directory is the exception: it is the seed, whether or not the scan
// knew it as a project.
draft.seed_cwd = pinned;
if !presets.is_empty() {
draft.set_presets(presets);
}
@@ -1463,7 +1549,12 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
/// The command bar pinned the spawn to this directory (#154): nothing
/// — not the focused session, not the recency ranking — may move it.
pinned: Option<PathBuf>,
},
/// The command bar's `~` candidates arrived (#154).
BarDirectories { bar: u64, paths: Vec<PathBuf> },
}
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -2222,6 +2313,102 @@ mod tests {
assert_eq!(req["worktree"]["create_branch"], json!(true));
}
/// The command bar's `~` pins the spawn: the directory the operator named
/// wins over the recency ranking that plain fast-forward would follow.
#[tokio::test]
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
use axum::routing::{get, post};
use axum::Json;
use serde_json::json;
use std::sync::{Arc, Mutex};
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
"last_command": "zsh"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
seen.lock().unwrap().push(body);
Json(json!({
"id": uuid::Uuid::from_u128(78).to_string(),
"cwd": "/repos/pinned", "command": "zsh",
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names))
.route(
maestro_protocol::routes::SESSIONS,
post(spawn_session).with_state(seen),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
.await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
let posts = posted.lock().unwrap();
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
assert_eq!(posts[0]["command"], json!("zsh"));
}
/// A directory the project scan does not know resolves no project, which
/// is an unresolvable default: the form opens seeded with it rather than
/// the spawn silently landing somewhere else.
#[tokio::test]
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
let mut app = make_app();
app.fast_forward_spawn(
None,
Some(vec![maestro_protocol::Project {
path: PathBuf::from("/repos/fresh"),
name: "fresh".into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now()),
last_command: Some("aider".into()),
}]),
vec!["crimson-otter".into()],
None,
Some(PathBuf::from("/elsewhere")),
)
.await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
let draft = app.draft.as_ref().expect("form is up");
// The seed is dropped by the picker when no project matches it, so
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
}
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
/// switching which session the pane shows, and leaving the pane back to
/// the sidebar. A sidebar highlight alone must report nothing.
@@ -2850,12 +3037,115 @@ mod tests {
.iter()
.map(|r| match r.target {
BarTarget::Session(id) => id,
ref other => panic!("expected a session row, got {other:?}"),
})
.collect::<Vec<_>>(),
vec![bravo, alpha]
);
}
/// `~`'s candidates are a network round trip, so opening asks for them
/// rather than blocking the keystroke on one.
#[test]
fn opening_the_bar_asks_for_its_directories() {
let mut app = make_app();
let effects = app.dispatch(Action::OpenCommandBar);
let bar = app.command_bar.as_ref().unwrap().id();
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
}
fn type_into_bar(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_overlay_input(&InputKey::Char {
c,
ctrl: false,
alt: false,
shift: false,
});
}
}
/// `>` lists the live keymap for the current mode, matched on the label
/// alone, and Enter hands the action to the ordinary dispatcher.
#[test]
fn enter_on_a_command_row_dispatches_the_action() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">defaults");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Create session with defaults");
assert_eq!(
row.target,
BarTarget::Command(Action::FastForwardSpawn),
"chords: {:?}",
row.chords
);
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::FastForward]);
assert!(app.state.overlay().is_none());
assert!(app.command_bar.is_none());
}
/// The row shows the chord bound to it, and both chords when the map binds
/// two — but the chord is never what matched.
#[test]
fn a_command_row_carries_its_chords() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">open selected");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Open selected item");
assert!(
row.chords.iter().any(|c| c == "enter"),
"chords: {:?}",
row.chords
);
assert!(
row.chords.len() > 1,
"the default map binds more than one chord to it: {:?}",
row.chords
);
}
#[test]
fn enter_on_a_directory_row_fast_forward_spawns_there() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
app.command_bar
.as_mut()
.unwrap()
.set_directories(&[PathBuf::from("/repos/api")]);
type_into_bar(&mut app, "~api");
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(
effects,
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
);
assert!(app.state.overlay().is_none());
}
/// A load that outlived its bar fills in nothing: the reopened bar has its
/// own identity.
#[tokio::test]
async fn a_stale_directory_load_is_dropped() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
let stale = app.command_bar.as_ref().unwrap().id();
app.handle_overlay_input(&InputKey::Esc);
app.dispatch(Action::OpenCommandBar);
app.apply_draft_update(DraftUpdate::BarDirectories {
bar: stale,
paths: vec![PathBuf::from("/repos/api")],
})
.await;
type_into_bar(&mut app, "~");
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
}
/// Opening from Insert mode leaves the keyboard where it was, so closing
/// gives it back — the bar is a view over the app, not a place in it.
#[test]
+523 -59
View File
@@ -1,15 +1,17 @@
//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a
//! result list, opened from Normal or Insert mode, closed with Escape.
//!
//! Sessions are the only kind here (#153); the sigils (#154), the session
//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The
//! grouping below is already kind-generic so those slot in without a rewrite.
//! Three kinds live here: sessions and commands, ranked together by default,
//! and directories, which need the `~` sigil (#154). The session action page
//! (#155) and the leader's literal Ctrl-K (#156) land later.
//!
//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the
//! groups are a rendering of the ranked list, in the order their best-scoring
//! member appears. The web bar (`web/src/commandBar.ts`) implements the same
//! contract; the two must agree.
use std::path::{Path, PathBuf};
use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path};
use maestro_protocol::{Session, SessionStatus};
use ratatui::layout::Rect;
@@ -19,19 +21,73 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
use crate::action::Action;
use crate::input::InputKey;
use crate::matcher::{
match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate,
match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate,
MatchFieldName, MatchRange, SessionCandidate,
};
use crate::overlays::centered;
use crate::palette;
use crate::text_input::TextInput;
/// What Enter on a row does. Only sessions exist today; `>` commands and `~`
/// directories (#154) become further variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Identity for routing an async directory load back to *this* bar: a load in
/// flight must not land in a bar the operator closed and reopened.
static NEXT_BAR_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// What Enter on a row does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BarTarget {
Session(Uuid),
/// Dispatch the action the row stands for. The chord is displayed beside
/// the row but is not part of the target: two chords bound to one action
/// are one row.
Command(Action),
/// Fast-forward spawn in this directory, absolute — the abbreviated form
/// is what the row shows and what the matcher ranked.
Directory(PathBuf),
}
/// The first character of the query, when it is one of the two that narrow the
/// list to a single kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarSigil {
/// `>` — commands from the live keymap.
Command,
/// `~` — recent dirs and the project list.
Directory,
}
/// A raw query line split into its sigil and the query proper.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BarQuery<'q> {
pub sigil: Option<BarSigil>,
/// What the matcher sees: everything after a sigil, or the whole query.
pub query: &'q str,
}
/// Split a raw query line into its sigil and the query proper.
///
/// A sigil is only a sigil in first position: `>` or `~` anywhere else is a
/// literal query character, which is what a directory query with a second `~`
/// in it depends on. Backspacing past a sigil needs no special case either —
/// the first character goes and the query is a mixed one again. Mirrors the
/// web's `parseBarQuery`.
pub fn parse_bar_query(raw: &str) -> BarQuery<'_> {
let sigil = match raw.chars().next() {
Some('>') => BarSigil::Command,
Some('~') => BarSigil::Directory,
_ => {
return BarQuery {
sigil: None,
query: raw,
}
}
};
BarQuery {
sigil: Some(sigil),
query: &raw[1..],
}
}
/// One row of the result list, already resolved to what it renders and what it
@@ -50,6 +106,9 @@ pub struct BarRow {
pub field: Option<MatchFieldName>,
pub ranges: Vec<MatchRange>,
pub dead: bool,
/// Every chord bound to a command row's action, displayed beside it and
/// never matched. Empty for every other kind.
pub chords: Vec<String>,
}
impl BarRow {
@@ -78,7 +137,7 @@ fn kind_title(kind: CandidateKind) -> &'static str {
}
/// What a keystroke asked the bar to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BarOutcome {
/// The bar stays up.
Stay,
@@ -128,40 +187,112 @@ pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate
}
}
/// One command row: a distinct [`Action`] and every chord bound to it.
///
/// Dedupe is by **action, never by chord** — the default map binds both
/// `enter` and `tab` to `AttachHighlighted`, and listed naively that is two
/// identical rows. Actions that share a `help_label` collapse too:
/// `AttachHighlighted` and `ToggleAttachHighlighted` are the same match arm in
/// `app.rs` and genuinely do the same thing, so they are one row carrying both
/// chords rather than two rows a reader cannot tell apart. `Noop` is left out:
/// it exists to suppress a default binding, not as something to run.
///
/// Input order is the keymap's own (`Keymap::entries` sorts by chord), and the
/// first mention of each action keeps its place — the registry order the
/// contract asks an empty `>` query to list.
pub fn command_entries(entries: &[(String, Action)]) -> Vec<(Action, Vec<String>)> {
let mut out: Vec<(Action, Vec<String>)> = Vec::new();
for (chord, action) in entries {
if *action == Action::Noop {
continue;
}
let label = action.help_label();
match out
.iter_mut()
.find(|(kept, _)| kept == action || kept.help_label() == label)
{
Some((_, chords)) => {
if !chords.iter().any(|c| c == chord) {
chords.push(chord.clone());
}
}
None => out.push((action.clone(), vec![chord.clone()])),
}
}
out
}
/// One candidate plus what it renders and what Enter does with it. The matcher
/// never sees anything but `candidate`.
#[derive(Debug, Clone, PartialEq, Eq)]
struct BarEntry {
candidate: MatchCandidate,
target: BarTarget,
dead: bool,
chords: Vec<String>,
}
impl BarEntry {
fn candidate_kind(&self) -> CandidateKind {
match self.candidate {
MatchCandidate::Session(_) => CandidateKind::Session,
MatchCandidate::Command(_) => CandidateKind::Command,
MatchCandidate::Directory(_) => CandidateKind::Directory,
}
}
}
/// The bar's state while it is up: the query buffer, the candidates it was
/// opened over, the rows the current query produces, and the highlight.
#[derive(Debug)]
pub struct CommandBar {
/// Identity for routing an async directory load back to this bar.
id: u64,
input: TextInput,
/// Matcher input, in the empty-query order. Never reordered — the matcher's
/// last tie-break is what keeps that order meaningful.
candidates: Vec<MatchCandidate>,
/// What each candidate opens, parallel to `candidates`.
targets: Vec<BarTarget>,
/// Whether each candidate's session is dead, parallel to `candidates`.
dead: Vec<bool>,
home_dir: String,
/// Matcher input per kind, in the empty-query order. Never reordered —
/// the matcher's last tie-break is what keeps that order meaningful.
sessions: Vec<BarEntry>,
commands: Vec<BarEntry>,
directories: Vec<BarEntry>,
rows: Vec<BarRow>,
highlight: usize,
}
impl CommandBar {
/// Open the bar over the sessions the catalog has right now.
pub fn new(sessions: &[Session], home_dir: &str) -> Self {
/// Open the bar over the sessions the catalog has right now and the live
/// keymap for the current mode. Directories arrive later
/// ([`Self::set_directories`]) — they are a network round trip and the
/// keystroke must not wait on one.
pub fn new(sessions: &[Session], keymap: &[(String, Action)], home_dir: &str) -> Self {
let ordered = order_sessions_for_bar(sessions);
let candidates = ordered
let sessions = ordered
.iter()
.map(|s| MatchCandidate::Session(session_candidate(s, home_dir)))
.map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead,
chords: Vec::new(),
})
.collect();
let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect();
let dead = ordered
.iter()
.map(|s| s.status == SessionStatus::Dead)
let commands = command_entries(keymap)
.into_iter()
.map(|(action, chords)| BarEntry {
candidate: MatchCandidate::Command(CommandCandidate {
label: action.help_label(),
}),
target: BarTarget::Command(action),
dead: false,
chords,
})
.collect();
let mut bar = Self {
id: NEXT_BAR_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
input: TextInput::default(),
candidates,
targets,
dead,
home_dir: home_dir.to_string(),
sessions,
commands,
directories: Vec::new(),
rows: Vec::new(),
highlight: 0,
};
@@ -169,6 +300,34 @@ impl CommandBar {
bar
}
pub fn id(&self) -> u64 {
self.id
}
/// Hand the bar `~`'s candidates once the daemon has answered: recent dirs
/// (newest first) ahead of the project list, deduped by the directory a
/// spawn would land in, keeping the first mention of each.
pub fn set_directories(&mut self, paths: &[PathBuf]) {
let mut seen: Vec<&Path> = Vec::new();
let mut entries = Vec::new();
for path in paths {
if path.as_os_str().is_empty() || seen.contains(&path.as_path()) {
continue;
}
seen.push(path.as_path());
entries.push(BarEntry {
candidate: MatchCandidate::Directory(DirectoryCandidate {
path: abbreviate_dir(path, &self.home_dir),
}),
target: BarTarget::Directory(path.clone()),
dead: false,
chords: Vec::new(),
});
}
self.directories = entries;
self.recompute();
}
pub fn query(&self) -> &str {
self.input.value()
}
@@ -224,7 +383,7 @@ impl CommandBar {
match key {
InputKey::Esc => BarOutcome::Close,
InputKey::Enter => match self.selected() {
Some(row) => BarOutcome::Open(row.target),
Some(row) => BarOutcome::Open(row.target.clone()),
// Nothing to open: the bar stays up rather than closing on a
// key that did nothing.
None => BarOutcome::Stay,
@@ -257,31 +416,58 @@ impl CommandBar {
}
}
/// The sigil in force for the query as it stands.
pub fn sigil(&self) -> Option<BarSigil> {
parse_bar_query(self.input.value()).sigil
}
/// The candidates the query line asks for. A sigil narrows to one kind;
/// with no sigil the list is mixed — sessions and commands ranked together
/// — except on an empty query, where the bar is a session switcher.
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
match parsed.sigil {
Some(BarSigil::Command) => self.commands.iter().collect(),
Some(BarSigil::Directory) => self.directories.iter().collect(),
None if parsed.query.is_empty() => self.sessions.iter().collect(),
None => self.sessions.iter().chain(self.commands.iter()).collect(),
}
}
/// Re-rank for the current query. The highlight starts on the globally
/// best row every time the list changes: an old index would point at a
/// different result.
fn recompute(&mut self) {
let rows: Vec<BarRow> = {
let results = match_and_rank(self.input.value(), &self.candidates);
results
let parsed = parse_bar_query(self.input.value());
let entries = self.active_entries(parsed);
let candidates: Vec<MatchCandidate> =
entries.iter().map(|e| e.candidate.clone()).collect();
match_and_rank(parsed.query, &candidates)
.iter()
.map(|result| {
let index = self
.candidates
let index = candidates
.iter()
.position(|c| std::ptr::eq(c, result.candidate))
.expect("every result borrows one of this bar's candidates");
let MatchCandidate::Session(session) = result.candidate else {
unreachable!("sessions are the only kind the bar builds (#153)")
.expect("every result borrows one of this query's candidates");
let entry = entries[index];
let (name, meta) = match &entry.candidate {
MatchCandidate::Session(session) => {
(session.name.clone(), meta_text(session, result.field))
}
// A command's label and a directory's path are the row
// itself; neither has a second field to fall back to.
MatchCandidate::Command(command) => (command.label.clone(), None),
MatchCandidate::Directory(directory) => (directory.path.clone(), None),
};
BarRow {
kind: CandidateKind::Session,
target: self.targets[index],
name: session.name.clone(),
meta: meta_text(session, result.field),
kind: entry.candidate_kind(),
target: entry.target.clone(),
name,
meta,
field: result.field,
ranges: result.ranges.clone(),
dead: self.dead[index],
dead: entry.dead,
chords: entry.chords.clone(),
}
})
.collect()
@@ -297,7 +483,11 @@ impl CommandBar {
let area = centered(parent, 72, height);
f.render_widget(Clear, area);
let block = Block::default()
.title(" go to ")
.title(match self.sigil() {
Some(BarSigil::Command) => " run a command ",
Some(BarSigil::Directory) => " spawn in ",
None => " go to ",
})
.borders(Borders::ALL)
.style(pal.card.base())
.border_style(pal.border_style(true));
@@ -349,11 +539,16 @@ impl CommandBar {
// sits on so the scroll can keep it visible.
let mut lines: Vec<Line<'static>> = Vec::new();
let mut highlight_line = 0usize;
// While a sigil is in force there is one kind, so the header is
// dropped — it would name what the whole list already is.
let headers = self.sigil().is_none();
for group in self.groups() {
lines.push(Line::from(Span::styled(
group.title.to_string(),
pal.group_row_style(),
)));
if headers {
lines.push(Line::from(Span::styled(
group.title.to_string(),
pal.group_row_style(),
)));
}
for index in group.start..group.start + group.len {
if index == self.highlight {
highlight_line = lines.len();
@@ -384,6 +579,12 @@ impl CommandBar {
&row.ranges
};
spans.extend(highlighted_spans(&row.name, name_ranges, base, hit));
// The chord is teaching material, not a match target: it comes from
// the live keymap and is never fed to the matcher.
if !row.chords.is_empty() {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(row.chords.join(", "), surface.muted_style()));
}
if let Some(meta) = &row.meta {
let meta_style = surface.muted_style();
let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
@@ -485,6 +686,7 @@ mod tests {
rows.iter()
.map(|r| match r.target {
BarTarget::Session(id) => id,
ref other => panic!("expected a session row, got {other:?}"),
})
.collect()
}
@@ -509,7 +711,7 @@ mod tests {
let mut c = named(3, "charlie");
c.last_focused_at = Some(at(20));
let bar = CommandBar::new(&[a, b, c], "/home/me");
let bar = CommandBar::new(&[a, b, c], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)]
@@ -527,7 +729,7 @@ mod tests {
let mut newer = named(3, "newer");
newer.created_at = at(20);
let bar = CommandBar::new(&[older, newer, focused], "/home/me");
let bar = CommandBar::new(&[older, newer, focused], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)]
@@ -542,7 +744,7 @@ mod tests {
dead.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dead, live], "/home/me");
let bar = CommandBar::new(&[dead, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
@@ -552,7 +754,11 @@ mod tests {
#[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me");
let mut bar = CommandBar::new(
&[named(1, "web-search"), named(2, "websearch")],
&[],
"/home/me",
);
typed(&mut bar, "ws");
// F9: the `s` after `-` earns the boundary bonus.
assert_eq!(
@@ -565,7 +771,7 @@ mod tests {
#[test]
fn a_query_matching_nothing_leaves_no_rows() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
assert!(bar.rows().is_empty());
assert!(bar.selected().is_none());
@@ -577,7 +783,7 @@ mod tests {
fn a_directory_match_highlights_in_the_meta_text() {
let mut worker = named(1, "worker");
worker.cwd = PathBuf::from("/home/me/dev/api");
let mut bar = CommandBar::new(&[worker], "/home/me");
let mut bar = CommandBar::new(&[worker], &[], "/home/me");
typed(&mut bar, "api");
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory));
assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api"));
@@ -590,7 +796,7 @@ mod tests {
fn the_tool_field_is_a_candidate_field() {
let mut nameless = named(1, "nameless");
nameless.command = "claude --resume".into();
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me");
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], &[], "/home/me");
typed(&mut bar, "claude");
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]);
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool));
@@ -598,7 +804,7 @@ mod tests {
#[test]
fn results_group_under_kind_headers_with_the_best_row_first() {
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
let groups = bar.groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title, "Sessions");
@@ -612,6 +818,7 @@ mod tests {
fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() {
let mut bar = CommandBar::new(
&[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")],
&[],
"/home/me",
);
assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay);
@@ -640,7 +847,7 @@ mod tests {
#[test]
fn enter_opens_the_highlighted_session_and_escape_closes() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
bar.handle_key(&InputKey::Down);
assert_eq!(
bar.handle_key(&InputKey::Enter),
@@ -651,14 +858,14 @@ mod tests {
#[test]
fn enter_with_no_results_leaves_the_bar_up() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay);
}
#[test]
fn editing_the_query_re_ranks_and_resets_the_highlight() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
bar.handle_key(&InputKey::Down);
assert_eq!(bar.highlight(), 1);
typed(&mut bar, "alp");
@@ -676,7 +883,7 @@ mod tests {
let mut recent = named(1, "alpha");
recent.last_focused_at = Some(at(50));
let older = named(2, "alpine");
let mut bar = CommandBar::new(&[older, recent], "/home/me");
let mut bar = CommandBar::new(&[older, recent], &[], "/home/me");
typed(&mut bar, "alpi");
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]);
for _ in 0..4 {
@@ -689,6 +896,251 @@ mod tests {
);
}
fn keymap(pairs: &[(&str, Action)]) -> Vec<(String, Action)> {
pairs
.iter()
.map(|(chord, action)| (chord.to_string(), action.clone()))
.collect()
}
fn labels(rows: &[BarRow]) -> Vec<String> {
rows.iter().map(|r| r.name.clone()).collect()
}
// ---- sigil parsing ----
#[test]
fn a_leading_sigil_is_a_sigil_and_the_rest_is_the_query() {
assert_eq!(
parse_bar_query(">kill"),
BarQuery {
sigil: Some(BarSigil::Command),
query: "kill"
}
);
assert_eq!(
parse_bar_query("~dev"),
BarQuery {
sigil: Some(BarSigil::Directory),
query: "dev"
}
);
assert_eq!(
parse_bar_query("api"),
BarQuery {
sigil: None,
query: "api"
}
);
assert_eq!(
parse_bar_query(""),
BarQuery {
sigil: None,
query: ""
}
);
}
/// A directory query is where a second `~` is most likely to be typed, and
/// it must reach the matcher.
#[test]
fn a_sigil_anywhere_but_first_is_a_literal_query_character() {
assert_eq!(
parse_bar_query("~~/dev"),
BarQuery {
sigil: Some(BarSigil::Directory),
query: "~/dev"
}
);
assert_eq!(
parse_bar_query("a>b"),
BarQuery {
sigil: None,
query: "a>b"
}
);
}
// ---- `>` commands ----
/// The default map binds both `enter` and `tab` to `AttachHighlighted`;
/// listed naively that is two identical rows.
#[test]
fn command_rows_dedupe_by_action_and_keep_every_chord() {
let entries = command_entries(&keymap(&[
("enter", Action::AttachHighlighted),
("tab", Action::AttachHighlighted),
("q", Action::Quit),
]));
assert_eq!(
entries,
vec![
(
Action::AttachHighlighted,
vec!["enter".to_string(), "tab".to_string()]
),
(Action::Quit, vec!["q".to_string()]),
]
);
}
/// `AttachHighlighted` and `ToggleAttachHighlighted` share a `help_label`
/// because they are the same match arm and genuinely do the same thing.
/// Two rows a reader cannot tell apart is the bug; relabelling them would
/// describe behaviour that does not exist.
#[test]
fn actions_sharing_a_label_collapse_into_one_row() {
let entries = command_entries(&keymap(&[
("enter", Action::AttachHighlighted),
("o", Action::ToggleAttachHighlighted),
]));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].1, vec!["enter".to_string(), "o".to_string()]);
}
/// `Noop` exists to suppress a default binding, not as something to run.
#[test]
fn noop_bindings_are_not_commands() {
let entries = command_entries(&keymap(&[("x", Action::Noop), ("q", Action::Quit)]));
assert_eq!(entries, vec![(Action::Quit, vec!["q".to_string()])]);
}
#[test]
fn the_command_sigil_narrows_to_commands_matched_on_their_label() {
let map = keymap(&[
("K", Action::KillHighlighted),
("q", Action::Quit),
("r", Action::RenameHighlighted),
]);
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
typed(&mut bar, ">kill");
assert_eq!(
labels(bar.rows()),
vec!["Kill selected session".to_string()]
);
assert!(bar.rows().iter().all(|r| r.kind == CandidateKind::Command));
assert_eq!(bar.rows()[0].chords, vec!["K".to_string()]);
}
/// Matching the chord would make `>` results depend on a user's rebinds.
#[test]
fn a_chord_is_displayed_and_never_matched() {
let map = keymap(&[("zzz", Action::Quit)]);
let mut bar = CommandBar::new(&[], &map, "/home/me");
typed(&mut bar, ">zzz");
assert!(bar.rows().is_empty(), "{:?}", labels(bar.rows()));
bar.handle_key(&InputKey::Backspace);
bar.handle_key(&InputKey::Backspace);
bar.handle_key(&InputKey::Backspace);
assert_eq!(labels(bar.rows()), vec!["Quit Maestro".to_string()]);
assert_eq!(bar.rows()[0].chords, vec!["zzz".to_string()]);
}
#[test]
fn enter_on_a_command_row_dispatches_its_action() {
let map = keymap(&[("q", Action::Quit)]);
let mut bar = CommandBar::new(&[], &map, "/home/me");
typed(&mut bar, ">quit");
assert_eq!(
bar.handle_key(&InputKey::Enter),
BarOutcome::Open(BarTarget::Command(Action::Quit))
);
}
/// An empty `>` query lists every command in keymap order.
#[test]
fn an_empty_command_query_lists_the_whole_keymap() {
let map = keymap(&[("K", Action::KillHighlighted), ("q", Action::Quit)]);
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
typed(&mut bar, ">");
assert_eq!(
labels(bar.rows()),
vec![
"Kill selected session".to_string(),
"Quit Maestro".to_string()
]
);
}
// ---- `~` directories ----
#[test]
fn the_directory_sigil_narrows_to_directories_deduped_and_abbreviated() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.set_directories(&[
PathBuf::from("/home/me/dev/api"),
PathBuf::from("/home/me/dev/api"),
PathBuf::from("/srv/www"),
]);
typed(&mut bar, "~");
assert_eq!(
labels(bar.rows()),
vec!["~/dev/api".to_string(), "/srv/www".to_string()]
);
assert!(bar
.rows()
.iter()
.all(|r| r.kind == CandidateKind::Directory));
}
#[test]
fn enter_on_a_directory_row_spawns_there() {
let mut bar = CommandBar::new(&[], &[], "/home/me");
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
typed(&mut bar, "~api");
assert_eq!(
bar.handle_key(&InputKey::Enter),
BarOutcome::Open(BarTarget::Directory(PathBuf::from("/home/me/dev/api")))
);
}
// ---- the mixed list ----
#[test]
fn no_sigil_ranks_sessions_and_commands_together_under_kind_headers() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "killer")], &map, "/home/me");
typed(&mut bar, "kill");
let groups = bar.groups();
assert_eq!(
groups.iter().map(|g| g.title).collect::<Vec<_>>(),
vec!["Sessions", "Commands"]
);
assert_eq!(
labels(bar.rows()),
vec!["killer".to_string(), "Kill selected session".to_string()]
);
}
/// An empty bar is a session switcher: commands and directories need a
/// sigil or a query to appear.
#[test]
fn an_empty_query_lists_sessions_only() {
let map = keymap(&[("q", Action::Quit)]);
let bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
assert_eq!(labels(bar.rows()), vec!["alpha".to_string()]);
}
/// The sigil is one character of the query like any other: deleting it is
/// not a special key.
#[test]
fn backspacing_past_a_sigil_returns_to_the_mixed_list() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
typed(&mut bar, ">kill");
assert_eq!(bar.rows().len(), 1);
assert_eq!(bar.sigil(), Some(BarSigil::Command));
bar.handle_key(&InputKey::Home);
bar.handle_key(&InputKey::Right);
bar.handle_key(&InputKey::Backspace);
assert_eq!(bar.query(), "kill");
assert_eq!(bar.sigil(), None);
assert_eq!(
labels(bar.rows()),
vec!["kill-me".to_string(), "Kill selected session".to_string()]
);
}
fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
@@ -710,7 +1162,7 @@ mod tests {
#[test]
fn renders_the_query_the_headers_and_the_rows() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
typed(&mut bar, "alp");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("go to"), "{rendered}");
@@ -721,9 +1173,21 @@ mod tests {
assert!(rendered.contains("[esc] close"), "{rendered}");
}
/// While a sigil is in force there is one kind, so the kind header goes.
#[test]
fn a_sigil_drops_the_kind_header_and_renders_the_chord() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
typed(&mut bar, ">kill");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("Kill selected session"), "{rendered}");
assert!(rendered.contains(" K"), "{rendered}");
assert!(!rendered.contains("Commands"), "{rendered}");
}
#[test]
fn renders_a_no_results_state() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("No matches"), "{rendered}");
@@ -732,7 +1196,7 @@ mod tests {
/// The matched characters are drawn bold, so the row shows why it matched.
#[test]
fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() {
let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "web-search")], &[], "/home/me");
typed(&mut bar, "ws");
let backend = TestBackend::new(90, 20);
let mut terminal = Terminal::new(backend).unwrap();
+67
View File
@@ -49,6 +49,33 @@ pub fn resolve(
.into_iter()
.next()
});
defaults(project, presets, handle)
}
/// Resolve the defaults for a spawn pinned to one directory — the command
/// bar's `~` (#154), where the operator named the directory and nothing may
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
/// the project scan does not know leaves `project` `None`, which is an
/// unresolvable default, so the keystroke opens the pre-filled form instead of
/// spawning somewhere else.
pub fn resolve_at(
dir: &std::path::Path,
projects: &[Project],
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let project = projects.iter().find(|p| p.path == dir).cloned();
defaults(project, presets, handle)
}
/// The project's last command, or the first non-custom preset, wrapped up with
/// the rest of the defaults. Shared by both resolvers so the two entry points
/// can never settle on different commands.
fn defaults(
project: Option<Project>,
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
@@ -159,6 +186,46 @@ mod tests {
}
}
/// The command bar's `~` names the directory; nothing may override it.
#[test]
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
let projects = vec![
project("/repos/fresh", 0, Some("aider")),
project("/repos/pinned", 30, Some("zsh")),
];
let resolved = resolve_at(
std::path::Path::new("/repos/pinned"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(
resolved.project.as_ref().map(|p| p.path.clone()),
Some(PathBuf::from("/repos/pinned"))
);
assert_eq!(resolved.command.as_deref(), Some("zsh"));
let req = resolved.build_request().expect("every default resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
}
/// A directory the scan does not know resolves no project, so there is no
/// request to build — the caller opens the pre-filled form instead of
/// spawning somewhere else.
#[test]
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
let resolved = resolve_at(
std::path::Path::new("/elsewhere"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(resolved.project, None);
// The preset still resolves; the missing project is what blocks it.
assert_eq!(resolved.command.as_deref(), Some("claude"));
assert_eq!(resolved.build_request(), None);
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
+7
View File
@@ -50,6 +50,10 @@ export interface AttachConnection {
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
@@ -506,6 +510,9 @@ export function createAttachConnection(
focusTerminal() {
term.focus();
},
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
dispose() {
if (disposed) return;
disposed = true;
+240 -5
View File
@@ -6,13 +6,19 @@ import {
barGroups,
barRows,
closeCommandBar,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
isCommandBarOpen,
openCommandBar,
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionCandidate,
SPAWN_DIRECTORY_EVENT,
} from "./commandBar.js";
import { COMMAND_SPECS } from "./commands.js";
import { COMMAND_SPECS, type Command } from "./commands.js";
import type { Session } from "./types.js";
function session(id: string, over: Partial<Session> = {}): Session {
@@ -173,8 +179,12 @@ describe("the overlay", () => {
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
];
// Sessions only: the mixed list's command half is the sigil describe's
// business, and the registry is whatever another test file happened to bind.
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
return openCommandBar(
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
);
}
it("is a registry command, so the help overlay lists it", () => {
@@ -252,7 +262,7 @@ describe("the overlay", () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SESSION_SELECT_EVENT, listener);
openCommandBar({ sessions, homeDir: "/home/me" });
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
press("ArrowDown");
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
@@ -262,7 +272,7 @@ describe("the overlay", () => {
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", onOpenSession });
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
@@ -301,9 +311,234 @@ describe("the overlay", () => {
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", onOpenSession });
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
});
// ---- the sigils (#150) ----
function command(id: string, label: string, keys: string[], run = () => {}): Command {
return { id, label, group: "Test", keys, scope: "global", run };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
function rowKeys(): string[][] {
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
);
}
describe("parsing the query line", () => {
it("reads a sigil only in first position", () => {
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
});
it("leaves a sigil character elsewhere in the query literal", () => {
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
// The case that matters: the second `~` of a directory query reaches the
// matcher.
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
});
it("returns to the mixed list when the sigil is backspaced away", () => {
expect(parseBarQuery(">").sigil).toBe(">");
expect(parseBarQuery("").sigil).toBeNull();
});
});
describe("command candidates", () => {
it("dedupes by action, not by chord", () => {
const run = () => {};
const kill = command("a.kill", "Kill the session", ["x"], run);
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
const other = command("c.rename", "Rename the session", ["r"]);
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
});
it("matches on the label only, and keeps the chord for display", () => {
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
});
});
describe("directory candidates", () => {
it("abbreviates the path it shows and keeps the one it spawns in", () => {
const c = directoryCandidate("/home/me/dev/api", "/home/me");
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
});
it("expands a typed `~` and dedupes by the directory spawned in", () => {
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
"/home/me/dev/api",
"/tmp/scratch",
]);
});
});
describe("the sigils in the overlay", () => {
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
const ran: string[] = [];
const commands = [
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
];
afterEach(() => {
ran.length = 0;
});
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => [],
listDirEntries: async () => [],
onSpawnDirectory: () => {},
...over,
});
}
it("lists every command in registry order under `>`, with its chord and no header", () => {
open();
type(">");
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
expect(rowKeys()).toEqual([["n"], ["x"]]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("matches a command on its label and never on its chord", () => {
open();
type(">kill");
expect(rowNames()).toEqual(["Kill the highlighted session"]);
// `n` and `x` are chords, not label text: querying one finds nothing that
// the label does not also justify.
type(">ctrlx");
expect(rowNames()).toEqual([]);
});
it("runs the highlighted command on Enter", () => {
open();
type(">kill");
press("Enter");
expect(ran).toEqual(["list.kill"]);
expect(isCommandBarOpen()).toBe(false);
});
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
open();
type("s");
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
"Commands",
]);
type(">spawn");
expect(rowNames()).toEqual(["Open the spawn form"]);
// Backspacing the sigil away is ordinary editing.
type("spawn");
expect(rowNames()).toContain("Open the spawn form");
type("");
expect(rowNames()).toEqual(["api"]);
});
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
type("~");
await flush();
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("fills the list in from /api/dirs as a path is typed", async () => {
const asked: string[] = [];
open({
loadDirectories: async () => ["/home/me/notes"],
listDirEntries: async (dir) => {
asked.push(dir);
return ["maestro", "api"];
},
});
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await flush();
expect(asked).toEqual(["~/dev/"]);
expect(rowNames()).toEqual(["~/dev/maestro"]);
});
it("spawns in the highlighted directory on Enter", async () => {
const spawned: string[] = [];
open({
loadDirectories: async () => ["~/dev/api"],
onSpawnDirectory: (path) => spawned.push(path),
});
type("~api");
await flush();
press("Enter");
expect(spawned).toEqual(["/home/me/dev/api"]);
expect(isCommandBarOpen()).toBe(false);
});
it("raises the spawn event when the caller supplies no handler", async () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => ["/home/me/dev/api"],
listDirEntries: async () => [],
});
type("~");
await flush();
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect(seen).toEqual(["/home/me/dev/api"]);
});
});
describe("the focus round-trip over a pane (#152)", () => {
function terminalTextarea(): HTMLTextAreaElement {
const ta = document.createElement("textarea");
ta.className = "xterm-helper-textarea";
document.body.appendChild(ta);
return ta;
}
it("hands the keyboard back to the terminal that had it", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
press("Escape");
expect(document.activeElement).toBe(ta);
});
it("reports no focus change to the daemon, opening or closing", () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
try {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
press("Escape");
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
String(url).includes("/focus"),
);
expect(focusCalls).toEqual([]);
} finally {
vi.unstubAllGlobals();
}
});
});
+316 -30
View File
@@ -1,33 +1,47 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list, opened from the sidebar, closed with Escape.
//
// Sessions are the only kind here (#149); the sigils, the action page and the
// terminal-focus binding land in #150-#152.
// Three kinds live here: sessions and commands, ranked together by default,
// and directories, which need the `~` sigil. The action page and the
// terminal-focus binding land in #151-#152.
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import { SESSION_SELECT_EVENT } from "./catalog.js";
import { keyCaps, publishedCommands, type Command } from "./commands.js";
import {
matchAndRank,
type CandidateKind,
type CommandCandidate,
type DirectoryCandidate,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
import { splitPath } from "./paths.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
/** A matcher candidate that remembers which session it came from, so a result
* can be acted on without a second lookup. The extra property is invisible to
* the matcher, which only ever reads `kind` and `fields`. */
/** A matcher candidate that remembers what it came from, so a result can be
* acted on without a second lookup. The extra property is invisible to the
* matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type CommandBarCandidate = CommandCandidate & { command: Command };
/** `fields.path` is the abbreviated path the operator reads and the matcher
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
export type BarCandidate = SessionBarCandidate;
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** Raised when Enter lands on a directory row. The bar picks the directory; the
* shell owns the spawn, which is the same fast-forward path `N` takes. */
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup {
export interface BarGroup<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: BarResult[];
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
@@ -36,6 +50,29 @@ const KIND_TITLES: Record<CandidateKind, string> = {
directory: "Directories",
};
// ---- the sigils ----
export type BarSigil = ">" | "~";
export interface BarQuery {
/** The sigil in force, or null for the mixed list. */
sigil: BarSigil | null;
/** What the matcher sees: everything after a sigil, or the whole query. */
query: string;
}
/** Split a raw query line into its sigil and the query proper.
*
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
* literal query character, which is what a directory query with a second `~`
* in it depends on. Backspacing past a sigil needs no special case either —
* the first character goes and the query is a mixed one again. */
export function parseBarQuery(raw: string): BarQuery {
const first = raw[0];
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
return { sigil: null, query: raw };
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
@@ -99,14 +136,92 @@ export function sessionBarCandidates(
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
}
/** Drop the commands that would list twice. Several chords can be bound to one
* action — the registry already carries its aliases on one entry, but nothing
* stops two entries running the same handler, and listed naively that is two
* identical rows. The dedupe is by **action**, never by chord: the chord is
* displayed, so a command with two of them is still one row. */
export function dedupeCommands(commands: readonly Command[]): Command[] {
const seenRun = new Set<Command["run"]>();
const seenLabel = new Set<string>();
const kept: Command[] = [];
for (const command of commands) {
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
seenRun.add(command.run);
seenLabel.add(command.label);
kept.push(command);
}
return kept;
}
/** Every bound command as a candidate, in registry order. Only the label is a
* field: the chord is displayed beside the row, never matched, because
* matching it would make `>` results depend on a user's rebinds. */
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
return dedupeCommands(commands).map((command) => ({
kind: "command",
command,
fields: { label: command.label },
}));
}
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
* a path the operator typed or `/api/dirs` completed. */
export function expandHome(path: string, homeDir?: string): string {
if (!homeDir) return path;
if (path === "~") return homeDir;
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
}
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
const absolute = expandHome(path, homeDir);
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
}
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
* first mention of each — recent dirs (newest first) ahead of the project
* list, which is the empty-query order the contract asks for. */
export function directoryBarCandidates(
paths: readonly string[],
homeDir?: string,
): DirectoryBarCandidate[] {
const seen = new Set<string>();
const out: DirectoryBarCandidate[] = [];
for (const path of paths) {
if (path === "") continue;
const candidate = directoryCandidate(path, homeDir);
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
out.push(candidate);
}
return out;
}
/** The directory list the bar starts from: what the spawn form already knows —
* `GET /api/recent-dirs` and the project list — either alone if the other
* fails. */
export async function loadBarDirectories(): Promise<string[]> {
const [recents, discovered] = await Promise.all([
recentDirs()
.then((r) => r.dirs.map((d) => d.cwd))
.catch(() => [] as string[]),
fetchProjects()
.then((list) => list.map((p) => p.path))
.catch(() => [] as string[]),
]);
return [...recents, ...discovered];
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups(results: readonly BarResult[]): BarGroup[] {
const groups: BarGroup[] = [];
export function barGroups<C extends BarCandidate>(
results: readonly MatchResult<C>[],
): BarGroup<C>[] {
const groups: BarGroup<C>[] = [];
for (const result of results) {
const kind = result.candidate.kind;
let group = groups.find((g) => g.kind === kind);
@@ -122,7 +237,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
/** The rows a query produces, flat and in rank order — what the highlight walks
* and what Enter acts on. Headers are labels, not rows, so they are never in
* this list. */
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
export function barRows<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
@@ -145,9 +263,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
return frag;
}
/** The secondary line under a row's name: the field the match was found in when
* that was not the name, so a session found through its directory says so. */
/** The name line of a row: the session's name, the command's label, or the
* directory's path. */
function rowName(candidate: BarCandidate): string {
switch (candidate.kind) {
case "session":
return candidate.fields.name;
case "command":
return candidate.fields.label;
case "directory":
return candidate.fields.path;
}
}
/** The secondary line under a session row: the field the match was found in
* when that was not the name, so a session found through its directory says
* so. Only sessions have one — a command shows keycaps instead, and a
* directory's path is already the name. */
function metaText(result: BarResult): string | undefined {
if (result.candidate.kind !== "session") return undefined;
const fields = result.candidate.fields;
switch (result.field) {
case "directory":
@@ -162,22 +296,44 @@ function metaText(result: BarResult): string | undefined {
}
function renderRow(result: BarResult, index: number): HTMLElement {
const candidate = result.candidate;
const row = document.createElement("div");
row.className = "cmdbar-row";
row.setAttribute("role", "option");
row.id = `cmdbar-row-${index}`;
row.dataset.index = String(index);
row.dataset.sessionId = result.candidate.session.id;
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
row.dataset.kind = candidate.kind;
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead");
} else if (candidate.kind === "command") {
row.dataset.commandId = candidate.command.id;
} else {
row.dataset.path = candidate.path;
}
const name = document.createElement("span");
name.className = "cmdbar-row-name";
const nameText = result.candidate.fields.name;
name.append(
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
);
const nameText = rowName(candidate);
const nameMatched =
result.field === "name" || result.field === "label" || result.field === "path";
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
row.appendChild(name);
// The chord is teaching material, not a match target: it is rendered from
// the live registry entry and never fed to the matcher.
if (candidate.kind === "command") {
const keys = document.createElement("span");
keys.className = "cmdbar-row-keys";
for (const cap of keyCaps(candidate.command.keys[0])) {
const kbd = document.createElement("kbd");
kbd.textContent = cap;
keys.appendChild(kbd);
}
row.appendChild(keys);
return row;
}
const meta = metaText(result);
if (meta) {
const el = document.createElement("span");
@@ -189,6 +345,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
return row;
}
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
mixed: "Go to a session…",
">": "Run a command…",
"~": "Spawn in a directory…",
};
// ---- the overlay ----
export interface CommandBarOptions {
@@ -200,6 +362,18 @@ export interface CommandBarOptions {
* sidebar's click event — which is what the contract requires; tests pass
* their own. */
onOpenSession?: (sessionId: string) => void;
/** The command registry to list under `>`. Defaults to every bound command,
* in registry order. */
commands?: readonly Command[];
/** The directory list behind `~`, fetched once on first use. */
loadDirectories?: () => Promise<string[]>;
/** One directory's subdirectories, for filling the list in as a path is
* typed. Defaults to `GET /api/dirs`. */
listDirEntries?: (dir: string) => Promise<string[]>;
/** Spawn a session in the highlighted directory. Defaults to raising
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
* spawn. */
onSpawnDirectory?: (path: string) => void;
}
interface OpenBar {
@@ -228,8 +402,21 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
const openSession =
opts.onOpenSession ??
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
const spawnDirectory =
opts.onSpawnDirectory ??
((path: string) =>
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
const listEntries =
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
opts.sessions,
opts.homeDir,
);
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
opts.commands ?? publishedCommands(),
);
const el = document.createElement("div");
el.className = "cmdbar-overlay";
@@ -246,10 +433,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", "cmdbar-results");
input.setAttribute("aria-label", "Search sessions");
input.setAttribute("aria-label", "Search sessions, commands and directories");
input.setAttribute("autocomplete", "off");
input.spellcheck = false;
input.placeholder = "Go to a session…";
input.placeholder = PLACEHOLDERS.mixed;
const results = document.createElement("div");
results.className = "cmdbar-results";
@@ -263,6 +450,90 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let rows: BarResult[] = [];
let highlighted = 0;
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
let directories: DirectoryBarCandidate[] = [];
let directoriesRequested = false;
let probed: DirectoryBarCandidate[] = [];
let probedDir: string | null = null;
const listings = new Map<string, string[]>();
function alive(): boolean {
return current?.el === el;
}
/** Fetch the recents-and-projects list the first time `~` is used. */
function ensureDirectories(): void {
if (directoriesRequested) return;
directoriesRequested = true;
loadDirectories()
.then((paths) => {
directories = directoryBarCandidates(paths, opts.homeDir);
if (alive()) render();
})
.catch(() => {
/* no directories is a shorter list, not an error state */
});
}
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
* the directory it names is listed and its children join the candidates, so
* `~` reaches a directory that was never spawned in. */
function probeDirectories(query: string): void {
const { dir } = splitPath(query);
if (dir === "") {
probed = [];
probedDir = null;
return;
}
if (dir === probedDir) return;
probedDir = dir;
const cached = listings.get(dir);
if (cached) {
probed = directoryBarCandidates(
cached.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
return;
}
probed = [];
listEntries(dir)
.then((entries) => {
listings.set(dir, entries);
if (!alive() || probedDir !== dir) return;
probed = directoryBarCandidates(
entries.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
render();
})
.catch(() => {
/* an unreadable directory contributes nothing */
});
}
/** What the matcher sees for the query line as it stands. With no sigil the
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
function candidatesFor(parsed: BarQuery): BarCandidate[] {
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
case "~": {
ensureDirectories();
probeDirectories(parsed.query);
const seen = new Set<string>();
return [...directories, ...probed].filter((c) =>
seen.has(c.path) ? false : (seen.add(c.path), true),
);
}
default:
return parsed.query === ""
? [...sessionCandidates]
: [...sessionCandidates, ...commandCandidates];
}
}
function paintHighlight(): void {
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
const active = Number(row.dataset.index) === highlighted;
@@ -277,7 +548,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
}
function render(): void {
rows = barRows(input.value, candidates);
const parsed = parseBarQuery(input.value);
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
rows = barRows(parsed.query, candidatesFor(parsed));
// The highlight starts on the globally best row every time the list
// changes: an old index would point at a different result.
highlighted = 0;
@@ -294,11 +567,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let index = 0;
for (const group of barGroups(rows)) {
const header = document.createElement("div");
header.className = "cmdbar-group-title";
header.setAttribute("role", "presentation");
header.textContent = group.title;
results.appendChild(header);
// While a sigil is in force there is one kind, so the header is dropped.
if (parsed.sigil === null) {
const header = document.createElement("div");
header.className = "cmdbar-group-title";
header.setAttribute("role", "presentation");
header.textContent = group.title;
results.appendChild(header);
}
for (const result of group.results) {
const at = index;
const row = renderRow(result, at);
@@ -320,9 +596,19 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const sessionId = result.candidate.session.id;
const candidate = result.candidate;
close();
openSession(sessionId);
switch (candidate.kind) {
case "session":
openSession(candidate.session.id);
return;
case "command":
candidate.command.run();
return;
case "directory":
spawnDirectory(candidate.path);
return;
}
}
function onKeydown(e: KeyboardEvent): void {
+73
View File
@@ -7,6 +7,8 @@ import {
DEFAULT_LEADER,
canonicalizeKey,
defineCommands,
isBarTypingTarget,
isTerminalTextarea,
isTypingTarget,
keyCaps,
publishedCommands,
@@ -234,3 +236,74 @@ describe("isTypingTarget", () => {
expect(isTypingTarget(null)).toBe(false);
});
});
describe("isTerminalTextarea", () => {
it("recognises xterm's offscreen textarea, and nothing else", () => {
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
expect(isTerminalTextarea(el("<input>"))).toBe(false);
expect(isTerminalTextarea(null)).toBe(false);
});
});
describe("isBarTypingTarget", () => {
it("holds the bar's keys out of real form fields", () => {
expect(isBarTypingTarget(el("<input>"))).toBe(true);
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
});
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
expect(isBarTypingTarget(null)).toBe(false);
});
});
describe("ctrl-k over a focused pane (#152)", () => {
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
/** The global listener's routing: the bar's set only sees a key that is not
* aimed at a real form field. */
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
if (isBarTypingTarget(target)) return false;
return pressOn(set, target, k, init);
}
it("opens the bar when the terminal has the keyboard", () => {
const open = vi.fn();
const set = new CommandSet(defineCommands({ "bar.open": open }));
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
expect(open).toHaveBeenCalledTimes(1);
});
it("leaves the spawn form's inputs their own Ctrl-K", () => {
const open = vi.fn();
const set = new CommandSet(defineCommands({ "bar.open": open }));
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
it("sends the literal 0x0B after the leader, without opening the bar", () => {
const open = vi.fn();
const literal = vi.fn();
const bar = new CommandSet(defineCommands({ "bar.open": open }));
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
leader: DEFAULT_LEADER,
});
const target = terminal();
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
const e = key("k", { ctrlKey: true });
Object.defineProperty(e, "target", { value: target });
expect(set.handle(e)).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The leader's set consumed the key, so the bar's set never sees it.
expect(open).not.toHaveBeenCalled();
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
expect(open).toHaveBeenCalledTimes(1);
});
it("binds the escape hatch behind the default leader", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
});
});
+20 -5
View File
@@ -32,6 +32,10 @@ export interface Command extends CommandSpec {
run: () => void;
}
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
* in a browser, so a set that owns the leader `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has. The single list this issue exists to
* create: labels, chords and grouping live here and nowhere else. */
export const COMMAND_SPECS: readonly CommandSpec[] = [
@@ -56,6 +60,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
keys: ["ctrl-k"],
scope: "global",
},
{
id: "pane.literalCtrlK",
label: "Send a literal Ctrl-K to the pane",
group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`],
scope: "global",
},
{
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
@@ -241,11 +252,19 @@ export function isTypingTarget(target: EventTarget | null): boolean {
/** xterm's offscreen textarea. It is a typing target like any other, but it is
* also where the leader has to work — a pane is exactly where the operator
* needs the escape hatch (docs/COMMAND-BAR.md). */
function isTerminalTextarea(target: EventTarget | null): boolean {
export function isTerminalTextarea(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
}
/** True where the command bar's own keys must stay out of the way: a real form
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
* the spawn form's inputs keep their own Ctrl-K. */
export function isBarTypingTarget(target: EventTarget | null): boolean {
return isTypingTarget(target) && !isTerminalTextarea(target);
}
// ---- display ----
const CAPS: Record<string, string> = {
@@ -292,10 +311,6 @@ export function keyCaps(chord: string): string[] {
// ---- dispatch ----
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
* in a browser, so a set that owns the leader `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a";
/** How long a partial chord waits for its next key — the TUI's
* `chord_timeout_ms` default. */
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
+40
View File
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => ({
sessionId: id,
el: document.createElement("div"),
onMount() {},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
dispose() {},
}),
}));
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => {
await Promise.resolve();
});
});
describe("ConnectionPool.sendToFocused", () => {
it("types the bytes into the focused session's PTY", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
expect(pool.sendToFocused("\x0b")).toBe(true);
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
});
it("is a no-op with nothing focused", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
expect(pool.sendToFocused("\x0b")).toBe(false);
expect(sent).toEqual([]);
});
});
+12
View File
@@ -159,6 +159,18 @@ export class ConnectionPool {
this.cache.get(this.focusedId)?.focusTerminal();
}
/** Type raw bytes into the focused session's PTY. Returns false when there is
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
* is a no-op over the welcome pane rather than an error
* (docs/COMMAND-BAR.md). */
sendToFocused(data: string): boolean {
if (this.focusedId === null) return false;
const conn = this.cache.get(this.focusedId);
if (!conn) return false;
conn.sendInput(data);
return true;
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
+65 -13
View File
@@ -7,8 +7,8 @@ import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen } from "./help.js";
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
@@ -17,6 +17,7 @@ import {
createSession,
fetchNames,
getDaemonInfo,
gitRoot,
projects as fetchProjects,
} from "./api.js";
import {
@@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
// a new branch named after that name. Anything unresolvable (no projects
// known yet, no command, no generated name) opens the form pre-filled with
// what did resolve instead.
async function spawnPresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
/* keep fallbacks */
}
return FALLBACK_PRESETS;
}
async function requestFastForward(): Promise<void> {
const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
@@ -344,13 +355,7 @@ async function requestFastForward(): Promise<void> {
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
let presets: SpawnPreset[] = FALLBACK_PRESETS;
try {
const info = await getDaemonInfo();
if (info.presets?.length) presets = info.presets;
} catch {
/* keep fallbacks */
}
const presets = await spawnPresets();
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
@@ -372,13 +377,54 @@ async function requestFastForward(): Promise<void> {
});
}
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
// fast-forward spawn, aimed at the chosen directory instead of the most recent
// project. A directory the project scan never saw stands in as its own project;
// one outside any repo spawns without a worktree, since there is no repo to cut
// a branch from. Anything still unresolved opens the pre-filled form.
async function requestSpawnInDirectory(path: string): Promise<void> {
const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, name, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
known !== undefined ||
(await gitRoot(path)
.then((r) => !!r.root)
.catch(() => false));
if (!inRepo) delete req.worktree;
try {
mountSpawned(await createSession(req));
return;
} catch {
// A failed POST degrades to the pre-filled form, never a dead row.
}
}
openSpawnModal({
seed: fastForwardSeed(resolved),
onSuccess: (session) => mountSpawned(session),
});
}
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
});
// The global half of the command registry (#148). Two sets, because they
// differ in one thing: the help overlay owns the keyboard while it is up, so
// the plain keys go deaf behind it while the focus chord does not — an
// attached terminal must never be able to trap the focus keys.
//
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
// hidden textarea.
const spawnKeys = new CommandSet(
defineCommands({
@@ -405,6 +451,11 @@ const focusKeys = new CommandSet(
sidebar.focusList();
}
},
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
// away from every pane, and leader ctrl-k gives it back as the literal
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
// bar's, so the chord never opens the bar.
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
}),
{ leader: DEFAULT_LEADER },
);
@@ -433,9 +484,10 @@ window.addEventListener(
if (focusKeys.handle(e)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
// Opening the bar over a focused terminal is #152; until then the pane's
// hidden textarea keeps its Ctrl+K, like any other typing target.
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
// seen ahead of xterm and opens the bar (#152). A real form field — the
// spawn form's inputs — keeps its own Ctrl+K.
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
spawnKeys.handle(e);
},
true,
+19
View File
@@ -1517,6 +1517,25 @@ input.leaf-rename:focus {
text-overflow: ellipsis;
}
/* A command row's chord, shown so the bar teaches the keymap. */
.cmdbar-row-keys {
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
gap: var(--sp-1);
min-width: 0;
}
.cmdbar-row-keys kbd {
font-family: var(--font-mono);
font-size: var(--fs-11);
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
padding: 0 var(--sp-1);
}
.cmdbar-hit {
background: none;
color: var(--accent);