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
8 changed files with 1417 additions and 86 deletions
+306 -16
View File
@@ -168,6 +168,14 @@ pub enum Effect {
/// Fast-forward spawn (#67): resolve every default from the daemon and
/// spawn with no form; unresolvable defaults open a pre-filled form.
FastForward,
/// The same fast path pinned to one directory — the command bar's `~`
/// (#154). Nothing may override the directory the operator named.
FastForwardIn(PathBuf),
/// Fetch the command bar's `~` candidates: recent dirs and the project
/// list. `bar` guards a load landing in a bar that was reopened since.
LoadBarDirectories {
bar: u64,
},
/// The form's target changed: probe `/api/gitroot` so the worktree
/// section knows its base repo (#65). `seq` guards stale answers.
ProbeGitRoot {
@@ -386,6 +394,8 @@ impl App {
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft => self.spawn_load_draft(),
Effect::FastForward => self.spawn_fast_forward(),
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
@@ -459,6 +469,16 @@ impl App {
self.dispatch(Action::AttachHighlighted),
)
}
// The bar picks the action and hands off; it never
// reimplements one.
BarOutcome::Open(BarTarget::Command(action)) => {
self.close_command_bar();
(OverlayDecision::Consumed, self.dispatch(action))
}
BarOutcome::Open(BarTarget::Directory(dir)) => {
self.close_command_bar();
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
}
}
}
Some(OverlayKind::Help) => {
@@ -506,7 +526,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenCommandBar => self.open_command_bar(),
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
@@ -628,12 +648,21 @@ impl App {
/// while a session is previewed discards the preview first, like any
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
/// so closing gives the keyboard back to whatever held it.
fn open_command_bar(&mut self) {
fn open_command_bar(&mut self) -> Vec<Effect> {
let snapshot = self.catalog.snapshot();
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
// The keymap for the mode the bar was opened from, which is the one
// the operator would be typing into behind it. Same source the help
// overlay reads, so the two can never disagree.
let keymap = self.cfg.keymap.entries(self.state.mode);
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
let id = bar.id();
self.command_bar = Some(bar);
self.pending_preview = None;
self.clear_preview();
self.state.show_overlay(OverlayKind::CommandBar);
// `~`'s candidates are a network round trip; the bar opens without
// them and fills in when they land.
vec![Effect::LoadBarDirectories { bar: id }]
}
/// Take the bar down, dropping its query. Every exit goes through here so
@@ -990,18 +1019,27 @@ impl App {
projects,
names,
focused_cwd,
pinned,
} = update
{
self.fast_forward_spawn(info, projects, names, focused_cwd)
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
.await;
return;
}
// Addressed to the bar, not to a form: a load that outlived its bar is
// dropped rather than filling in the next one.
if let DraftUpdate::BarDirectories { bar, paths } = update {
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
open.set_directories(&paths);
}
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
// Handled above; they target no particular draft.
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
};
// Match by identity: dismissing the form and opening another while a
// load is in flight must not let the old answer land in the new form.
@@ -1036,7 +1074,9 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
unreachable!()
}
}
if follow_up_branches {
self.spawn_load_branches();
@@ -1064,16 +1104,52 @@ impl App {
projects: projects.ok(),
names,
focused_cwd,
pinned: None,
});
});
}
/// The same fast path, pinned to the directory the command bar's `~` row
/// named (#154). Off the loop for the same reason as [`Self::
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
fn spawn_fast_forward_in(&self, dir: PathBuf) {
let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
let _ = tx.send(DraftUpdate::FastForward {
info: info.ok(),
projects: projects.ok(),
names,
focused_cwd: None,
pinned: Some(dir),
});
});
}
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
/// (newest first) ahead of the project list. Either failing is a shorter
/// list, not an error state.
fn spawn_load_bar_directories(&self, bar: u64) {
let daemon = self.daemon.clone();
let tx = self.draft_tx.clone();
tokio::spawn(async move {
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
let mut paths: Vec<PathBuf> = recents
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
.unwrap_or_default();
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
});
}
async fn fast_forward_spawn(
&mut self,
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
pinned: Option<PathBuf>,
) {
// Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -1087,13 +1163,21 @@ impl App {
_ => Vec::new(),
};
let discovered = projects.unwrap_or_default();
let resolved = crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
// A pinned directory is the operator's own pick, so it takes no
// fallback: a directory outside the project scan resolves no project,
// which is an unresolvable default and opens the form seeded with it.
let resolved = match pinned.as_deref() {
Some(dir) => {
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
}
None => crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
),
};
if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await;
return;
@@ -1105,8 +1189,10 @@ impl App {
};
draft.home_dir = home_dir;
// The fast path resolved its own project; its pick wins over the
// focused session's, so the seed never lands on top of it.
draft.seed_cwd = None;
// focused session's, so the seed never lands on top of it. A pinned
// directory is the exception: it is the seed, whether or not the scan
// knew it as a project.
draft.seed_cwd = pinned;
if !presets.is_empty() {
draft.set_presets(presets);
}
@@ -1463,7 +1549,12 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
/// The command bar pinned the spawn to this directory (#154): nothing
/// — not the focused session, not the recency ranking — may move it.
pinned: Option<PathBuf>,
},
/// The command bar's `~` candidates arrived (#154).
BarDirectories { bar: u64, paths: Vec<PathBuf> },
}
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -2222,6 +2313,102 @@ mod tests {
assert_eq!(req["worktree"]["create_branch"], json!(true));
}
/// The command bar's `~` pins the spawn: the directory the operator named
/// wins over the recency ranking that plain fast-forward would follow.
#[tokio::test]
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
use axum::routing::{get, post};
use axum::Json;
use serde_json::json;
use std::sync::{Arc, Mutex};
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
"last_command": "zsh"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
seen.lock().unwrap().push(body);
Json(json!({
"id": uuid::Uuid::from_u128(78).to_string(),
"cwd": "/repos/pinned", "command": "zsh",
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names))
.route(
maestro_protocol::routes::SESSIONS,
post(spawn_session).with_state(seen),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
.await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
let posts = posted.lock().unwrap();
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
assert_eq!(posts[0]["command"], json!("zsh"));
}
/// A directory the project scan does not know resolves no project, which
/// is an unresolvable default: the form opens seeded with it rather than
/// the spawn silently landing somewhere else.
#[tokio::test]
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
let mut app = make_app();
app.fast_forward_spawn(
None,
Some(vec![maestro_protocol::Project {
path: PathBuf::from("/repos/fresh"),
name: "fresh".into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now()),
last_command: Some("aider".into()),
}]),
vec!["crimson-otter".into()],
None,
Some(PathBuf::from("/elsewhere")),
)
.await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
let draft = app.draft.as_ref().expect("form is up");
// The seed is dropped by the picker when no project matches it, so
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
}
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
/// switching which session the pane shows, and leaving the pane back to
/// the sidebar. A sidebar highlight alone must report nothing.
@@ -2850,12 +3037,115 @@ mod tests {
.iter()
.map(|r| match r.target {
BarTarget::Session(id) => id,
ref other => panic!("expected a session row, got {other:?}"),
})
.collect::<Vec<_>>(),
vec![bravo, alpha]
);
}
/// `~`'s candidates are a network round trip, so opening asks for them
/// rather than blocking the keystroke on one.
#[test]
fn opening_the_bar_asks_for_its_directories() {
let mut app = make_app();
let effects = app.dispatch(Action::OpenCommandBar);
let bar = app.command_bar.as_ref().unwrap().id();
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
}
fn type_into_bar(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_overlay_input(&InputKey::Char {
c,
ctrl: false,
alt: false,
shift: false,
});
}
}
/// `>` lists the live keymap for the current mode, matched on the label
/// alone, and Enter hands the action to the ordinary dispatcher.
#[test]
fn enter_on_a_command_row_dispatches_the_action() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">defaults");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Create session with defaults");
assert_eq!(
row.target,
BarTarget::Command(Action::FastForwardSpawn),
"chords: {:?}",
row.chords
);
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::FastForward]);
assert!(app.state.overlay().is_none());
assert!(app.command_bar.is_none());
}
/// The row shows the chord bound to it, and both chords when the map binds
/// two — but the chord is never what matched.
#[test]
fn a_command_row_carries_its_chords() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">open selected");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Open selected item");
assert!(
row.chords.iter().any(|c| c == "enter"),
"chords: {:?}",
row.chords
);
assert!(
row.chords.len() > 1,
"the default map binds more than one chord to it: {:?}",
row.chords
);
}
#[test]
fn enter_on_a_directory_row_fast_forward_spawns_there() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
app.command_bar
.as_mut()
.unwrap()
.set_directories(&[PathBuf::from("/repos/api")]);
type_into_bar(&mut app, "~api");
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(
effects,
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
);
assert!(app.state.overlay().is_none());
}
/// A load that outlived its bar fills in nothing: the reopened bar has its
/// own identity.
#[tokio::test]
async fn a_stale_directory_load_is_dropped() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
let stale = app.command_bar.as_ref().unwrap().id();
app.handle_overlay_input(&InputKey::Esc);
app.dispatch(Action::OpenCommandBar);
app.apply_draft_update(DraftUpdate::BarDirectories {
bar: stale,
paths: vec![PathBuf::from("/repos/api")],
})
.await;
type_into_bar(&mut app, "~");
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
}
/// Opening from Insert mode leaves the keyboard where it was, so closing
/// gives it back — the bar is a view over the app, not a place in it.
#[test]
+523 -59
View File
@@ -1,15 +1,17 @@
//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a
//! result list, opened from Normal or Insert mode, closed with Escape.
//!
//! Sessions are the only kind here (#153); the sigils (#154), the session
//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The
//! grouping below is already kind-generic so those slot in without a rewrite.
//! Three kinds live here: sessions and commands, ranked together by default,
//! and directories, which need the `~` sigil (#154). The session action page
//! (#155) and the leader's literal Ctrl-K (#156) land later.
//!
//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the
//! groups are a rendering of the ranked list, in the order their best-scoring
//! member appears. The web bar (`web/src/commandBar.ts`) implements the same
//! contract; the two must agree.
use std::path::{Path, PathBuf};
use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path};
use maestro_protocol::{Session, SessionStatus};
use ratatui::layout::Rect;
@@ -19,19 +21,73 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
use crate::action::Action;
use crate::input::InputKey;
use crate::matcher::{
match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate,
match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate,
MatchFieldName, MatchRange, SessionCandidate,
};
use crate::overlays::centered;
use crate::palette;
use crate::text_input::TextInput;
/// What Enter on a row does. Only sessions exist today; `>` commands and `~`
/// directories (#154) become further variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Identity for routing an async directory load back to *this* bar: a load in
/// flight must not land in a bar the operator closed and reopened.
static NEXT_BAR_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// What Enter on a row does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BarTarget {
Session(Uuid),
/// Dispatch the action the row stands for. The chord is displayed beside
/// the row but is not part of the target: two chords bound to one action
/// are one row.
Command(Action),
/// Fast-forward spawn in this directory, absolute — the abbreviated form
/// is what the row shows and what the matcher ranked.
Directory(PathBuf),
}
/// The first character of the query, when it is one of the two that narrow the
/// list to a single kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarSigil {
/// `>` — commands from the live keymap.
Command,
/// `~` — recent dirs and the project list.
Directory,
}
/// A raw query line split into its sigil and the query proper.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BarQuery<'q> {
pub sigil: Option<BarSigil>,
/// What the matcher sees: everything after a sigil, or the whole query.
pub query: &'q str,
}
/// Split a raw query line into its sigil and the query proper.
///
/// A sigil is only a sigil in first position: `>` or `~` anywhere else is a
/// literal query character, which is what a directory query with a second `~`
/// in it depends on. Backspacing past a sigil needs no special case either —
/// the first character goes and the query is a mixed one again. Mirrors the
/// web's `parseBarQuery`.
pub fn parse_bar_query(raw: &str) -> BarQuery<'_> {
let sigil = match raw.chars().next() {
Some('>') => BarSigil::Command,
Some('~') => BarSigil::Directory,
_ => {
return BarQuery {
sigil: None,
query: raw,
}
}
};
BarQuery {
sigil: Some(sigil),
query: &raw[1..],
}
}
/// One row of the result list, already resolved to what it renders and what it
@@ -50,6 +106,9 @@ pub struct BarRow {
pub field: Option<MatchFieldName>,
pub ranges: Vec<MatchRange>,
pub dead: bool,
/// Every chord bound to a command row's action, displayed beside it and
/// never matched. Empty for every other kind.
pub chords: Vec<String>,
}
impl BarRow {
@@ -78,7 +137,7 @@ fn kind_title(kind: CandidateKind) -> &'static str {
}
/// What a keystroke asked the bar to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BarOutcome {
/// The bar stays up.
Stay,
@@ -128,40 +187,112 @@ pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate
}
}
/// One command row: a distinct [`Action`] and every chord bound to it.
///
/// Dedupe is by **action, never by chord** — the default map binds both
/// `enter` and `tab` to `AttachHighlighted`, and listed naively that is two
/// identical rows. Actions that share a `help_label` collapse too:
/// `AttachHighlighted` and `ToggleAttachHighlighted` are the same match arm in
/// `app.rs` and genuinely do the same thing, so they are one row carrying both
/// chords rather than two rows a reader cannot tell apart. `Noop` is left out:
/// it exists to suppress a default binding, not as something to run.
///
/// Input order is the keymap's own (`Keymap::entries` sorts by chord), and the
/// first mention of each action keeps its place — the registry order the
/// contract asks an empty `>` query to list.
pub fn command_entries(entries: &[(String, Action)]) -> Vec<(Action, Vec<String>)> {
let mut out: Vec<(Action, Vec<String>)> = Vec::new();
for (chord, action) in entries {
if *action == Action::Noop {
continue;
}
let label = action.help_label();
match out
.iter_mut()
.find(|(kept, _)| kept == action || kept.help_label() == label)
{
Some((_, chords)) => {
if !chords.iter().any(|c| c == chord) {
chords.push(chord.clone());
}
}
None => out.push((action.clone(), vec![chord.clone()])),
}
}
out
}
/// One candidate plus what it renders and what Enter does with it. The matcher
/// never sees anything but `candidate`.
#[derive(Debug, Clone, PartialEq, Eq)]
struct BarEntry {
candidate: MatchCandidate,
target: BarTarget,
dead: bool,
chords: Vec<String>,
}
impl BarEntry {
fn candidate_kind(&self) -> CandidateKind {
match self.candidate {
MatchCandidate::Session(_) => CandidateKind::Session,
MatchCandidate::Command(_) => CandidateKind::Command,
MatchCandidate::Directory(_) => CandidateKind::Directory,
}
}
}
/// The bar's state while it is up: the query buffer, the candidates it was
/// opened over, the rows the current query produces, and the highlight.
#[derive(Debug)]
pub struct CommandBar {
/// Identity for routing an async directory load back to this bar.
id: u64,
input: TextInput,
/// Matcher input, in the empty-query order. Never reordered — the matcher's
/// last tie-break is what keeps that order meaningful.
candidates: Vec<MatchCandidate>,
/// What each candidate opens, parallel to `candidates`.
targets: Vec<BarTarget>,
/// Whether each candidate's session is dead, parallel to `candidates`.
dead: Vec<bool>,
home_dir: String,
/// Matcher input per kind, in the empty-query order. Never reordered —
/// the matcher's last tie-break is what keeps that order meaningful.
sessions: Vec<BarEntry>,
commands: Vec<BarEntry>,
directories: Vec<BarEntry>,
rows: Vec<BarRow>,
highlight: usize,
}
impl CommandBar {
/// Open the bar over the sessions the catalog has right now.
pub fn new(sessions: &[Session], home_dir: &str) -> Self {
/// Open the bar over the sessions the catalog has right now and the live
/// keymap for the current mode. Directories arrive later
/// ([`Self::set_directories`]) — they are a network round trip and the
/// keystroke must not wait on one.
pub fn new(sessions: &[Session], keymap: &[(String, Action)], home_dir: &str) -> Self {
let ordered = order_sessions_for_bar(sessions);
let candidates = ordered
let sessions = ordered
.iter()
.map(|s| MatchCandidate::Session(session_candidate(s, home_dir)))
.map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead,
chords: Vec::new(),
})
.collect();
let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect();
let dead = ordered
.iter()
.map(|s| s.status == SessionStatus::Dead)
let commands = command_entries(keymap)
.into_iter()
.map(|(action, chords)| BarEntry {
candidate: MatchCandidate::Command(CommandCandidate {
label: action.help_label(),
}),
target: BarTarget::Command(action),
dead: false,
chords,
})
.collect();
let mut bar = Self {
id: NEXT_BAR_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
input: TextInput::default(),
candidates,
targets,
dead,
home_dir: home_dir.to_string(),
sessions,
commands,
directories: Vec::new(),
rows: Vec::new(),
highlight: 0,
};
@@ -169,6 +300,34 @@ impl CommandBar {
bar
}
pub fn id(&self) -> u64 {
self.id
}
/// Hand the bar `~`'s candidates once the daemon has answered: recent dirs
/// (newest first) ahead of the project list, deduped by the directory a
/// spawn would land in, keeping the first mention of each.
pub fn set_directories(&mut self, paths: &[PathBuf]) {
let mut seen: Vec<&Path> = Vec::new();
let mut entries = Vec::new();
for path in paths {
if path.as_os_str().is_empty() || seen.contains(&path.as_path()) {
continue;
}
seen.push(path.as_path());
entries.push(BarEntry {
candidate: MatchCandidate::Directory(DirectoryCandidate {
path: abbreviate_dir(path, &self.home_dir),
}),
target: BarTarget::Directory(path.clone()),
dead: false,
chords: Vec::new(),
});
}
self.directories = entries;
self.recompute();
}
pub fn query(&self) -> &str {
self.input.value()
}
@@ -224,7 +383,7 @@ impl CommandBar {
match key {
InputKey::Esc => BarOutcome::Close,
InputKey::Enter => match self.selected() {
Some(row) => BarOutcome::Open(row.target),
Some(row) => BarOutcome::Open(row.target.clone()),
// Nothing to open: the bar stays up rather than closing on a
// key that did nothing.
None => BarOutcome::Stay,
@@ -257,31 +416,58 @@ impl CommandBar {
}
}
/// The sigil in force for the query as it stands.
pub fn sigil(&self) -> Option<BarSigil> {
parse_bar_query(self.input.value()).sigil
}
/// The candidates the query line asks for. A sigil narrows to one kind;
/// with no sigil the list is mixed — sessions and commands ranked together
/// — except on an empty query, where the bar is a session switcher.
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
match parsed.sigil {
Some(BarSigil::Command) => self.commands.iter().collect(),
Some(BarSigil::Directory) => self.directories.iter().collect(),
None if parsed.query.is_empty() => self.sessions.iter().collect(),
None => self.sessions.iter().chain(self.commands.iter()).collect(),
}
}
/// Re-rank for the current query. The highlight starts on the globally
/// best row every time the list changes: an old index would point at a
/// different result.
fn recompute(&mut self) {
let rows: Vec<BarRow> = {
let results = match_and_rank(self.input.value(), &self.candidates);
results
let parsed = parse_bar_query(self.input.value());
let entries = self.active_entries(parsed);
let candidates: Vec<MatchCandidate> =
entries.iter().map(|e| e.candidate.clone()).collect();
match_and_rank(parsed.query, &candidates)
.iter()
.map(|result| {
let index = self
.candidates
let index = candidates
.iter()
.position(|c| std::ptr::eq(c, result.candidate))
.expect("every result borrows one of this bar's candidates");
let MatchCandidate::Session(session) = result.candidate else {
unreachable!("sessions are the only kind the bar builds (#153)")
.expect("every result borrows one of this query's candidates");
let entry = entries[index];
let (name, meta) = match &entry.candidate {
MatchCandidate::Session(session) => {
(session.name.clone(), meta_text(session, result.field))
}
// A command's label and a directory's path are the row
// itself; neither has a second field to fall back to.
MatchCandidate::Command(command) => (command.label.clone(), None),
MatchCandidate::Directory(directory) => (directory.path.clone(), None),
};
BarRow {
kind: CandidateKind::Session,
target: self.targets[index],
name: session.name.clone(),
meta: meta_text(session, result.field),
kind: entry.candidate_kind(),
target: entry.target.clone(),
name,
meta,
field: result.field,
ranges: result.ranges.clone(),
dead: self.dead[index],
dead: entry.dead,
chords: entry.chords.clone(),
}
})
.collect()
@@ -297,7 +483,11 @@ impl CommandBar {
let area = centered(parent, 72, height);
f.render_widget(Clear, area);
let block = Block::default()
.title(" go to ")
.title(match self.sigil() {
Some(BarSigil::Command) => " run a command ",
Some(BarSigil::Directory) => " spawn in ",
None => " go to ",
})
.borders(Borders::ALL)
.style(pal.card.base())
.border_style(pal.border_style(true));
@@ -349,11 +539,16 @@ impl CommandBar {
// sits on so the scroll can keep it visible.
let mut lines: Vec<Line<'static>> = Vec::new();
let mut highlight_line = 0usize;
// While a sigil is in force there is one kind, so the header is
// dropped — it would name what the whole list already is.
let headers = self.sigil().is_none();
for group in self.groups() {
lines.push(Line::from(Span::styled(
group.title.to_string(),
pal.group_row_style(),
)));
if headers {
lines.push(Line::from(Span::styled(
group.title.to_string(),
pal.group_row_style(),
)));
}
for index in group.start..group.start + group.len {
if index == self.highlight {
highlight_line = lines.len();
@@ -384,6 +579,12 @@ impl CommandBar {
&row.ranges
};
spans.extend(highlighted_spans(&row.name, name_ranges, base, hit));
// The chord is teaching material, not a match target: it comes from
// the live keymap and is never fed to the matcher.
if !row.chords.is_empty() {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(row.chords.join(", "), surface.muted_style()));
}
if let Some(meta) = &row.meta {
let meta_style = surface.muted_style();
let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
@@ -485,6 +686,7 @@ mod tests {
rows.iter()
.map(|r| match r.target {
BarTarget::Session(id) => id,
ref other => panic!("expected a session row, got {other:?}"),
})
.collect()
}
@@ -509,7 +711,7 @@ mod tests {
let mut c = named(3, "charlie");
c.last_focused_at = Some(at(20));
let bar = CommandBar::new(&[a, b, c], "/home/me");
let bar = CommandBar::new(&[a, b, c], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)]
@@ -527,7 +729,7 @@ mod tests {
let mut newer = named(3, "newer");
newer.created_at = at(20);
let bar = CommandBar::new(&[older, newer, focused], "/home/me");
let bar = CommandBar::new(&[older, newer, focused], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)]
@@ -542,7 +744,7 @@ mod tests {
dead.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dead, live], "/home/me");
let bar = CommandBar::new(&[dead, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
@@ -552,7 +754,11 @@ mod tests {
#[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me");
let mut bar = CommandBar::new(
&[named(1, "web-search"), named(2, "websearch")],
&[],
"/home/me",
);
typed(&mut bar, "ws");
// F9: the `s` after `-` earns the boundary bonus.
assert_eq!(
@@ -565,7 +771,7 @@ mod tests {
#[test]
fn a_query_matching_nothing_leaves_no_rows() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
assert!(bar.rows().is_empty());
assert!(bar.selected().is_none());
@@ -577,7 +783,7 @@ mod tests {
fn a_directory_match_highlights_in_the_meta_text() {
let mut worker = named(1, "worker");
worker.cwd = PathBuf::from("/home/me/dev/api");
let mut bar = CommandBar::new(&[worker], "/home/me");
let mut bar = CommandBar::new(&[worker], &[], "/home/me");
typed(&mut bar, "api");
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory));
assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api"));
@@ -590,7 +796,7 @@ mod tests {
fn the_tool_field_is_a_candidate_field() {
let mut nameless = named(1, "nameless");
nameless.command = "claude --resume".into();
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me");
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], &[], "/home/me");
typed(&mut bar, "claude");
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]);
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool));
@@ -598,7 +804,7 @@ mod tests {
#[test]
fn results_group_under_kind_headers_with_the_best_row_first() {
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
let groups = bar.groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title, "Sessions");
@@ -612,6 +818,7 @@ mod tests {
fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() {
let mut bar = CommandBar::new(
&[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")],
&[],
"/home/me",
);
assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay);
@@ -640,7 +847,7 @@ mod tests {
#[test]
fn enter_opens_the_highlighted_session_and_escape_closes() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
bar.handle_key(&InputKey::Down);
assert_eq!(
bar.handle_key(&InputKey::Enter),
@@ -651,14 +858,14 @@ mod tests {
#[test]
fn enter_with_no_results_leaves_the_bar_up() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay);
}
#[test]
fn editing_the_query_re_ranks_and_resets_the_highlight() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
bar.handle_key(&InputKey::Down);
assert_eq!(bar.highlight(), 1);
typed(&mut bar, "alp");
@@ -676,7 +883,7 @@ mod tests {
let mut recent = named(1, "alpha");
recent.last_focused_at = Some(at(50));
let older = named(2, "alpine");
let mut bar = CommandBar::new(&[older, recent], "/home/me");
let mut bar = CommandBar::new(&[older, recent], &[], "/home/me");
typed(&mut bar, "alpi");
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]);
for _ in 0..4 {
@@ -689,6 +896,251 @@ mod tests {
);
}
fn keymap(pairs: &[(&str, Action)]) -> Vec<(String, Action)> {
pairs
.iter()
.map(|(chord, action)| (chord.to_string(), action.clone()))
.collect()
}
fn labels(rows: &[BarRow]) -> Vec<String> {
rows.iter().map(|r| r.name.clone()).collect()
}
// ---- sigil parsing ----
#[test]
fn a_leading_sigil_is_a_sigil_and_the_rest_is_the_query() {
assert_eq!(
parse_bar_query(">kill"),
BarQuery {
sigil: Some(BarSigil::Command),
query: "kill"
}
);
assert_eq!(
parse_bar_query("~dev"),
BarQuery {
sigil: Some(BarSigil::Directory),
query: "dev"
}
);
assert_eq!(
parse_bar_query("api"),
BarQuery {
sigil: None,
query: "api"
}
);
assert_eq!(
parse_bar_query(""),
BarQuery {
sigil: None,
query: ""
}
);
}
/// A directory query is where a second `~` is most likely to be typed, and
/// it must reach the matcher.
#[test]
fn a_sigil_anywhere_but_first_is_a_literal_query_character() {
assert_eq!(
parse_bar_query("~~/dev"),
BarQuery {
sigil: Some(BarSigil::Directory),
query: "~/dev"
}
);
assert_eq!(
parse_bar_query("a>b"),
BarQuery {
sigil: None,
query: "a>b"
}
);
}
// ---- `>` commands ----
/// The default map binds both `enter` and `tab` to `AttachHighlighted`;
/// listed naively that is two identical rows.
#[test]
fn command_rows_dedupe_by_action_and_keep_every_chord() {
let entries = command_entries(&keymap(&[
("enter", Action::AttachHighlighted),
("tab", Action::AttachHighlighted),
("q", Action::Quit),
]));
assert_eq!(
entries,
vec![
(
Action::AttachHighlighted,
vec!["enter".to_string(), "tab".to_string()]
),
(Action::Quit, vec!["q".to_string()]),
]
);
}
/// `AttachHighlighted` and `ToggleAttachHighlighted` share a `help_label`
/// because they are the same match arm and genuinely do the same thing.
/// Two rows a reader cannot tell apart is the bug; relabelling them would
/// describe behaviour that does not exist.
#[test]
fn actions_sharing_a_label_collapse_into_one_row() {
let entries = command_entries(&keymap(&[
("enter", Action::AttachHighlighted),
("o", Action::ToggleAttachHighlighted),
]));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].1, vec!["enter".to_string(), "o".to_string()]);
}
/// `Noop` exists to suppress a default binding, not as something to run.
#[test]
fn noop_bindings_are_not_commands() {
let entries = command_entries(&keymap(&[("x", Action::Noop), ("q", Action::Quit)]));
assert_eq!(entries, vec![(Action::Quit, vec!["q".to_string()])]);
}
#[test]
fn the_command_sigil_narrows_to_commands_matched_on_their_label() {
let map = keymap(&[
("K", Action::KillHighlighted),
("q", Action::Quit),
("r", Action::RenameHighlighted),
]);
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
typed(&mut bar, ">kill");
assert_eq!(
labels(bar.rows()),
vec!["Kill selected session".to_string()]
);
assert!(bar.rows().iter().all(|r| r.kind == CandidateKind::Command));
assert_eq!(bar.rows()[0].chords, vec!["K".to_string()]);
}
/// Matching the chord would make `>` results depend on a user's rebinds.
#[test]
fn a_chord_is_displayed_and_never_matched() {
let map = keymap(&[("zzz", Action::Quit)]);
let mut bar = CommandBar::new(&[], &map, "/home/me");
typed(&mut bar, ">zzz");
assert!(bar.rows().is_empty(), "{:?}", labels(bar.rows()));
bar.handle_key(&InputKey::Backspace);
bar.handle_key(&InputKey::Backspace);
bar.handle_key(&InputKey::Backspace);
assert_eq!(labels(bar.rows()), vec!["Quit Maestro".to_string()]);
assert_eq!(bar.rows()[0].chords, vec!["zzz".to_string()]);
}
#[test]
fn enter_on_a_command_row_dispatches_its_action() {
let map = keymap(&[("q", Action::Quit)]);
let mut bar = CommandBar::new(&[], &map, "/home/me");
typed(&mut bar, ">quit");
assert_eq!(
bar.handle_key(&InputKey::Enter),
BarOutcome::Open(BarTarget::Command(Action::Quit))
);
}
/// An empty `>` query lists every command in keymap order.
#[test]
fn an_empty_command_query_lists_the_whole_keymap() {
let map = keymap(&[("K", Action::KillHighlighted), ("q", Action::Quit)]);
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
typed(&mut bar, ">");
assert_eq!(
labels(bar.rows()),
vec![
"Kill selected session".to_string(),
"Quit Maestro".to_string()
]
);
}
// ---- `~` directories ----
#[test]
fn the_directory_sigil_narrows_to_directories_deduped_and_abbreviated() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.set_directories(&[
PathBuf::from("/home/me/dev/api"),
PathBuf::from("/home/me/dev/api"),
PathBuf::from("/srv/www"),
]);
typed(&mut bar, "~");
assert_eq!(
labels(bar.rows()),
vec!["~/dev/api".to_string(), "/srv/www".to_string()]
);
assert!(bar
.rows()
.iter()
.all(|r| r.kind == CandidateKind::Directory));
}
#[test]
fn enter_on_a_directory_row_spawns_there() {
let mut bar = CommandBar::new(&[], &[], "/home/me");
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
typed(&mut bar, "~api");
assert_eq!(
bar.handle_key(&InputKey::Enter),
BarOutcome::Open(BarTarget::Directory(PathBuf::from("/home/me/dev/api")))
);
}
// ---- the mixed list ----
#[test]
fn no_sigil_ranks_sessions_and_commands_together_under_kind_headers() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "killer")], &map, "/home/me");
typed(&mut bar, "kill");
let groups = bar.groups();
assert_eq!(
groups.iter().map(|g| g.title).collect::<Vec<_>>(),
vec!["Sessions", "Commands"]
);
assert_eq!(
labels(bar.rows()),
vec!["killer".to_string(), "Kill selected session".to_string()]
);
}
/// An empty bar is a session switcher: commands and directories need a
/// sigil or a query to appear.
#[test]
fn an_empty_query_lists_sessions_only() {
let map = keymap(&[("q", Action::Quit)]);
let bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
assert_eq!(labels(bar.rows()), vec!["alpha".to_string()]);
}
/// The sigil is one character of the query like any other: deleting it is
/// not a special key.
#[test]
fn backspacing_past_a_sigil_returns_to_the_mixed_list() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
typed(&mut bar, ">kill");
assert_eq!(bar.rows().len(), 1);
assert_eq!(bar.sigil(), Some(BarSigil::Command));
bar.handle_key(&InputKey::Home);
bar.handle_key(&InputKey::Right);
bar.handle_key(&InputKey::Backspace);
assert_eq!(bar.query(), "kill");
assert_eq!(bar.sigil(), None);
assert_eq!(
labels(bar.rows()),
vec!["kill-me".to_string(), "Kill selected session".to_string()]
);
}
fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
@@ -710,7 +1162,7 @@ mod tests {
#[test]
fn renders_the_query_the_headers_and_the_rows() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
typed(&mut bar, "alp");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("go to"), "{rendered}");
@@ -721,9 +1173,21 @@ mod tests {
assert!(rendered.contains("[esc] close"), "{rendered}");
}
/// While a sigil is in force there is one kind, so the kind header goes.
#[test]
fn a_sigil_drops_the_kind_header_and_renders_the_chord() {
let map = keymap(&[("K", Action::KillHighlighted)]);
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
typed(&mut bar, ">kill");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("Kill selected session"), "{rendered}");
assert!(rendered.contains(" K"), "{rendered}");
assert!(!rendered.contains("Commands"), "{rendered}");
}
#[test]
fn renders_a_no_results_state() {
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "zzz");
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("No matches"), "{rendered}");
@@ -732,7 +1196,7 @@ mod tests {
/// The matched characters are drawn bold, so the row shows why it matched.
#[test]
fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() {
let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me");
let mut bar = CommandBar::new(&[named(1, "web-search")], &[], "/home/me");
typed(&mut bar, "ws");
let backend = TestBackend::new(90, 20);
let mut terminal = Terminal::new(backend).unwrap();
+67
View File
@@ -49,6 +49,33 @@ pub fn resolve(
.into_iter()
.next()
});
defaults(project, presets, handle)
}
/// Resolve the defaults for a spawn pinned to one directory — the command
/// bar's `~` (#154), where the operator named the directory and nothing may
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
/// the project scan does not know leaves `project` `None`, which is an
/// unresolvable default, so the keystroke opens the pre-filled form instead of
/// spawning somewhere else.
pub fn resolve_at(
dir: &std::path::Path,
projects: &[Project],
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let project = projects.iter().find(|p| p.path == dir).cloned();
defaults(project, presets, handle)
}
/// The project's last command, or the first non-custom preset, wrapped up with
/// the rest of the defaults. Shared by both resolvers so the two entry points
/// can never settle on different commands.
fn defaults(
project: Option<Project>,
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
@@ -159,6 +186,46 @@ mod tests {
}
}
/// The command bar's `~` names the directory; nothing may override it.
#[test]
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
let projects = vec![
project("/repos/fresh", 0, Some("aider")),
project("/repos/pinned", 30, Some("zsh")),
];
let resolved = resolve_at(
std::path::Path::new("/repos/pinned"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(
resolved.project.as_ref().map(|p| p.path.clone()),
Some(PathBuf::from("/repos/pinned"))
);
assert_eq!(resolved.command.as_deref(), Some("zsh"));
let req = resolved.build_request().expect("every default resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
}
/// A directory the scan does not know resolves no project, so there is no
/// request to build — the caller opens the pre-filled form instead of
/// spawning somewhere else.
#[test]
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
let resolved = resolve_at(
std::path::Path::new("/elsewhere"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(resolved.project, None);
// The preset still resolves; the missing project is what blocks it.
assert_eq!(resolved.command.as_deref(), Some("claude"));
assert_eq!(resolved.build_request(), None);
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
+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();
},
};
+180 -1
View File
@@ -1,7 +1,12 @@
// @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,
@@ -15,6 +20,7 @@ import {
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionActionCandidates,
sessionCandidate,
SPAWN_DIRECTORY_EVENT,
} from "./commandBar.js";
@@ -542,3 +548,176 @@ describe("the focus round-trip over a pane (#152)", () => {
}
});
});
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",
]);
});
});
+152 -10
View File
@@ -2,11 +2,16 @@
// list, opened from the sidebar, closed with Escape.
//
// Three kinds live here: sessions and commands, ranked together by default,
// and directories, which need the `~` sigil. The action page and the
// terminal-focus binding land in #151-#152.
// 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 { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import { SESSION_SELECT_EVENT } from "./catalog.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,
@@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command };
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
/** 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
@@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery {
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 {
@@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement {
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 {
@@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
// 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") {
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])) {
@@ -374,6 +421,13 @@ export interface CommandBarOptions {
* `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 {
@@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
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));
@@ -450,6 +512,13 @@ 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[] = [];
@@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
* 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];
@@ -547,9 +617,27 @@ 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 {
const parsed = parseBarQuery(input.value);
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
// 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.
@@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let index = 0;
for (const group of barGroups(rows)) {
// While a sigil is in force there is one kind, so the header is dropped.
if (parsed.sigil === null) {
// 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");
@@ -593,10 +682,50 @@ 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 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();
switch (candidate.kind) {
case "session":
@@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
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();
+9
View File
@@ -1472,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);