Compare commits

..
Author SHA1 Message Date
Miguel Palhas 3eb1d7ddbe feat(maestro): drill into a session's action page with tab
Tab on a highlighted session in the web command bar opens that session's
action page in the same overlay: kill, rename, spawn similar, still fuzzy
searchable and headed by the session's name. Escape, or backspace at an
empty query, returns to the result list with the previous query intact.

Each action raises the flow the card already has. The two that lived
inside the sidebar get an event apiece — the confirm-and-blockers kill and
the inline rename prompt — so the bar picks the action and nothing more;
spawn similar reuses the existing SPAWN_SIMILAR_EVENT.
2026-09-03 17:27:13 +01:00
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
Miguel Palhas dc783778c8 feat(maestro): add the ctrl-k command bar to the TUI
Opens from Normal and Insert mode as an ordinary keymap entry, so it is
rebindable and the help overlay picks it up from the live keymap. An
empty query lists sessions most recently focused first, matching the web
bar's `orderSessionsForBar`; typing ranks through `matcher.rs` with the
matched ranges highlighted, grouped under kind headers.

Enter attaches through the same path the sidebar's attach uses, Escape
closes and leaves focus and mode where they were.
2026-09-03 16:42:21 +01:00
Miguel Palhas 83741d7203 feat(maestro): add Rust match-and-rank matcher for command bar
Ports the docs/COMMAND-BAR.md scoring algorithm (dynamic program over
query/text positions, word-boundary bonuses, whole-field adjustment,
four-level tie-break) to crates/maestro-tui, matching web/src/matcher.ts.
Fixtures F1-F12 from the doc are ported as tests, asserting order,
scores, field and ranges.
2026-09-03 16:27:11 +01:00
Miguel Palhas b618b7dd27 merge origin/blitz/command-bar into command-bar/147-rust-matcher 2026-09-03 16:23:45 +01:00
Miguel Palhas f37ff681ea feat(maestro): fill the row for the session in the pane
ci / web (push) Successful in 34s
ci / flake (push) Successful in 2m51s
ci / rust (push) Successful in 6m54s
The web list still said "open in the focused pane" with a 1px ring, the last
edge left in it, and the TUI said it with the ● glyph alone. Both now use a
background.

Web: `--accent-fill`, the same 30% recipe `--preview-tint` uses, so the two
tiers differ by hue rather than by strength. The row rules are also rewritten
as one ordered run of equal-specificity `:where()` selectors, which is what
`docs/SIDEBAR.md` claims the ladder is — `.is-open` moves in from the bottom
of the multi-view block, where source order had been deciding its precedence
by accident.

TUI: `palette.pane_row_bg()`, blended partway from the card surface to the
selection surface, so the sidebar reads unfilled / half filled / filled. It
borrows the selection surface's foregrounds, since those are the ones picked
for a filled row, and the cursor still wins on its own row. Blended rather
than shipped as a third `Surface` — that would be eight more colours in each
of seventeen presets — with palette tests asserting it stays a visible step
from both surfaces and stays legible in every preset.
2026-09-03 15:31:44 +01:00
25 changed files with 4289 additions and 133 deletions
+53
View File
@@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String {
}
}
/// Program name (already basenamed and lowercased) -> the tool id its mark
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
/// recognition is deliberately narrow, so an unrecognised program has no tool.
const BY_PROGRAM: [(&str, &str); 17] = [
("claude", "claude"),
("claude-code", "claude"),
("codex", "codex"),
("opencode", "opencode"),
("gemini", "gemini"),
("aider", "aider"),
("cursor", "cursor"),
("cursor-agent", "cursor"),
("pi", "pi"),
("bash", "shell"),
("zsh", "shell"),
("sh", "shell"),
("fish", "shell"),
("dash", "shell"),
("ksh", "shell"),
("nu", "shell"),
("xonsh", "shell"),
];
/// The tool id a command line's icon stands for (`claude`, `codex`,
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
/// the same tools the web's `splitCommand().tool` does.
pub fn command_tool(command: &str) -> Option<&'static str> {
let program = command_program(command).to_lowercase();
BY_PROGRAM
.iter()
.find(|(name, _)| *name == program)
.map(|(_, tool)| *tool)
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
@@ -476,4 +511,22 @@ mod tests {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
/// tools the web's `splitCommand().tool` does.
#[test]
fn command_tool_names_the_mark_the_card_shows() {
assert_eq!(command_tool("claude --resume"), Some("claude"));
assert_eq!(command_tool("npx opencode"), Some("opencode"));
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
assert_eq!(command_tool("CLAUDE"), Some("claude"));
}
/// Narrow on purpose: an unrecognised program has no mark, so the
/// candidate has no `tool` field to match rather than a wrong one.
#[test]
fn command_tool_is_absent_for_an_unrecognised_program() {
assert_eq!(command_tool("hx src/main.rs"), None);
assert_eq!(command_tool(""), None);
}
}
+18
View File
@@ -53,6 +53,9 @@ pub enum Action {
RenameHighlighted,
// Chrome
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
/// over the sessions, ranked by the shared matcher.
OpenCommandBar,
ToggleSidebar,
ShowHelp,
Quit,
@@ -94,6 +97,7 @@ impl Action {
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::OpenCommandBar => "Go to a session".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
@@ -120,6 +124,7 @@ impl Action {
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -239,10 +244,23 @@ mod tests {
);
}
/// The command bar is an ordinary keymap entry, so it must round-trip
/// like every other action (#153).
#[test]
fn open_command_bar_round_trips() {
assert_eq!(
Action::parse("OpenCommandBar").unwrap(),
Action::OpenCommandBar
);
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::OpenCommandBar,
Action::RenameHighlighted,
Action::Quit,
Action::OpenSpawnModal,
+539 -13
View File
@@ -23,6 +23,7 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink;
use crate::draft::{DraftInputResult, DraftPane};
@@ -78,6 +79,9 @@ pub struct App {
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
/// same reason the worktree entries are.
rename_input: TextInput,
/// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153),
/// kept here for the same reason the rename buffer is.
command_bar: Option<CommandBar>,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -164,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 {
@@ -210,6 +222,7 @@ impl App {
link,
http,
rename_input: TextInput::default(),
command_bar: None,
draft_tx,
draft_rx,
region: RegionMap::default(),
@@ -381,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,
@@ -430,6 +445,42 @@ impl App {
(OverlayDecision::Consumed, Vec::new())
}
},
// The bar owns every key while it is up: the keymap must not see
// `j`, `x` or `q` as commands mid-query.
Some(OverlayKind::CommandBar) => {
let outcome = match self.command_bar.as_mut() {
Some(bar) => bar.handle_key(key),
None => BarOutcome::Close,
};
match outcome {
BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()),
BarOutcome::Close => {
self.close_command_bar();
(OverlayDecision::Consumed, Vec::new())
}
BarOutcome::Open(BarTarget::Session(sid)) => {
self.close_command_bar();
// The same open path the sidebar's own attach uses, so
// a bar attach can never leave the layout in a state a
// highlight-and-enter could not produce.
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
(
OverlayDecision::Consumed,
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) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
@@ -475,6 +526,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
@@ -537,6 +589,10 @@ impl App {
/// highlight already sitting on the attached session — clears at once.
fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
// The bar is a view over the app, not a place in it: while it is
// up the sidebar highlight behind it previews nothing
// (`docs/COMMAND-BAR.md`, "Focus round-trip").
_ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None,
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
_ => None,
};
@@ -588,6 +644,34 @@ impl App {
}
}
/// Put the command bar up over whatever is behind it (#153). Opening
/// 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) -> Vec<Effect> {
let snapshot = self.catalog.snapshot();
// 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
/// a stale buffer can never outlive the overlay.
fn close_command_bar(&mut self) {
self.command_bar = None;
self.state.dismiss_overlay();
}
fn clear_preview(&mut self) {
if self.state.preview.take().is_some() {
self.epoch = self.epoch.wrapping_add(1);
@@ -935,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.
@@ -981,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();
@@ -1009,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
@@ -1032,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;
@@ -1050,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);
}
@@ -1179,6 +1320,10 @@ impl App {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight.clone(),
attached,
// The active tab's session, not the displayed one: a preview is
// shown in the pane but never becomes what the operator picked,
// so it must not move this fill (`docs/PREVIEW.md`).
pane_session: self.state.active_session(),
};
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
@@ -1218,6 +1363,7 @@ impl App {
draft: self.draft.as_ref(),
help: &self.help,
rename_input: &self.rename_input,
command_bar: self.command_bar.as_ref(),
link: &link_status,
};
@@ -1269,6 +1415,7 @@ fn visible_sidebar_order(
sessions: snap.sessions.clone(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
}
.rendered_items_with_collapsed(collapsed)
}
@@ -1402,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`.
@@ -2161,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.
@@ -2711,6 +2959,284 @@ mod tests {
.is_none());
}
// -- command bar (#153) -------------------------------------------------
fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
id: Uuid::from_u128(n),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: HashMap::from([(
"name".to_string(),
serde_json::Value::String(name.to_string()),
)]),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: focused,
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![
make(1, "alpha", Some(older)),
make(2, "bravo", Some(Utc::now())),
],
});
(Uuid::from_u128(1), Uuid::from_u128(2))
}
/// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar
/// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`).
#[test]
fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() {
let app = make_app();
assert_eq!(
app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"),
Some(Action::OpenCommandBar)
);
assert_eq!(
app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"),
Some(Action::OpenCommandBar)
);
}
/// The help overlay builds its lines from the live keymap, so the bar
/// appears there with no second list to maintain.
#[test]
fn the_help_overlay_lists_the_command_bar() {
let app = make_app();
let lines = render_help_lines(&app.cfg);
assert!(
lines
.iter()
.any(|l| l.contains("ctrl-k") && l.contains("Go to a session")),
"{lines:#?}"
);
}
#[test]
fn opening_the_bar_lists_sessions_most_recently_focused_first() {
let mut app = make_app();
let (alpha, bravo) = seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
let bar = app.command_bar.as_ref().unwrap();
assert_eq!(bar.query(), "");
assert_eq!(
bar.rows()
.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]
fn opening_from_insert_mode_leaves_focus_and_mode_alone() {
let mut app = make_app();
seed_two_sessions(&mut app);
app.state.attach_to_focused(Uuid::from_u128(1));
assert_eq!(
(app.state.focus, app.state.mode),
(Focus::Pane, Mode::Insert)
);
app.dispatch(Action::OpenCommandBar);
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
assert_eq!(
(app.state.focus, app.state.mode),
(Focus::Pane, Mode::Insert)
);
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none());
assert!(app.command_bar.is_none());
assert_eq!(
(app.state.focus, app.state.mode),
(Focus::Pane, Mode::Insert)
);
}
/// Enter goes through the same open path the sidebar's attach uses.
#[test]
fn enter_attaches_the_highlighted_session() {
let mut app = make_app();
let (_alpha, bravo) = seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::Attach(bravo)]);
assert!(app.state.overlay().is_none());
assert!(app.command_bar.is_none());
assert_eq!(app.state.active_session(), Some(bravo));
}
/// Down moves the highlight, so Enter opens the second row instead.
#[test]
fn the_highlight_moves_before_enter_attaches() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
app.handle_overlay_input(&InputKey::Down);
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(effects, vec![Effect::Attach(alpha)]);
assert_eq!(app.state.active_session(), Some(alpha));
}
/// Typing goes to the query, not to the keymap: `q` must not quit and `x`
/// must not raise a kill confirm while the bar is up.
#[test]
fn typing_reaches_the_query_rather_than_the_keymap() {
let mut app = make_app();
seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
for c in "bra".chars() {
let (decision, _) = app.handle_overlay_input(&InputKey::Char {
c,
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(decision, OverlayDecision::Consumed);
}
let bar = app.command_bar.as_ref().unwrap();
assert_eq!(bar.query(), "bra");
assert_eq!(bar.rows().len(), 1);
}
/// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a
/// preview and the highlight behind it raises no new one.
#[test]
fn opening_the_bar_discards_a_preview() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
app.state.sidebar_highlight = Some(SidebarItem::Session(alpha));
app.state.preview = Some(alpha);
app.dispatch(Action::OpenCommandBar);
assert_eq!(app.state.preview, None);
assert_eq!(app.pending_preview, None);
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
// The command bar (#153). An ordinary entry, so it is rebindable and the
// help overlay picks it up from the live keymap.
("ctrl-k", "OpenCommandBar"),
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
@@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// Default Insert-mode bindings — deliberately empty. Every key typed in
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
/// Insert mode belongs to the session, `esc` included: full-screen apps read
/// it constantly, so binding it here would make it unreachable. The only way
/// back to the sidebar is the leader (`LeaveInsert`), which
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
/// the Insert escape with it.
const DEFAULT_INSERT: &[(&str, &str)] = &[];
///
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
/// kill-to-end-of-line is given back through the leader (#156).
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
+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![
+2
View File
@@ -6,6 +6,7 @@ pub mod action;
pub mod app;
pub mod catalog;
pub mod cli;
pub mod command_bar;
pub mod config;
pub mod daemon_link;
pub mod draft;
@@ -13,6 +14,7 @@ pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod matcher;
pub mod overlays;
pub mod palette;
pub mod pane;
+750
View File
@@ -0,0 +1,750 @@
//! Pure match-and-rank function for the `ctrl-k` command bar.
//!
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
//! and scores as `web/src/matcher.ts` for the same input.
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
Session,
Command,
Directory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MatchFieldName {
Name,
Directory,
Command,
Tool,
Label,
Path,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCandidate {
pub name: String,
pub directory: Option<String>,
pub command: Option<String>,
pub tool: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandCandidate {
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectoryCandidate {
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchCandidate {
Session(SessionCandidate),
Command(CommandCandidate),
Directory(DirectoryCandidate),
}
pub type MatchRange = (usize, usize);
#[derive(Debug, PartialEq, Eq)]
pub struct MatchResult<'c> {
pub candidate: &'c MatchCandidate,
pub score: i32,
pub field: Option<MatchFieldName>,
pub ranges: Vec<MatchRange>,
}
struct Field<'c> {
name: MatchFieldName,
text: &'c str,
weight: i32,
}
impl MatchCandidate {
fn kind(&self) -> CandidateKind {
match self {
MatchCandidate::Session(_) => CandidateKind::Session,
MatchCandidate::Command(_) => CandidateKind::Command,
MatchCandidate::Directory(_) => CandidateKind::Directory,
}
}
fn fields(&self) -> Vec<Field<'_>> {
match self {
MatchCandidate::Session(session) => {
let mut fields = vec![Field {
name: MatchFieldName::Name,
text: &session.name,
weight: 0,
}];
if let Some(directory) = &session.directory {
fields.push(Field {
name: MatchFieldName::Directory,
text: directory,
weight: -24,
});
}
if let Some(command) = &session.command {
fields.push(Field {
name: MatchFieldName::Command,
text: command,
weight: -32,
});
}
if let Some(tool) = &session.tool {
fields.push(Field {
name: MatchFieldName::Tool,
text: tool,
weight: -40,
});
}
fields
}
MatchCandidate::Command(command) => vec![Field {
name: MatchFieldName::Label,
text: &command.label,
weight: 0,
}],
MatchCandidate::Directory(directory) => vec![Field {
name: MatchFieldName::Path,
text: &directory.path,
weight: 0,
}],
}
}
}
fn kind_order(kind: CandidateKind) -> u8 {
match kind {
CandidateKind::Session => 0,
CandidateKind::Command => 1,
CandidateKind::Directory => 2,
}
}
fn lower_char(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn lower_chars(chars: &[char]) -> Vec<char> {
chars.iter().copied().map(lower_char).collect()
}
fn normalize_query(query: &str) -> Vec<char> {
query
.chars()
.filter(|c| !c.is_whitespace())
.map(lower_char)
.collect()
}
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
fn boundary_bonus(text: &[char], position: usize) -> i32 {
if position == 0 {
return 18;
}
let previous = text[position - 1];
if previous == ' ' || SEPARATORS.contains(&previous) {
return 16;
}
let current = text[position];
if current.is_uppercase() && previous.is_lowercase() {
return 12;
}
let is_digit = current.is_ascii_digit();
let previous_is_digit = previous.is_ascii_digit();
if is_digit && !previous_is_digit {
return 12;
}
0
}
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
let gap = position as isize - previous_position - 1;
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
}
#[derive(Clone)]
struct Embedding {
score: i32,
positions: Vec<usize>,
}
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
let Some(incumbent) = incumbent else {
return true;
};
if candidate.score != incumbent.score {
return candidate.score > incumbent.score;
}
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
if c != i {
return c < i;
}
}
false
}
/// The dynamic program over `(query index, text position)` described in
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
/// `mtu` against `maestro-tui`), so every reachable position at each query
/// index carries its best score and position vector forward.
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
let lower_text = lower_chars(text);
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
for (query_index, &qc) in query.iter().enumerate() {
let mut states: HashMap<usize, Embedding> = HashMap::new();
for (position, &text_char) in lower_text.iter().enumerate() {
if text_char != qc {
continue;
}
if query_index == 0 {
states.insert(
position,
Embedding {
score: contribution(text, position, -1),
positions: vec![position],
},
);
continue;
}
for (&previous_position, previous) in previous_states.iter() {
if previous_position >= position {
continue;
}
let mut positions = previous.positions.clone();
positions.push(position);
let candidate = Embedding {
score: previous.score
+ contribution(text, position, previous_position as isize),
positions,
};
if is_better_embedding(&candidate, states.get(&position)) {
states.insert(position, candidate);
}
}
}
if states.is_empty() {
return None;
}
previous_states = states;
}
let mut best: Option<Embedding> = None;
for embedding in previous_states.into_values() {
if is_better_embedding(&embedding, best.as_ref()) {
best = Some(embedding);
}
}
best
}
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
let mut ranges: Vec<MatchRange> = Vec::new();
for &position in positions {
if let Some(last) = ranges.last_mut() {
if last.1 == position {
last.1 = position + 1;
continue;
}
}
ranges.push((position, position + 1));
}
ranges
}
fn compact(text: &str) -> String {
text.chars()
.filter(|c| !c.is_whitespace())
.map(lower_char)
.collect()
}
struct Ranked<'c> {
candidate: &'c MatchCandidate,
score: i32,
field: Option<MatchFieldName>,
ranges: Vec<MatchRange>,
matched_text: String,
input_index: usize,
}
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
let normalized_query = normalize_query(query);
if normalized_query.is_empty() {
return candidates
.iter()
.map(|candidate| MatchResult {
candidate,
score: 0,
field: None,
ranges: Vec::new(),
})
.collect();
}
let mut results: Vec<Ranked<'c>> = Vec::new();
for (input_index, candidate) in candidates.iter().enumerate() {
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
for field in candidate.fields() {
let text_chars: Vec<char> = field.text.chars().collect();
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
continue;
};
let compact_text = compact(field.text);
let normalized_query_str: String = normalized_query.iter().collect();
let adjustment = if compact_text == normalized_query_str {
64
} else if compact_text.starts_with(&normalized_query_str) {
32
} else {
0
};
let score = embedding.score + adjustment + field.weight;
let is_better = match &best {
None => true,
Some((_, _, best_score, _)) => score > *best_score,
};
if is_better {
best = Some((field.name, field.text, score, embedding.positions));
}
}
if let Some((field_name, field_text, score, positions)) = best {
results.push(Ranked {
candidate,
score,
field: Some(field_name),
ranges: to_ranges(&positions),
matched_text: field_text.to_string(),
input_index,
});
}
}
results.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
.then_with(|| {
a.matched_text
.chars()
.count()
.cmp(&b.matched_text.chars().count())
})
.then_with(|| a.matched_text.cmp(&b.matched_text))
.then_with(|| a.input_index.cmp(&b.input_index))
});
results
.into_iter()
.map(|ranked| MatchResult {
candidate: ranked.candidate,
score: ranked.score,
field: ranked.field,
ranges: ranked.ranges,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn session(
name: &str,
directory: Option<&str>,
command: Option<&str>,
tool: Option<&str>,
) -> MatchCandidate {
MatchCandidate::Session(SessionCandidate {
name: name.to_string(),
directory: directory.map(str::to_string),
command: command.map(str::to_string),
tool: tool.map(str::to_string),
})
}
fn command(label: &str) -> MatchCandidate {
MatchCandidate::Command(CommandCandidate {
label: label.to_string(),
})
}
fn directory(path: &str) -> MatchCandidate {
MatchCandidate::Directory(DirectoryCandidate {
path: path.to_string(),
})
}
fn label(candidate: &MatchCandidate) -> String {
match candidate {
MatchCandidate::Session(s) => format!("session:{}", s.name),
MatchCandidate::Command(c) => format!("command:{}", c.label),
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
}
}
fn positions(result: &MatchResult) -> Vec<usize> {
result
.ranges
.iter()
.flat_map(|&(start, end)| start..end)
.collect()
}
#[allow(clippy::type_complexity)]
fn expect_results(
query: &str,
candidates: &[MatchCandidate],
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
) {
let results = match_and_rank(query, candidates);
let actual: Vec<(String, i32)> = results
.iter()
.map(|r| (label(r.candidate), r.score))
.collect();
let expected_labels: Vec<(String, i32)> = expected
.iter()
.map(|(l, s, _, _)| (l.to_string(), *s))
.collect();
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
let expected_fields: Vec<Option<MatchFieldName>> =
expected.iter().map(|(_, _, f, _)| *f).collect();
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
let expected_positions: Vec<Vec<usize>> =
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
assert_eq!(
actual_positions, expected_positions,
"positions for query {query:?}"
);
}
#[test]
fn f1_empty_query_keeps_input_order() {
expect_results(
"",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
session(
"killer-app",
Some("~/dev/killer-app"),
Some("claude --resume"),
Some("claude"),
),
command("Kill selected session"),
],
&[
("session:kill", 0, None, &[]),
("session:killer-app", 0, None, &[]),
("command:Kill selected session", 0, None, &[]),
],
);
}
#[test]
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
expect_results(
"kill",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
session(
"killer-app",
Some("~/dev/killer-app"),
Some("claude --resume"),
Some("claude"),
),
command("Kill selected session"),
],
&[
(
"session:kill",
178,
Some(MatchFieldName::Name),
&[0, 1, 2, 3],
),
(
"session:killer-app",
146,
Some(MatchFieldName::Name),
&[0, 1, 2, 3],
),
(
"command:Kill selected session",
146,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
],
);
}
#[test]
fn f3_path_segment_boundaries_beat_mid_segment() {
expect_results(
"foo",
&[
directory("~/dev/foobar"),
directory("~/src/myfoo"),
directory("~/foo"),
],
&[
(
"directory:~/foo",
78,
Some(MatchFieldName::Path),
&[2, 3, 4],
),
(
"directory:~/dev/foobar",
74,
Some(MatchFieldName::Path),
&[6, 7, 8],
),
(
"directory:~/src/myfoo",
56,
Some(MatchFieldName::Path),
&[8, 9, 10],
),
],
);
}
#[test]
fn f4_no_match_returns_empty() {
expect_results(
"zzz",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
command("Kill selected session"),
],
&[],
);
}
#[test]
fn f5_highest_scoring_embedding_beats_greedy() {
expect_results(
"mtu",
&[
session("maestro-tui", None, None, None),
session("maestro-daemon", None, None, None),
session("mtui-scratch", None, None, None),
],
&[
(
"session:mtui-scratch",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
(
"session:maestro-tui",
91,
Some(MatchFieldName::Name),
&[0, 8, 9],
),
],
);
}
#[test]
fn f6_name_beats_weighted_directory() {
expect_results(
"api",
&[
session("api", Some("~/dev/web"), Some("bash"), None),
session("worker", Some("~/dev/api"), Some("bash"), None),
],
&[
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
(
"session:worker",
50,
Some(MatchFieldName::Directory),
&[6, 7, 8],
),
],
);
}
#[test]
fn f7_case_insensitive_length_breaks_tie() {
expect_results(
"api",
&[
session("API-Gateway", None, None, None),
session("apiary", None, None, None),
],
&[
(
"session:apiary",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
(
"session:API-Gateway",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
],
);
}
#[test]
fn f8_exact_tool_beats_command_field() {
expect_results(
"claude",
&[
session(
"nameless",
Some("~/dev/x"),
Some("claude --resume"),
Some("claude"),
),
session("clay", Some("~/dev/y"), Some("bash"), None),
],
&[(
"session:nameless",
186,
Some(MatchFieldName::Tool),
&[0, 1, 2, 3, 4, 5],
)],
);
}
#[test]
fn f9_separator_earns_word_boundary_bonus() {
expect_results(
"ws",
&[
session("websearch", None, None, None),
session("web-search", None, None, None),
],
&[
(
"session:web-search",
71,
Some(MatchFieldName::Name),
&[0, 4],
),
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
],
);
}
#[test]
fn f10_command_labels_rank_by_boundary_and_gap() {
expect_results(
"se",
&[
command("Kill selected session"),
command("Create a session"),
command("Show help"),
],
&[
(
"command:Show help",
53,
Some(MatchFieldName::Label),
&[0, 6],
),
(
"command:Kill selected session",
51,
Some(MatchFieldName::Label),
&[5, 6],
),
(
"command:Create a session",
47,
Some(MatchFieldName::Label),
&[9, 10],
),
],
);
}
#[test]
fn f11_directory_matches_separate_on_initial_gap() {
expect_results(
"dev",
&[
session("api", Some("~/dev/api"), Some("bash"), None),
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
session("scratch", Some("~/srv/devops"), Some("bash"), None),
],
&[
(
"session:api",
54,
Some(MatchFieldName::Directory),
&[2, 3, 4],
),
(
"session:scratch",
50,
Some(MatchFieldName::Directory),
&[6, 7, 8],
),
(
"session:notes",
44,
Some(MatchFieldName::Directory),
&[12, 13, 14],
),
],
);
}
#[test]
fn f12_exact_beats_prefix() {
expect_results(
"quit",
&[command("Quit Maestro"), command("Quit")],
&[
(
"command:Quit",
178,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
(
"command:Quit Maestro",
146,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
],
);
}
}
+62
View File
@@ -813,6 +813,21 @@ impl Palette {
}
}
/// Background for the card whose session the pane is showing
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
/// selection surface, so the sidebar reads as one ladder: unfilled, half
/// filled for the session in the pane, fully filled under the cursor.
///
/// Blended rather than added as a third `Surface` — a new surface means
/// eight more colours hand-picked seventeen times — which is also why it
/// borrows the *selection* surface's foregrounds: those are the ones
/// already picked for a filled row. A preset whose colours the terminal
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
/// says "in the pane" with the filled glyph alone.
pub fn pane_row_bg(&self) -> Color {
mix(self.card.bg, self.selection.bg, 0.55)
}
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned")
}
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
/// there is nothing to blend and `from` comes back unchanged.
fn mix(from: Color, to: Color, amount: f64) -> Color {
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
return from;
};
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked.
@@ -937,6 +962,43 @@ mod tests {
}
}
/// The pane row is the middle rung: a visible fill against the card, still
/// a different surface from the cursor's, and carrying the selection
/// surface's foregrounds, which it borrows rather than shipping its own.
#[test]
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
for (preset, p) in rgb_presets() {
let bg = p.pane_row_bg();
let from_card = contrast_ratio(p.card.bg, bg)
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
assert!(
from_card >= 1.15,
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
);
let from_selection =
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
assert!(
from_selection >= 1.15,
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
);
for (role, color) in p.selection.roles() {
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
);
}
}
}
/// A preset the terminal themes has nothing to blend from.
#[test]
fn ansi_keeps_the_card_background_for_the_pane_row() {
let p = Palette::ANSI;
assert_eq!(p.pane_row_bg(), p.card.bg);
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
+19
View File
@@ -86,6 +86,9 @@ pub struct RenderInputs<'a> {
pub help: &'a HelpOverlay,
/// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput,
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
/// up.
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
/// Liveness + release of the daemon, for the status bar's indicator and the
/// restart overlay (`docs/RESTART.md`).
pub link: &'a crate::daemon_link::LinkStatus,
@@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
}
map.modal = Some(area);
}
Some(OverlayKind::CommandBar) => {
if let Some(bar) = inputs.command_bar {
if let Some((x, y)) = bar.render(f, area) {
f.set_cursor_position((x, y));
}
map.modal = Some(area);
}
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
@@ -415,6 +426,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
@@ -427,6 +439,7 @@ mod tests {
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link,
};
let backend = ratatui::backend::TestBackend::new(120, 24);
@@ -511,6 +524,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
@@ -522,6 +536,7 @@ mod tests {
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
@@ -561,6 +576,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
@@ -575,6 +591,7 @@ mod tests {
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -601,6 +618,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
@@ -615,6 +633,7 @@ mod tests {
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
+173 -19
View File
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
/// on its own row: a highlight that lands on the session already in the pane
/// still has to be visible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CardFill {
/// An ordinary card, on the card surface.
None,
/// The session the pane is showing.
Pane,
/// Under the cursor, with the sidebar holding the keyboard.
Cursor,
}
impl CardFill {
/// Both filled tiers carry the selection surface's foregrounds — those are
/// the colours picked for a filled row, and a colour chosen for the card
/// background is not legible on one.
fn filled(self) -> bool {
self != CardFill::None
}
}
/// Cards take the cursor's fill only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
fn card_base(fill: CardFill, focused: bool) -> Style {
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
let mut style = palette.surface(fill.filled()).text_style();
match fill {
CardFill::Cursor => {}
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
CardFill::None => {}
}
style
}
@@ -55,6 +80,9 @@ pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
/// The session the active tab's pane is showing. It wears a fill of its
/// own, so "in the pane" is not carried by the ● glyph alone.
pub pane_session: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,8 +141,7 @@ impl Sidebar {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let base = card_base(self.card_fill(s, focused), focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
@@ -226,6 +253,18 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// Which fill this session's card wears. The cursor wins on its own row,
/// so a highlight parked on the session already in the pane stays visible.
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
CardFill::Cursor
} else if self.pane_session == Some(s.id) {
CardFill::Pane
} else {
CardFill::None
}
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
@@ -233,12 +272,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let rail_style = base.fg(activity_color(s.activity, fill));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -257,7 +297,7 @@ impl Sidebar {
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
@@ -283,11 +323,11 @@ impl Sidebar {
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
@@ -308,7 +348,7 @@ impl Sidebar {
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
let surface = palette.surface(fill.filled());
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
@@ -352,10 +392,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
palette::active().surface(fill.filled()).activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -521,6 +559,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 12));
assert!(
@@ -545,6 +584,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
})
.groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
@@ -556,6 +596,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: Default::default(),
pane_session: None,
};
assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty());
@@ -567,6 +608,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new();
@@ -591,6 +633,7 @@ mod tests {
sessions: vec![session],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]);
@@ -621,6 +664,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp"));
@@ -636,6 +680,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude");
@@ -646,6 +691,87 @@ mod tests {
);
}
/// Whether any cell in the buffer is painted on `bg`.
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
(0..buf.area.height)
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
}
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
/// not the ● glyph alone carrying "this is the one on screen".
#[test]
fn the_session_in_the_pane_wears_the_pane_fill() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
let none = Sidebar {
pane_session: None,
..sb
};
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
}
/// The cursor's fill wins on its own row: a highlight that lands on the
/// session already in the pane has to stay visible.
#[test]
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(sid)),
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw_with_focus(&sb, 28, 6, true);
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
assert!(
!any_cell_on(&buf, p.pane_row_bg()),
"the pane fill is showing through the cursor's own row"
);
}
/// A card the pane is showing is a filled row, so it takes the selection
/// surface's foregrounds — a colour picked for the card background is not
/// legible on a fill.
#[test]
fn the_pane_row_carries_selection_foregrounds() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let mut sess = session_named(1, "claude");
sess.activity = ActivityState::AwaitingInput;
let sb = Sidebar {
sessions: vec![sess],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw(&sb, 28, 6);
let on_pane_row = |fg: Color| {
(0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| {
let st = buf[(x, y)].style();
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
})
})
};
assert!(
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
"the rail must carry the state in the filled surface's colours"
);
assert!(
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
"a card surface colour is painted onto the pane fill"
);
}
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "claude");
@@ -654,6 +780,7 @@ mod tests {
sessions: vec![sess],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 6);
let all = rows(&buf);
@@ -682,6 +809,7 @@ mod tests {
sessions: vec![blocked, clear, unprobed],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 44, 10));
@@ -728,6 +856,7 @@ mod tests {
sessions: vec![generated, settled],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -760,6 +889,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -786,6 +916,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
@@ -817,6 +948,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -856,6 +988,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height)
@@ -884,6 +1017,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let on_selection = |fg: Color| {
@@ -923,6 +1057,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -942,6 +1077,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
@@ -970,6 +1106,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -999,6 +1136,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1026,6 +1164,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1054,6 +1193,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 42, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1087,6 +1227,7 @@ mod tests {
sessions: vec![legacy],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 40, 8));
assert!(
@@ -1130,6 +1271,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 6));
assert!(
@@ -1145,6 +1287,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(
@@ -1166,6 +1309,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 9, 6));
assert!(
@@ -1192,6 +1336,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1216,6 +1361,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1237,6 +1383,7 @@ mod tests {
sessions: vec![highlighted],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height {
@@ -1262,6 +1409,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
@@ -1297,6 +1445,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height)
@@ -1316,6 +1465,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
},
28,
8,
@@ -1336,6 +1486,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
@@ -1354,6 +1505,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
@@ -1371,6 +1523,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash");
@@ -1390,6 +1543,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
+4
View File
@@ -52,6 +52,10 @@ pub enum OverlayKind {
/// so this stays comparable.
Rename(Uuid),
Help,
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
/// rename buffer does — so this stays comparable.
CommandBar,
Toast(String),
}
+3 -3
View File
@@ -49,9 +49,9 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge: the only ring left in the session list
is `.is-active`'s, which means "open in a pane". The dashed edges belong to the
pane — the canvas ring and the throwaway tab.
Rows say it with fill, not with an edge — see the tier ladder in
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
belong to the pane: the canvas ring and the throwaway tab.
The hue is a palette token, not a fixed colour: a palette whose accent already
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
+32
View File
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
selection background. Contrast is a property of the palette (values are RGB)
and is asserted in the palette's tests, not left to the terminal theme.
## Which row is filled
Both clients say every one of these with a **background fill**, never an edge.
A row is usually several of them at once, so they form a ladder: the tier
furthest down wins the paint.
| Tier | Web | TUI |
|---|---|---|
| Alive | 6% accent wash | — (the rail carries it) |
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in
*is* the precedence. Add a tier where it belongs in that run, never by piling
on another class to out-weigh the rule above it.
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
since those are the colours picked for a filled row. `pane_row_bg()` is
blended from the two surfaces rather than shipped as a third one — a third
surface would mean eight more colours hand-picked seventeen times — and the
palette tests assert it stays a visible step from both. The cursor's fill wins
on its own row, so a highlight parked on the session already in the pane is
still visible.
The one edge left in either list is the TUI's `▏` marker, which stands in for
the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
+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;
+131
View File
@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
//
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function mount(s: Session): HTMLElement {
const handle = renderSidebar(storeWith([s]));
mounted = handle;
document.body.appendChild(handle.el);
return handle.el;
}
function raise(name: string, s: Session): void {
document.dispatchEvent(new CustomEvent(name, { detail: s }));
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("kill from the action page", () => {
it("goes through the daemon, without a first confirmation", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const s = session();
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
window.fetch = ((url: string) => {
if (String(url).includes("force=true")) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const s = session({ blocked_work: { untracked_files: 1 } });
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file");
});
});
describe("rename from the action page", () => {
it("expands the folder hiding the card, since the prompt lives in the card", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
});
});
+49
View File
@@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateCwd,
abbreviateFolderName,
blockerStats,
customSessionName,
@@ -16,6 +17,7 @@ import {
sessionBlockers,
isProvisionalName,
sessionName,
sessionTreePath,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
@@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** Ask the sidebar to kill a session — the command bar's action page picking
* `kill` (#151). Detail is the session. Handled here, so the kill goes
* through the same confirm-and-blockers flow the card's ✕ does
* (`docs/WORKTREES.md`); the bar never reimplements it. */
export const KILL_SESSION_EVENT = "catalog-kill-session";
/** Ask the sidebar to rename a session — the command bar's action page picking
* `rename` (#151). Detail is the session. The prompt is the card's own inline
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
export const RENAME_SESSION_EVENT = "catalog-rename-session";
/** A session row was clicked (issue #133). The row's `href` drives normal
* hash navigation, but a split/drag can move focus without ever touching
* `location.hash` — so a click back onto a session whose hash still
@@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
};
document.addEventListener("catalog-rerender", rerenderHandler);
// The command bar's action page (#151) picks an action and hands it here:
// both flows are the ones the card already has, reached by session rather
// than by row.
const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
void requestKill(s, sessionName(s));
};
const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const row = rowForSession(s);
if (row) beginRename(row, s);
};
document.addEventListener(KILL_SESSION_EVENT, killHandler);
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
/** The card a session is rendered as, expanding whatever folder hides it
* first: the rename prompt lives inside the card, so a collapsed folder
* would otherwise swallow the request. */
function rowForSession(s: Session): HTMLElement | null {
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
const found = find();
if (found) return found;
// Folder paths are the abbreviated directory's segments joined back up
// (see buildTree), so the session's own path is normalised the same way
// before it is compared against them.
const cwd = sessionTreePath(s);
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
for (const folder of [...collapsed]) {
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
}
rerenderHandler();
return find();
}
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed the spawn modal with the session's dir/command.
@@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
unsubscribe();
},
};
+420 -6
View File
@@ -1,18 +1,30 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_SELECT_EVENT } from "./catalog.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import {
barGroups,
barRows,
closeCommandBar,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
isCommandBarOpen,
openCommandBar,
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionActionCandidates,
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 +185,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 +268,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 +278,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 +317,407 @@ 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();
}
});
});
describe("the session action page (#151)", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
];
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands: [],
onOpenSession: () => {},
...over,
});
}
function headerText(): string | undefined {
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
open();
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
expect(activeName()).toBe("Kill session");
});
it("names the session being acted on", () => {
open();
press("ArrowDown");
press("Tab");
expect(headerText()).toContain("worker");
});
it("drops the kind headers, since the page is one kind", () => {
open();
press("Tab");
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
open();
press("Tab");
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
it("does nothing on a command row", () => {
openCommandBar({
sessions,
homeDir: "/home/me",
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
onOpenSession: () => {},
});
type(">");
const before = rowNames();
press("Tab");
expect(rowNames()).toEqual(before);
expect(headerText()).toBeUndefined();
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await Promise.resolve();
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
});
it("returns to the result list on Escape, with the previous query intact", () => {
open();
type("wor");
press("Tab");
expect(headerText()).toContain("worker");
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
open();
type("wor");
press("Tab");
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
open();
press("Tab");
type("ren");
press("Backspace");
expect(headerText()).toContain("api");
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
open();
press("Tab");
press("Escape");
press("Escape");
expect(isCommandBarOpen()).toBe(false);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
const seen: string[] = [];
for (const [hook, key] of [
["onKillSession", "Kill session"],
["onRenameSession", "Rename session"],
["onSpawnSimilar", "Spawn similar"],
] as const) {
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
type("wor");
press("Tab");
type(key);
press("Enter");
expect(isCommandBarOpen()).toBe(false);
}
expect(seen).toEqual([
"onKillSession:worker",
"onRenameSession:worker",
"onSpawnSimilar:worker",
]);
});
it("hands each action to the flow it already has, by default", () => {
const seen: string[] = [];
const listeners: Array<[string, EventListener]> = [
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
];
for (const [name, fn] of listeners) document.addEventListener(name, fn);
try {
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
open();
press("Tab");
type(action);
press("Enter");
}
} finally {
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
}
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
});
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
+460 -32
View File
@@ -1,33 +1,58 @@
// 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. `tab` on a session drills into
// its action page (#151), which is the same overlay over three actions.
import { SESSION_SELECT_EVENT } from "./catalog.js";
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_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;
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | 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 +61,63 @@ 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 };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
@@ -99,14 +181,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 +282,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 +308,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 +341,46 @@ 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 (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} 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" && !isActionCandidate(candidate)) {
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 +392,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 +409,25 @@ 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;
/** The action page's three actions (#151). Each defaults to raising the
* event the flow that already implements it listens on — the sidebar's
* confirm-and-blockers kill, its inline rename prompt, the spawn form
* seeded from the session. The bar picks the action and nothing more. */
onKillSession?: (session: Session) => void;
onRenameSession?: (session: Session) => void;
onSpawnSimilar?: (session: Session) => void;
}
interface OpenBar {
@@ -228,8 +456,29 @@ 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 sessionEvent =
(name: string) =>
(session: Session): void => {
document.dispatchEvent(new CustomEvent(name, { detail: session }));
};
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
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 +495,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 +512,98 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let rows: BarResult[] = [];
let highlighted = 0;
// The action page (#151). `page` is the session being acted on, `stashed`
// the result-list query it was drilled into from — backing out restores it,
// so the round trip costs the operator nothing.
let page: Session | null = null;
let stashed = "";
let header: HTMLElement | null = null;
// `~`'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[] {
if (page) return sessionActionCandidates();
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;
@@ -276,8 +617,28 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
}
/** The line naming the session being acted on, so the target is never
* ambiguous. Present only while the action page is up. */
function paintHeader(): void {
header?.remove();
header = null;
if (!page) return;
header = document.createElement("div");
header.className = "cmdbar-page-header";
header.setAttribute("role", "presentation");
header.textContent = `Actions for "${sessionName(page)}"`;
panel.insertBefore(header, results);
}
function render(): void {
rows = barRows(input.value, candidates);
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
input.placeholder = page
? `Act on ${sessionName(page)}…`
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
paintHeader();
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 +655,15 @@ 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 — and the action page is one kind too.
if (parsed.sigil === null && !page) {
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);
@@ -317,19 +682,82 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
paintHighlight();
}
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
function drillIn(): void {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
page = candidate.session;
stashed = input.value;
input.value = "";
render();
}
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
function leavePage(): void {
page = null;
input.value = stashed;
stashed = "";
render();
}
function runAction(action: SessionAction, session: Session): void {
close();
switch (action.id) {
case "kill":
killSession(session);
return;
case "rename":
renameSession(session);
return;
case "spawn-similar":
spawnSimilar(session);
return;
}
}
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const sessionId = result.candidate.session.id;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
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 {
switch (e.key) {
case "Escape":
e.preventDefault();
close();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else close();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && input.value === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
+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,
+59 -40
View File
@@ -101,9 +101,11 @@
oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h))
);
/* Opaque, not a wash: the previewed row reads as a fill, and a fill has to
land on the same colour whatever tier the row was already painted. */
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
the same colour whatever tier the row was already painted. `--accent-fill`
is the same recipe on the accent, so the two tiers differ by hue alone. */
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
--preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6)
@@ -1148,38 +1150,35 @@ textarea {
transition: box-shadow var(--dur-fast) var(--ease-out);
}
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
.tree-leaf[data-status="running"] {
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
once, so every qualifier below sits in `:where()`: each rule is worth the
same specificity and the ladder is decided purely by the order they appear
in, latest wins. Add a tier where it belongs in that order, not by piling on
another class to out-weigh the rule above it. Each tier paints a background
and nothing else — the list says all of this with fill, never an edge. */
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
.tree-leaf:where([data-status="running"]) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the
preview hue — never the accent, which belongs to the row actually open. */
.tree-leaf:hover {
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
which belongs to the row actually open. */
.tree-leaf:where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf[data-status="running"]:hover {
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-leaf:focus-visible {
.tree-leaf:where(:focus-visible) {
outline: none;
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. On a session row the cursor is
also what previews it, so it reads exactly like a hover — the preview hue,
deepening to the full fill once the preview lands. A folder row previews
nothing, so it keeps the plain cursor ring. */
.tree-leaf.is-highlighted {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf.is-highlighted[data-status="running"] {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
/* A folder row previews nothing, so its cursor stays a ring. */
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
@@ -1188,25 +1187,24 @@ textarea {
outline: none;
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active,
.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
box-shadow: inset 0 0 0 1px var(--accent-ring);
/* 3. Open in some pane, but not the focused one (issue #33). */
.tree-leaf:where(.is-open) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* 4. The session currently mounted in the content pane. */
.tree-leaf:where(.is-active) {
background: var(--accent-fill);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
/* The row the content pane is previewing right now — after `.is-active` so a
preview of an already-open session still reads as provisional. */
.tree-leaf.is-previewing,
.tree-leaf.is-previewing[data-status="running"],
.tree-leaf.is-previewing.is-active,
.tree-leaf.is-previewing.is-highlighted {
/* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional. */
.tree-leaf:where(.is-previewing) {
background: var(--preview-tint);
box-shadow: none;
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
@@ -1474,6 +1472,15 @@ input.leaf-rename:focus {
padding: var(--sp-2) 0;
}
/* The action page's header (#151): names the session being acted on, so the
target of kill/rename/spawn-similar is never ambiguous. */
.cmdbar-page-header {
padding: var(--sp-2) var(--sp-4);
border-bottom: 1px solid var(--border);
font-size: var(--fs-12);
color: var(--muted);
}
.cmdbar-group-title {
padding: var(--sp-2) var(--sp-4) var(--sp-1);
font-size: var(--fs-11);
@@ -1519,6 +1526,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);
@@ -3300,13 +3326,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* =========================================================================
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.