Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
46 changed files with 623 additions and 7383 deletions
-1
View File
@@ -251,7 +251,6 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split();
// This client's claim on the shared window size. Registered up front and
// released on every exit below, so a socket that dies mid-session stops
// constraining the size for the clients still attached.
let viewport = manager.attach_viewport();
// NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam.
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(());
}
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await;
let _ = manager.set_viewport(id, viewport, cols, rows).await;
}
ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong)
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
}
}
manager.release_viewport(id, viewport).await;
if session_ended {
let _ = sink.send(Message::Close(None)).await;
}
+1
View File
@@ -30,6 +30,7 @@ pub mod spawn_history;
#[cfg(feature = "web")]
pub mod static_bundle;
pub mod tmux;
pub mod viewport;
pub mod worktree;
pub mod worktree_probe;
+83 -59
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. A subscriber that falls
@@ -166,6 +168,14 @@ struct Inner {
report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
/// Sizes reported by the clients attached to each session, and what tmux
/// was last told. See [`crate::viewport`] for why the window is not simply
/// set to whatever arrived most recently.
viewports: Mutex<Viewports>,
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
}
impl SessionManager {
@@ -206,6 +216,8 @@ impl SessionManager {
report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()),
events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
}),
}
}
@@ -285,50 +297,33 @@ impl SessionManager {
/// Subscribe to a session's PTY broadcast and return the replay buffer
/// (NAP-229). Callers send the buffer before pumping the live receiver
/// so freshly-attached clients see the last screenful immediately.
///
/// Opening an attach is focusing (#145): it stamps
/// [`Session::last_focused_at`], the same field `set_focused` stamps, so
/// a client that only ever attaches sorts identically to one that
/// reports focus explicitly.
pub async fn subscribe_pty_with_replay(
&self,
id: Uuid,
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
let tmux = self.inner.tmux.clone();
let (result, updated) = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id)?;
rec.session.last_focused_at = Some(Utc::now());
let updated = rec.session.clone();
// For a live session, tmux's own screen beats the byte tail: the
// tail starts mid-stream, so a full-screen program (an agent TUI)
// replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it
// keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in
// the stream.
let result = rec.bridge.subscribe_with_snapshot(|| {
if !alive {
return None;
let sessions = self.inner.sessions.lock().await;
let rec = sessions.get(&id)?;
// For a live session, tmux's own screen beats the byte tail: the tail
// starts mid-stream, so a full-screen program (an agent TUI) replayed
// from it renders as garbage until something forces a repaint. A dead
// session has no pane left to capture, so it keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, which
// parks the reader for the length of one tmux round-trip. That only
// happens on attach, and the alternative is a seam in the stream.
Some(rec.bridge.subscribe_with_snapshot(|| {
if !alive {
return None;
}
match tmux.capture_pane(id) {
Ok(snapshot) => snapshot,
Err(e) => {
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
None
}
match tmux.capture_pane(id) {
Ok(snapshot) => snapshot,
Err(e) => {
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
None
}
}
});
(result, updated)
};
let _ = self
.inner
.events
.send(CatalogEvent::SessionUpdated { session: updated });
Some(result)
}
}))
}
pub async fn has(&self, id: Uuid) -> bool {
@@ -465,7 +460,6 @@ impl SessionManager {
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -670,7 +664,6 @@ impl SessionManager {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -770,6 +763,52 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Register a client that is about to start reporting its display size,
/// and hand back the handle it reports under.
///
/// Cheap and infallible: the session it will report against is not
/// consulted until the first [`Self::set_viewport`].
pub fn attach_viewport(&self) -> ViewportId {
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
}
/// A client reported the size it can display.
///
/// The shared tmux window is resized only when the size arbitrated across
/// every attached client actually changes — see [`crate::viewport`]. Two
/// clients of different sizes used to resize the window past each other on
/// every report, which leaves a full-screen agent repainting into rows it
/// never clears and looking, to the operator, like a pane that has stopped
/// accepting input.
pub async fn set_viewport(
&self,
id: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Result<(), SessionError> {
let target = self
.inner
.viewports
.lock()
.await
.set(id, viewport, cols, rows);
match target {
Some((cols, rows)) => self.resize(id, cols, rows).await,
None => Ok(()),
}
}
/// Drop a detached client's claim on the window size, resizing to what the
/// clients still attached can display. Errors are the caller's to ignore:
/// a socket closing because its session died has nothing left to resize.
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
let target = self.inner.viewports.lock().await.release(id, viewport);
if let Some((cols, rows)) = target {
let _ = self.resize(id, cols, rows).await;
}
}
/// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command.
///
@@ -857,26 +896,11 @@ impl SessionManager {
/// Record whether a client's pane is currently showing `id`. Read by the
/// activity pass's next tick to decide whether an `AwaitingInput` glow
/// has been looked at long enough to clear (`apply_focus_suppression`).
/// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing
/// it does not, since the command bar's ordering cares about the last
/// time the operator arrived, not when they left. An unknown session is
/// [`SessionError::NotFound`].
/// An unknown session is [`SessionError::NotFound`].
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
let updated = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
rec.focused_since = focused.then(std::time::Instant::now);
if focused {
rec.session.last_focused_at = Some(Utc::now());
}
focused.then(|| rec.session.clone())
};
if let Some(session) = updated {
let _ = self
.inner
.events
.send(CatalogEvent::SessionUpdated { session });
}
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
rec.focused_since = focused.then(std::time::Instant::now);
Ok(())
}
}
+186
View File
@@ -0,0 +1,186 @@
//! What size the shared tmux window should be, given every client watching it.
//!
//! A tmux window has one size, but a session can have several clients attached
//! at once — two browser panes, a browser and the TUI. Each reports the size
//! *it* can display, and applying whichever arrived last makes the window
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
//! repaint at a new height, and the rows it drew at the old height are never
//! cleared: the operator ends up looking at a stale input box, types into a
//! box that is not where the screen says it is, and concludes the pane is
//! stuck.
//!
//! So the window is sized to the smallest attached client on each axis — the
//! same rule tmux uses for its own multiple clients, and the only size every
//! client can render without truncation. The size last handed to tmux is
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
use std::collections::HashMap;
use uuid::Uuid;
/// One attached client within a session. Allocated by
/// [`crate::session::SessionManager`] and opaque to everything else.
pub type ViewportId = u64;
/// Per-session client sizes, and what tmux was last told.
#[derive(Debug, Default)]
pub struct Viewports {
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
applied: HashMap<Uuid, (u16, u16)>,
}
impl Viewports {
pub fn new() -> Self {
Self::default()
}
/// Record what `viewport` can display. Returns the size tmux must be set
/// to, or `None` when the window already has it.
pub fn set(
&mut self,
session: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Option<(u16, u16)> {
self.clients
.entry(session)
.or_default()
.insert(viewport, (cols, rows));
self.settle(session)
}
/// Drop a client that has detached. Returns the size tmux must be set to
/// now that it no longer constrains the window.
///
/// The last client leaving settles nothing: the window keeps the size it
/// has rather than snapping to some remembered default no one is watching.
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
let clients = self.clients.get_mut(&session)?;
clients.remove(&viewport);
if clients.is_empty() {
self.clients.remove(&session);
self.applied.remove(&session);
return None;
}
self.settle(session)
}
/// The smallest size every attached client can render, but only when it
/// differs from what tmux was last told.
///
/// A zero on either axis is a client that cannot display anything yet — a
/// pane still laying out, a hidden tab — and must not drag the shared
/// window down with it, so it is held but left out of the arbitration.
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
let clients = self.clients.get(&session)?;
let mut cols = u16::MAX;
let mut rows = u16::MAX;
for &(c, r) in clients.values() {
if c == 0 || r == 0 {
continue;
}
cols = cols.min(c);
rows = rows.min(r);
}
if cols == u16::MAX || rows == u16::MAX {
return None;
}
let target = (cols, rows);
if self.applied.get(&session) == Some(&target) {
return None;
}
self.applied.insert(session, target);
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_report_settles_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
}
#[test]
fn a_report_that_changes_nothing_costs_no_resize() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.set(s, 1, 80, 24), None);
assert_eq!(v.set(s, 1, 80, 24), None);
}
/// The bug this module exists for: two clients of different sizes used to
/// resize the window past each other on every report.
#[test]
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
// Whatever order they re-report in, the answer stays the smaller one.
assert_eq!(v.set(s, 1, 53, 46), None);
assert_eq!(v.set(s, 2, 53, 20), None);
assert_eq!(v.set(s, 1, 53, 46), None);
}
#[test]
fn each_axis_is_arbitrated_on_its_own() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 200, 20);
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
}
#[test]
fn a_zero_sized_client_does_not_shrink_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(s, 2, 0, 0), None);
assert_eq!(v.set(s, 2, 0, 30), None);
}
#[test]
fn releasing_the_small_client_gives_the_window_back() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 46);
v.set(s, 2, 80, 20);
assert_eq!(v.release(s, 2), Some((80, 46)));
}
#[test]
fn releasing_the_last_client_settles_nothing() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 1), None);
// ...and the session is forgotten, so a fresh attach settles again.
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
}
#[test]
fn releasing_an_unknown_viewport_is_harmless() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.release(s, 7), None);
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 7), None);
}
#[test]
fn sessions_do_not_arbitrate_against_each_other() {
let mut v = Viewports::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
assert_eq!(v.set(a, 1, 80, 24), None);
}
}
-35
View File
@@ -383,41 +383,6 @@ async fn patch_unknown_session_returns_404() {
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// #145: `POST /.../focus` with `focused: true` over HTTP stamps
/// `last_focused_at`, observable on the next `GET /api/sessions`.
#[tokio::test]
async fn post_focus_stamps_last_focused_at() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
assert_eq!(session.last_focused_at, None);
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{}/focus", session.id)))
.json(&serde_json::json!({ "focused": true }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(sessions[0].last_focused_at.is_some());
}
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("naps-laptop").await;
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
Some(&"http://127.0.0.1:7070".to_string())
);
}
/// Two clients attached to one session used to resize the shared window past
/// each other on every report they made. Every flip repaints a full-screen
/// agent at a new height and orphans the rows it drew at the old one, which
/// reads to the operator as a pane that has stopped taking input.
#[tokio::test]
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
// redraw loop do. Neither report changes the arbitrated size.
for _ in 0..5 {
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
}
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(
resizes,
vec![(53, 46), (53, 20)],
"the window was resized more than the arbitrated size actually changed"
);
}
/// The smaller client leaving hands the window back to the one still attached.
#[tokio::test]
async fn releasing_a_client_resizes_to_what_is_left() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
fx.manager.release_viewport(s.id, tui).await;
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
}
@@ -1091,109 +1091,3 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
fx.manager.kill(s.id, false).await.expect("kill");
}
/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been
/// focused carries no `last_focused_at` at all, which is what lets a client
/// sort it after every session that has one (#145).
#[tokio::test]
async fn a_freshly_created_session_has_never_been_focused() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
assert_eq!(s.last_focused_at, None);
assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None);
}
/// #145: `POST /.../focus` reporting `focused: true` stamps
/// `last_focused_at` and announces it on the event stream; reporting
/// `focused: false` (losing focus) leaves the stamp alone, since the bar
/// orders by when the operator last arrived, not when they left.
#[tokio::test]
async fn set_focused_true_stamps_last_focused_at() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let mut events = fx.manager.events();
let before = chrono::Utc::now();
fx.manager.set_focused(s.id, true).await.expect("focus");
let updated = fx.manager.snapshot().await[0].clone();
let stamp = updated.last_focused_at.expect("stamped on focus");
assert!(stamp >= before);
let mut saw_update = false;
while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await {
if let Ok(CatalogEvent::SessionUpdated { session }) = event {
if session.id == s.id && session.last_focused_at == Some(stamp) {
saw_update = true;
break;
}
}
}
assert!(saw_update, "expected a SessionUpdated carrying the stamp");
fx.manager.set_focused(s.id, false).await.expect("unfocus");
let after_unfocus = fx.manager.snapshot().await[0].clone();
assert_eq!(
after_unfocus.last_focused_at,
Some(stamp),
"losing focus must not clear the last-focused stamp"
);
}
/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same
/// field `set_focused` does, so a client that only ever attaches sorts
/// identically to one that reports focus explicitly.
#[tokio::test]
async fn attaching_stamps_last_focused_at() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
assert_eq!(s.last_focused_at, None);
let before = chrono::Utc::now();
let (_replay, _rx) = fx
.manager
.subscribe_pty_with_replay(s.id)
.await
.expect("known session");
let updated = fx.manager.snapshot().await[0].clone();
let stamp = updated.last_focused_at.expect("attach stamps focus");
assert!(stamp >= before);
}
-1
View File
@@ -31,7 +31,6 @@ fn sample(id: Uuid) -> Session {
activity_since: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
last_focused_at: None,
}
}
-54
View File
@@ -202,41 +202,6 @@ 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();
@@ -283,7 +248,6 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -511,22 +475,4 @@ 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);
}
}
-31
View File
@@ -108,14 +108,6 @@ pub struct Session {
/// from daemons that predate the field stay forward-compatible.
#[serde(default)]
pub blocked_work: Option<InTheWay>,
/// Wall-clock time this session last became the pane a client was
/// showing (#145) — stamped by both `POST /.../focus` and opening
/// `/api/attach/:id`, since either one is the operator moving there.
/// `None` until the session has been focused at least once, which is
/// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`)
/// sorts a never-focused session after every session that has one.
#[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -855,7 +847,6 @@ mod tests {
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
}
}
@@ -905,28 +896,6 @@ mod tests {
);
}
#[test]
fn last_focused_at_defaults_to_none_on_legacy_payload() {
// Pre-#145 payload omits the field; `None` means never-focused.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.last_focused_at, None);
let mut focused = sample_session();
focused.last_focused_at = Some(
DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z")
.unwrap()
.with_timezone(&Utc),
);
assert_eq!(focused, round_trip(&focused));
}
#[test]
fn activity_since_defaults_to_epoch_on_legacy_payload() {
// A daemon predating #102 omits `activity_since`; it must deserialize
-1
View File
@@ -46,7 +46,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
-45
View File
@@ -53,17 +53,6 @@ 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,
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
/// byte to the focused pane instead of opening the command bar, giving
/// readline's kill-to-end-of-line back.
SendLiteralCtrlK,
/// Escape hatch (#158): the leader pressed twice sends one literal
/// leader byte to the focused pane and drops the pending chord, the way
/// tmux resolves a doubled prefix key.
SendLiteralLeader,
ToggleSidebar,
ShowHelp,
Quit,
@@ -105,9 +94,6 @@ 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::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
Action::SendLiteralLeader => "Send literal leader key to session".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
@@ -134,9 +120,6 @@ impl Action {
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -256,38 +239,10 @@ 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");
}
/// The escape hatch (#156) is an ordinary action like any other.
#[test]
fn send_literal_ctrl_k_round_trips() {
assert_eq!(
Action::parse("SendLiteralCtrlK").unwrap(),
Action::SendLiteralCtrlK
);
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
assert_eq!(
Action::SendLiteralCtrlK.help_label(),
"Send literal Ctrl-K to session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::OpenCommandBar,
Action::SendLiteralCtrlK,
Action::RenameHighlighted,
Action::Quit,
Action::OpenSpawnModal,
File diff suppressed because it is too large Load Diff
-1
View File
@@ -222,7 +222,6 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
File diff suppressed because it is too large Load Diff
+2 -82
View File
@@ -65,9 +65,6 @@ 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"),
@@ -78,17 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
/// Default Insert-mode bindings — deliberately empty. 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.
///
/// 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")];
const DEFAULT_INSERT: &[(&str, &str)] = &[];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
@@ -146,25 +139,6 @@ impl TuiConfig {
keymap
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
.expect("leader canonical form is valid by construction");
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
// the same leader spec so rebinding leader rebinds this chord too.
keymap
.set(
Mode::Insert,
&format!("{} ctrl-k", leader.canonical()),
"SendLiteralCtrlK",
)
.expect("leader canonical form is valid by construction");
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
// pressing it twice sends the leader byte itself. Built from the same
// leader spec, so rebinding leader rebinds this chord too.
keymap
.set(
Mode::Insert,
&format!("{} {}", leader.canonical(), leader.canonical()),
"SendLiteralLeader",
)
.expect("leader canonical form is valid by construction");
for (input, action) in &section.keymap_normal {
keymap.set(Mode::Normal, input, action).map_err(|source| {
@@ -297,60 +271,6 @@ mod tests {
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
}
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
/// leader, `ctrl-a ctrl-k` — not the command bar.
#[test]
fn default_leader_escapes_a_literal_ctrl_k() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
}
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
/// just the Insert escape — proving the chord is built from the
/// configured leader rather than a hardcoded `ctrl-a`.
#[test]
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
// The old leader's chord is gone along with its Insert escape.
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
}
/// The literal-leader escape hatch (#158) is chorded off the leader
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
#[test]
fn default_leader_escapes_a_literal_leader() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
Some(Action::SendLiteralLeader),
);
}
/// Rebinding the leader must rebind the literal-leader chord with it,
/// not just the Insert escape and the Ctrl-K chord — proving it too is
/// built from the configured leader rather than a hardcoded `ctrl-a`.
#[test]
fn rebinding_leader_moves_the_literal_leader_chord() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
Some(Action::SendLiteralLeader),
);
// The old leader's chord is gone along with the rest of its bindings.
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
}
#[test]
fn user_normal_override_replaces_default() {
let mut section = TuiSection::default();
-68
View File
@@ -228,10 +228,6 @@ pub struct DraftPane {
/// respawns there without touching the picker. Consumed by the first
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
pub seed_cwd: Option<PathBuf>,
/// Command to preselect once the preset list lands — "spawn similar"'s
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
/// `None` once applied or when nothing seeded it.
pub seed_command: Option<String>,
/// The repository picked in step one ([`group_key`] of its main box),
/// `None` until a box is picked. Looked up fresh in `groups` rather than
/// cached, so a project-list refresh can't leave it stale.
@@ -295,7 +291,6 @@ impl Default for DraftPane {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
seed_command: None,
custom_command: crate::text_input::TextInput::default(),
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
@@ -347,27 +342,6 @@ impl DraftPane {
.iter()
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
}
/// Land a seeded command on the tile that runs it: its own preset when the
/// daemon offers one, otherwise the custom escape hatch with the command
/// typed in.
fn apply_seed_command(&mut self) {
let Some(command) = self.seed_command.take() else {
return;
};
match self.presets.iter().position(|p| p.command == command) {
Some(index) => self.selected_preset = index,
None => {
self.selected_preset = self
.presets
.iter()
.position(|p| p.command.is_empty())
.unwrap_or(self.selected_preset);
self.custom_command = crate::text_input::TextInput::with_value(command);
}
}
}
/// Replace the project snapshot: sort fresh-first (#54), group by
@@ -1714,48 +1688,6 @@ mod tests {
assert_eq!(draft.presets.last().unwrap().id, "custom");
}
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
/// the tile that runs it when the daemon offers one.
#[test]
fn a_seeded_command_selects_its_own_preset() {
let mut draft = DraftPane {
seed_command: Some("aider".into()),
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "aider");
assert!(!draft.is_custom());
assert_eq!(draft.seed_command, None, "consumed by the first load");
}
/// A command no preset runs falls back to the custom tile with the command
/// typed in, so the clone still spawns what it cloned.
#[test]
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
let mut draft = DraftPane {
seed_command: Some("bash -l".into()),
..DraftPane::default()
};
draft.set_presets(vec![SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
}
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = DraftPane {
-67
View File
@@ -49,33 +49,6 @@ 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())
@@ -186,46 +159,6 @@ 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![
+3 -69
View File
@@ -387,9 +387,6 @@ pub struct ChordState {
held: Vec<String>,
last_press: Option<Instant>,
timeout: Duration,
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
/// to be replayed as a fresh keystroke by the caller.
replay: Option<InputKey>,
}
impl ChordState {
@@ -398,7 +395,6 @@ impl ChordState {
held: Vec::new(),
last_press: None,
timeout,
replay: None,
}
}
@@ -408,13 +404,6 @@ impl ChordState {
/// binding; wait for the next key.
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
/// up in the keymap. The held buffer is cleared.
///
/// A held prefix that is itself directly bound (e.g. a leader key bound
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
/// to that binding the moment the next key fails to extend it, rather
/// than swallowing the held key and losing its meaning — the new key
/// never belonged to the chord and is handed back via [`Self::take_replay`]
/// so the caller can process it as its own keystroke.
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
if let Some(t) = self.last_press {
if t.elapsed() > self.timeout {
@@ -439,31 +428,11 @@ impl ChordState {
if is_prefix {
self.held.push(key_s);
return ChordResult::Pending;
}
if known_bindings.contains(&candidate) {
ChordResult::Pending
} else {
self.held.clear();
return ChordResult::Resolved(candidate);
ChordResult::Resolved(candidate)
}
if !self.held.is_empty() {
let held_str = self.held.join(" ");
self.held.clear();
if known_bindings.contains(&held_str) {
self.replay = Some(key.clone());
return ChordResult::Resolved(held_str);
}
}
ChordResult::Resolved(candidate)
}
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
/// call, if any. The caller feeds it through again as a brand new
/// keystroke.
pub fn take_replay(&mut self) -> Option<InputKey> {
self.replay.take()
}
}
@@ -764,41 +733,6 @@ mod tests {
assert_eq!(r, ChordResult::Resolved("g x".into()));
}
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
/// doesn't complete the chord — the held key's meaning isn't lost, and
/// the new key comes back via `take_replay` for the caller to process on
/// its own (#156).
#[test]
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
assert_eq!(r, ChordResult::Pending);
let r = chord.feed(&InputKey::char('j'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
}
/// The full chord still resolves normally when the next key completes
/// it — no replay, no early fire of the shorter binding.
#[test]
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
chord.feed(&InputKey::ctrl_char('a'), &known);
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
assert_eq!(chord.take_replay(), None);
}
#[test]
fn chord_state_lone_key_resolves_immediately() {
let mut chord = ChordState::new(Duration::from_secs(10));
-2
View File
@@ -6,7 +6,6 @@ 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;
@@ -14,7 +13,6 @@ 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
@@ -1,750 +0,0 @@
//! 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],
),
],
);
}
}
-15
View File
@@ -86,9 +86,6 @@ 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,
@@ -201,14 +198,6 @@ 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);
@@ -439,7 +428,6 @@ 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);
@@ -536,7 +524,6 @@ mod tests {
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
@@ -591,7 +578,6 @@ 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);
@@ -633,7 +619,6 @@ 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);
-2
View File
@@ -499,7 +499,6 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -1265,7 +1264,6 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
};
let sb = Sidebar {
sessions: vec![s],
-4
View File
@@ -52,10 +52,6 @@ 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),
}
+2 -9
View File
@@ -45,15 +45,8 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
`preventDefault`s it, since selecting all of a terminal pane does nothing
useful.
The leader itself needs the same kind of way back: holding it consumes every
press, on both clients, even before anything is bound behind it. Pressing it
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
focused pane and clears the pending chord, the way tmux resolves a doubled
prefix key. This chord is also built from the configured leader, so a
rebound leader carries its own escape hatch with it.
Only Ctrl-K and the leader itself get an escape hatch. No other control
character is intercepted, so none of them needs one.
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
none of them needs one.
### Focus round-trip
+7 -22
View File
@@ -5,7 +5,6 @@ import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -14,15 +13,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
@@ -50,10 +40,6 @@ 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;
}
@@ -70,6 +56,8 @@ export interface AttachOptions {
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
@@ -222,12 +210,12 @@ export function createAttachConnection(
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
// shell/app. `false` tells xterm to skip its own handling of this event.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
if (e.type !== "keydown" || e.key !== "Escape") return true;
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
return false;
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
@@ -510,9 +498,6 @@ 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
@@ -1,131 +0,0 @@
// @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");
});
});
+36 -111
View File
@@ -2,12 +2,10 @@ import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js";
import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateCwd,
abbreviateFolderName,
blockerStats,
customSessionName,
@@ -17,7 +15,6 @@ import {
sessionBlockers,
isProvisionalName,
sessionName,
sessionTreePath,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
@@ -39,15 +36,6 @@ export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
/** Ask the shell to open the command bar with an empty query. Dispatched by
* the sidebar's search field, which is the touch entry point to the bar
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
* Handled in main.ts, which owns the session snapshot the bar needs. */
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
export function requestOpenCommandBar(): void {
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Tab in the sidebar list does. Handled in main.ts, which owns the
* connection pool. */
@@ -61,17 +49,6 @@ 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
@@ -370,12 +347,16 @@ function beginRename(row: HTMLElement, s: Session): void {
input.select();
}
// The tree has one empty state: no sessions at all. The old "No matches" one
// belonged to the inline substring filter, which the command bar replaced
// (#157) — the bar's own no-results state took over with it.
function renderEmpty(onSpawn: () => void): HTMLElement {
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌕</div>
<div class="empty-title">No matches</div>
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
return el;
}
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
@@ -446,16 +427,12 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// renders with absolute paths until it lands.
let homeDir: string | undefined;
// The field never holds a query: it is the touch entry point to the command
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
// on-screen keyboard over a field nothing is typed into.
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
<input
type="search"
class="search"
readonly
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
@@ -564,23 +541,11 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
// Touch entry (#157): the field is a button that looks like a field. It
// never holds a query — focusing or tapping it hands the keyboard straight
// to the bar's own input, which is also why it blurs first: leaving the
// focus here would make the bar impossible to close without reopening it.
const search = searchHeader.querySelector(".search") as HTMLInputElement;
const openBar = () => {
search.value = "";
search.blur();
requestOpenCommandBar();
};
search.addEventListener("focus", openBar);
// A tap can land without a focus event (iOS Safari on a blurred field), so
// the pointer path opens the bar too; the blur above keeps the two from
// stacking two overlays.
search.addEventListener("pointerdown", (e) => {
e.preventDefault();
openBar();
let filter = "";
search.addEventListener("input", () => {
filter = search.value;
refresh();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
@@ -704,30 +669,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// The session list's half of the command registry (#148). One set per
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
// labels and chords still come from the one registry.
const listKeys = new CommandSet(
defineCommands({
"list.next": () => moveNav(1),
"list.prev": () => moveNav(-1),
"list.activate": () => activateNav(),
"list.toggleFolder": () => toggleHighlightedFolder(),
"list.rename": () => {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
},
"list.kill": () => {
const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session));
},
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}),
);
// The inline rename field lives inside a row and owns its own keys; the set's
// plain-key rule keeps this walk out of it.
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
treeHost.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
// The inline rename field lives inside a row and owns its own keys.
if (isTypingTarget(e.target)) return;
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else if (e.key === "c") toggleHighlightedFolder();
else if (e.key === "x") {
const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session));
} else if (e.key === "r") {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
} else return;
e.preventDefault();
});
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
@@ -737,8 +696,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir);
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
const tree = buildTree(sessions, homeDir, filter);
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
@@ -749,7 +708,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
treeHost.appendChild(
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
);
return;
}
if (tree.kind === "folder") {
@@ -777,40 +738,6 @@ 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.
@@ -860,8 +787,6 @@ 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();
},
};
-723
View File
@@ -1,723 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
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, type Command } from "./commands.js";
import type { Session } from "./types.js";
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
afterEach(() => {
closeCommandBar();
document.body.replaceChildren();
});
function press(key: string, init: KeyboardEventInit = {}): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function type(value: string): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
}
describe("empty-query ordering", () => {
it("sorts most recently focused first", () => {
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
});
it("sorts a never-focused session after every focused one, newest created first", () => {
const focused = session("focused", {
last_focused_at: "2020-01-01T00:00:00Z",
created_at: "2020-01-01T00:00:00Z",
});
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
"focused",
"new-fresh",
"old-fresh",
]);
});
it("sorts dead sessions after live ones regardless of recency", () => {
const deadRecent = session("dead", {
status: "Dead",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
const liveNever = session("never");
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
"live",
"never",
"dead",
]);
});
it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
});
});
describe("session candidates", () => {
it("carries the candidate table's fields, with the directory abbreviated", () => {
const s = session("api", {
metadata: { name: "api" },
command: "claude --resume",
current_cwd: "/home/me/dev/api",
});
expect(sessionCandidate(s, "/home/me")).toEqual({
kind: "session",
session: s,
fields: {
name: "api",
directory: "~/dev/api",
command: "claude --resume",
tool: "claude",
},
});
});
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
const s = session("edit", {
metadata: {},
command: "hx src/",
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
});
const candidate = sessionCandidate(s, "/home/me");
expect(candidate.fields.tool).toBeUndefined();
expect(candidate.fields.directory).toBe("~/dev/api");
// No `metadata.name`: the sidebar's fallback is the command line.
expect(candidate.fields.name).toBe("hx src/");
});
it("builds candidates in the empty-query order", () => {
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
});
});
describe("rows and grouping", () => {
it("an empty query keeps every session in input order", () => {
const candidates = sessionBarCandidates([session("a"), session("b")]);
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
});
it("groups by kind without reordering the ranking", () => {
const candidates = sessionBarCandidates([
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
]);
const rows = barRows("api", candidates);
const groups = barGroups(rows);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("Sessions");
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
rows.map((r) => r.candidate.session.id),
);
// F6: the name beats the directory, and the directory match highlights
// inside the directory.
expect(groups[0].results[0].candidate.session.id).toBe("api");
expect(groups[0].results[1].field).toBe("directory");
});
});
describe("the overlay", () => {
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",
}),
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", commands: [], onOpenSession: () => {} },
);
}
it("is a registry command, so the help overlay lists it", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
expect(spec?.keys).toEqual(["ctrl-k"]);
expect(spec?.scope).toBe("global");
});
it("lists sessions by recency under a kind header on an empty query", () => {
open();
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
]);
expect(rowNames()).toEqual(["api", "worker", "notes"]);
expect(activeName()).toBe("api");
});
it("opening twice leaves one overlay", () => {
open();
open();
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
expect(isCommandBarOpen()).toBe(true);
});
it("ranks a typed query through the matcher and highlights the matched range", () => {
open();
type("wor");
expect(rowNames()).toEqual(["worker"]);
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
expect(marks).toEqual(["wor"]);
});
it("highlights inside the directory when that is the field that matched", () => {
open();
type("notes");
const row = document.querySelector(".cmdbar-row") as HTMLElement;
expect(row.dataset.sessionId).toBe("notes");
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
).toBe("dev/wor");
});
it("renders a no-results state rather than an empty list", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
open();
press("ArrowUp");
expect(activeName()).toBe("api");
press("ArrowDown");
expect(activeName()).toBe("worker");
press("n", { ctrlKey: true });
expect(activeName()).toBe("notes");
press("ArrowDown");
expect(activeName()).toBe("notes");
press("p", { ctrlKey: true });
expect(activeName()).toBe("worker");
});
it("puts the highlight back on the best row whenever the query changes", () => {
open();
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
});
it("opens the highlighted session through the shared open path on Enter", () => {
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", commands: [] });
press("ArrowDown");
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
expect(seen).toEqual(["worker"]);
expect(isCommandBarOpen()).toBe(false);
});
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
expect(isCommandBarOpen()).toBe(true);
});
it("closes on Escape and on a press outside the panel", () => {
open();
press("Escape");
expect(isCommandBarOpen()).toBe(false);
open();
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(isCommandBarOpen()).toBe(true);
});
it("returns the keyboard to whatever held it when it opened", () => {
const before = document.createElement("button");
document.body.appendChild(before);
before.focus();
open();
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
press("Escape");
expect(document.activeElement).toBe(before);
});
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
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",
]);
});
});
-809
View File
@@ -1,809 +0,0 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// 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. `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 {
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 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 };
/** 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<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
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 {
const stamp = s.last_focused_at;
if (typeof stamp !== "string" || stamp === "") return null;
const ms = Date.parse(stamp);
return Number.isNaN(ms) ? null : ms;
}
function createdAt(s: Session): number {
const ms = Date.parse(s.created_at ?? "");
return Number.isNaN(ms) ? 0 : ms;
}
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
* focused first; a session that has never been focused sorts after every
* session that has, most recently created first among those; and a dead
* session sorts after every live one regardless of recency, because a session
* you cannot attach to is not where you just were.
*
* This is also the input order for a non-empty query, which is what the
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
const focusB = focusedAt(b);
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
return createdAt(b) - createdAt(a);
});
}
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
* table names, with the directory abbreviated the way the sidebar shows it. */
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
const treePath = sessionTreePath(s);
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
const tool = splitCommand(s.command).tool ?? undefined;
return {
kind: "session",
session: s,
fields: {
name: sessionName(s),
...(directory ? { directory } : {}),
...(s.command ? { command: s.command } : {}),
...(tool ? { tool } : {}),
},
};
}
/** Every session as a candidate, in the empty-query order. */
export function sessionBarCandidates(
sessions: readonly Session[],
homeDir?: string,
): SessionBarCandidate[] {
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<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);
if (!group) {
group = { kind, title: KIND_TITLES[kind], results: [] };
groups.push(group);
}
group.results.push(result);
}
return groups;
}
/** 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<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
// ---- rendering ----
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
* matcher as `[start, end)` character pairs over the field that won. */
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
const frag = document.createDocumentFragment();
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) frag.append(text.slice(cursor, start));
const mark = document.createElement("mark");
mark.className = "cmdbar-hit";
mark.textContent = text.slice(start, end);
frag.append(mark);
cursor = end;
}
if (cursor < text.length) frag.append(text.slice(cursor));
return frag;
}
/** 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":
return fields.directory;
case "command":
return fields.command;
case "tool":
return fields.tool;
default:
return fields.directory;
}
}
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.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 = 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");
el.className = "cmdbar-row-meta";
const highlighted = result.field !== undefined && result.field !== "name";
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
row.appendChild(el);
}
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 {
/** Sessions to build candidates from, as the store has them right now. */
sessions: readonly Session[];
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
homeDir?: string;
/** Open the highlighted session. Defaults to the shared open path — the
* 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 {
el: HTMLElement;
dispose: () => void;
}
let current: OpenBar | null = null;
export function isCommandBarOpen(): boolean {
return current !== null;
}
export function closeCommandBar(): void {
current?.dispose();
}
/** Mount the bar. Opening while one is up replaces it, so a stray second call
* can never stack two overlays. */
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
closeCommandBar();
// Closing returns the keyboard to whatever held it when the bar opened: a
// round trip through the bar never silently moves focus.
const previouslyFocused = document.activeElement as HTMLElement | null;
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 sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
opts.sessions,
opts.homeDir,
);
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
opts.commands ?? publishedCommands(),
);
const el = document.createElement("div");
el.className = "cmdbar-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Command bar");
const panel = document.createElement("div");
panel.className = "cmdbar-panel";
const input = document.createElement("input");
input.type = "text";
input.className = "cmdbar-input";
input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", "cmdbar-results");
input.setAttribute("aria-label", "Search sessions, commands and directories");
input.setAttribute("autocomplete", "off");
input.spellcheck = false;
input.placeholder = PLACEHOLDERS.mixed;
const results = document.createElement("div");
results.className = "cmdbar-results";
results.id = "cmdbar-results";
results.setAttribute("role", "listbox");
panel.append(input, results);
el.appendChild(panel);
document.body.appendChild(el);
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;
row.classList.toggle("is-active", active);
row.setAttribute("aria-selected", active ? "true" : "false");
if (active) {
row.scrollIntoView({ block: "nearest" });
input.setAttribute("aria-activedescendant", row.id);
}
}
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 {
// 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;
results.replaceChildren();
if (rows.length === 0) {
const empty = document.createElement("div");
empty.className = "cmdbar-empty";
empty.textContent = "No matches";
results.appendChild(empty);
paintHighlight();
return;
}
let index = 0;
for (const group of barGroups(rows)) {
// 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);
row.addEventListener("click", () => activate(at));
results.appendChild(row);
index += 1;
}
}
paintHighlight();
}
/** The highlight does not wrap: it stops at the first and last row. */
function move(delta: number): void {
if (rows.length === 0) return;
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
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":
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();
// 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();
activate(highlighted);
return;
case "ArrowDown":
e.preventDefault();
move(1);
return;
case "ArrowUp":
e.preventDefault();
move(-1);
return;
case "n":
if (e.ctrlKey) {
e.preventDefault();
move(1);
}
return;
case "p":
if (e.ctrlKey) {
e.preventDefault();
move(-1);
}
return;
}
}
function close(): void {
if (current?.el !== el) return;
current = null;
el.remove();
// Restoring focus is what makes the bar a view over the app rather than a
// place in it; a detached node cannot take it.
if (previouslyFocused?.isConnected) previouslyFocused.focus();
}
input.addEventListener("keydown", onKeydown);
input.addEventListener("input", render);
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("mousedown", (ev) => {
if (!panel.contains(ev.target as Node)) close();
});
current = { el, dispose: close };
render();
input.focus();
return el;
}
-370
View File
@@ -1,370 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import {
COMMAND_SPECS,
CommandSet,
DEFAULT_LEADER,
canonicalizeKey,
controlByte,
defineCommands,
isBarTypingTarget,
isTerminalTextarea,
isTypingTarget,
keyCaps,
publishedCommands,
type Command,
} from "./commands.js";
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
}
function el(html: string): HTMLElement {
const d = document.createElement("div");
d.innerHTML = html;
return d.firstElementChild as HTMLElement;
}
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
const e = key(k, init);
Object.defineProperty(e, "target", { value: target });
return set.handle(e);
}
describe("command specs", () => {
it("has a unique id and at least one chord per command", () => {
const ids = COMMAND_SPECS.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
for (const spec of COMMAND_SPECS) {
expect(spec.keys.length).toBeGreaterThan(0);
expect(spec.label.length).toBeGreaterThan(0);
}
});
it("binds each chord once within a scope", () => {
const seen = new Set<string>();
for (const spec of COMMAND_SPECS) {
for (const k of spec.keys) {
const slot = `${spec.scope}:${k}`;
expect(seen.has(slot)).toBe(false);
seen.add(slot);
}
}
});
it("rejects a handler for an id that isn't in the registry", () => {
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
});
it("carries every spec field onto the defined command", () => {
const [cmd] = defineCommands({ "spawn.open": () => {} });
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
});
});
describe("canonicalizeKey", () => {
it("spells keys the way the TUI's InputKey::canonical does", () => {
expect(canonicalizeKey(key("n"))).toBe("n");
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
expect(canonicalizeKey(key("Escape"))).toBe("esc");
expect(canonicalizeKey(key("Enter"))).toBe("enter");
expect(canonicalizeKey(key("Tab"))).toBe("tab");
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
});
it("folds a modified letter to lower case with an explicit shift-", () => {
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
});
it("has no canonical form for a modifier pressed alone", () => {
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
});
});
describe("keyCaps", () => {
it("renders one cap per modifier and key", () => {
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
expect(keyCaps("enter")).toEqual(["Enter"]);
expect(keyCaps("j")).toEqual(["j"]);
expect(keyCaps("N")).toEqual(["N"]);
expect(keyCaps("?")).toEqual(["?"]);
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
});
});
describe("CommandSet", () => {
it("runs the command bound to a chord, and swallows the browser default", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "spawn.open": run }));
const e = key("n");
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
expect(e.defaultPrevented).toBe(true);
});
it("leaves an unbound key alone", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
const e = key("q");
expect(set.handle(e)).toBe(false);
expect(e.defaultPrevented).toBe(false);
});
it("treats a modified key as a different binding", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "spawn.open": run }));
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
expect(run).not.toHaveBeenCalled();
});
it("runs every alias of a command", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "list.next": run }));
set.handle(key("j"));
set.handle(key("ArrowDown"));
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
const plain = vi.fn();
const chorded = vi.fn();
const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
);
const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled();
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1);
});
it("keeps pane commands live inside the terminal's textarea", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
});
});
describe("the leader", () => {
it("defaults to the TUI's ctrl-a", () => {
expect(DEFAULT_LEADER).toBe("ctrl-a");
});
it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER,
});
const e = key("a", { ctrlKey: true });
expect(set.handle(e)).toBe(true);
expect(e.defaultPrevented).toBe(true);
});
it("swallows the key after the leader rather than running its plain binding", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
leader: DEFAULT_LEADER,
});
set.handle(key("a", { ctrlKey: true }));
expect(set.handle(key("n"))).toBe(true);
expect(run).not.toHaveBeenCalled();
// The next key is an ordinary one again.
set.handle(key("n"));
expect(run).toHaveBeenCalledTimes(1);
});
it("forgets a held leader once the chord timeout passes", () => {
const run = vi.fn();
let now = 0;
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
leader: DEFAULT_LEADER,
chordTimeoutMs: 1000,
now: () => now,
});
set.handle(key("a", { ctrlKey: true }));
now = 1500;
set.handle(key("n"));
expect(run).toHaveBeenCalledTimes(1);
});
it("leaves select-all alone in a real form field", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER,
});
const input = el("<input>");
const e = key("a", { ctrlKey: true });
Object.defineProperty(e, "target", { value: input });
expect(set.handle(e)).toBe(false);
expect(e.defaultPrevented).toBe(false);
});
});
describe("publishing", () => {
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
new CommandSet(defineCommands({ "list.kill": () => {} }));
new CommandSet(defineCommands({ "list.kill": () => {} }));
const ids = publishedCommands().map((c) => c.id);
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
});
it("keeps published commands in registry order", () => {
const order = COMMAND_SPECS.map((s) => s.id);
const ids = publishedCommands().map((c) => c.id);
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
});
});
describe("isTypingTarget", () => {
it("is true for form fields and editable nodes, false otherwise", () => {
expect(isTypingTarget(el("<input>"))).toBe(true);
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
expect(isTypingTarget(el("<select></select>"))).toBe(true);
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
expect(isTypingTarget(el("<button></button>"))).toBe(false);
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`]);
});
});
describe("leader pressed twice (#158)", () => {
it("sends one literal leader byte and clears the pending chord", () => {
const literal = vi.fn();
const run = vi.fn();
const set = new CommandSet(
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
{ leader: DEFAULT_LEADER },
);
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The chord is cleared, not still held: the next key is ordinary again.
expect(set.handle(key("n"))).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
});
it("binds the escape hatch behind the default leader", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
});
// Not just the default: the chord is resolved from whatever leader this
// set was constructed with, so a rebound leader carries the escape hatch
// with it — the same proof #156 wrote for the TUI. The web has no config
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
it("follows a rebound leader, not just the default", () => {
const literal = vi.fn();
const rebound: Command = {
id: "pane.literalLeader",
label: "Send a literal leader key to the pane",
group: "Pane",
scope: "global",
keys: ["ctrl-b ctrl-b"],
run: literal,
};
const set = new CommandSet([rebound], { leader: "ctrl-b" });
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The old leader no longer does anything special.
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
});
});
describe("controlByte", () => {
it("computes the raw control byte from a plain ctrl chord", () => {
expect(controlByte("ctrl-a")).toBe("\x01");
expect(controlByte("ctrl-k")).toBe("\x0b");
});
it("rejects anything that isn't a single plain ctrl chord", () => {
expect(() => controlByte("ctrl-shift-a")).toThrow();
expect(() => controlByte("a")).toThrow();
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
});
});
-424
View File
@@ -1,424 +0,0 @@
// The web client's command registry (#148). One entry per command — a stable
// id, a plain-language label, the key chord it answers to, and a `run()` — so
// there is exactly one list of what the client can do. The `?` overlay renders
// from it (`help.ts`), and every keybinding is dispatched through it; a command
// added here is bound and documented in the same edit.
//
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
// docs/COMMAND-BAR.md describe one key model for both clients.
/** Where a command's keys are listened for. `global` is the window, `list` the
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
* dialog while it is up. */
export type CommandScope = "global" | "list" | "pane" | "overlay";
export interface CommandSpec {
/** Stable identifier. Never rendered; safe to bind config and tests to. */
id: string;
/** Plain-language label — what the `?` overlay and (later) the command bar
* show. */
label: string;
/** Help-overlay section. Groups render in the order they first appear here. */
group: string;
/** Canonical chords that run it. The first is the one shown to the operator;
* the rest are aliases (`j` and `down`). */
keys: string[];
scope: CommandScope;
}
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[] = [
{
id: "spawn.open",
label: "Open the spawn form",
group: "Sessions",
keys: ["n"],
scope: "global",
},
{
id: "spawn.fastForward",
label: "Spawn a session with every default resolved",
group: "Sessions",
keys: ["N"],
scope: "global",
},
{
id: "bar.open",
label: "Open the command bar",
group: "Sessions",
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: "pane.literalLeader",
label: "Send a literal leader key to the pane",
group: "Pane",
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
scope: "global",
},
{
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
group: "Session list",
keys: ["ctrl-shift-h"],
scope: "global",
},
{
id: "list.next",
label: "Highlight the next session",
group: "Session list",
keys: ["j", "down"],
scope: "list",
},
{
id: "list.prev",
label: "Highlight the previous session",
group: "Session list",
keys: ["k", "up"],
scope: "list",
},
{
id: "list.activate",
label: "Open the highlighted session, or expand a folder",
group: "Session list",
keys: ["enter"],
scope: "list",
},
{
id: "list.toggleFolder",
label: "Collapse or expand the highlighted folder",
group: "Session list",
keys: ["c"],
scope: "list",
},
{
id: "list.rename",
label: "Rename the highlighted session",
group: "Session list",
keys: ["r"],
scope: "list",
},
{
id: "list.kill",
label: "Kill the highlighted session",
group: "Session list",
keys: ["x"],
scope: "list",
},
{
id: "list.focusPane",
label: "Send the keyboard to the pane",
group: "Session list",
keys: ["tab"],
scope: "list",
},
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{
id: "help.toggle",
label: "Show or hide this help",
group: "Help",
keys: ["?"],
scope: "global",
},
{
id: "help.close",
label: "Close the help overlay or an open menu",
group: "Help",
keys: ["esc"],
scope: "overlay",
},
{
id: "debug.toggleHud",
label: "Toggle the debug HUD",
group: "Debug",
keys: ["ctrl-shift-d"],
scope: "global",
},
];
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
* test failure rather than a key that silently does nothing. */
export function defineCommands(handlers: Record<string, () => void>): Command[] {
return Object.entries(handlers).map(([id, run]) => {
const spec = SPEC_BY_ID.get(id);
if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run };
});
}
// ---- what the help overlay renders ----
const published = new Map<string, Command>();
/** Record commands as bound. Re-registering an id replaces it, so a second
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
export function publishCommands(cmds: Command[]): void {
for (const c of cmds) published.set(c.id, c);
}
/** Every bound command, in `COMMAND_SPECS` order. */
export function publishedCommands(): Command[] {
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
}
// ---- key canonicalisation ----
const NAMED: Record<string, string> = {
Escape: "esc",
Enter: "enter",
Tab: "tab",
Backspace: "backspace",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
Home: "home",
End: "end",
PageUp: "pageup",
PageDown: "pagedown",
Delete: "delete",
" ": "space",
};
/** The canonical string for one keydown. Mirrors the TUI's
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
* only written when another modifier is present and the char was folded to
* lower case. */
export function canonicalizeKey(e: KeyboardEvent): string | null {
if (!e.key) return null;
// Modifier keys pressed alone are never a binding.
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
const named = NAMED[e.key];
if (named === "tab" && e.shiftKey) return "backtab";
let out = "";
if (e.ctrlKey) out += "ctrl-";
if (e.altKey) out += "alt-";
if (e.metaKey) out += "meta-";
if (named) {
if (e.shiftKey) out += "shift-";
return out + named;
}
if (e.key.length === 1) {
if (out === "") return e.key;
if (e.shiftKey) out += "shift-";
return out + e.key.toLowerCase();
}
if (e.shiftKey) out += "shift-";
return out + e.key.toLowerCase();
}
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
* itself so a different leader sends its own byte, not a hardcoded one. */
export function controlByte(chord: string): string {
const m = /^ctrl-([a-z])$/.exec(chord);
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
return String.fromCharCode(m[1].charCodeAt(0) - 96);
}
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
/** True when a chord carries a modifier or more than one key. Plain single-key
* chords are the ones that stay deaf inside form fields. */
function isChorded(chord: string): boolean {
return chord.includes(" ") || MOD_PREFIX.test(chord);
}
/** True when the event target swallows plain keystrokes — a form field, or the
* hidden textarea xterm types into. Every plain single-key binding is deaf in
* the same places. */
export function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
return (
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
el.isContentEditable
);
}
/** 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). */
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> = {
ctrl: "Ctrl",
alt: "Alt",
meta: "Meta",
shift: "Shift",
esc: "Esc",
enter: "Enter",
tab: "Tab",
backtab: "Shift Tab",
backspace: "Backspace",
up: "Up",
down: "Down",
left: "Left",
right: "Right",
home: "Home",
end: "End",
pageup: "PgUp",
pagedown: "PgDn",
delete: "Del",
space: "Space",
};
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
* multi-key chord separates its keys with a `then` cap. */
export function keyCaps(chord: string): string[] {
const caps: string[] = [];
for (const [i, key] of chord.split(" ").entries()) {
if (i > 0) caps.push("then");
const parts = key.split("-");
// A bare `-` key splits into two empties; treat it as the key itself.
if (parts.every((p) => p === "")) {
caps.push("-");
continue;
}
const last = parts.pop() as string;
const mods = parts.filter((p) => p !== "");
for (const m of mods) caps.push(CAPS[m] ?? m);
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
}
return caps;
}
// ---- dispatch ----
/** How long a partial chord waits for its next key — the TUI's
* `chord_timeout_ms` default. */
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
export interface CommandSetOptions {
/** Canonical leader key. A set given one holds every leader press as a
* pending chord, whether or not anything is bound behind it yet. */
leader?: string | null;
chordTimeoutMs?: number;
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
* handler, which swallows by returning `false` to xterm instead. */
preventDefault?: boolean;
/** Clock, for tests. */
now?: () => number;
}
/** A group of commands listening on one surface. Resolves a keydown to a
* command the way the TUI's `ChordState` does: hold while the sequence is a
* strict prefix of some binding, otherwise resolve and dispatch. */
export class CommandSet {
private readonly byChord = new Map<string, Command>();
private readonly leader: string | null;
private readonly timeout: number;
private readonly preventDefault: boolean;
private readonly now: () => number;
private held: string[] = [];
private lastPress = 0;
constructor(
readonly commands: Command[],
opts: CommandSetOptions = {},
) {
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
this.leader = opts.leader ?? null;
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
this.preventDefault = opts.preventDefault ?? true;
this.now = opts.now ?? (() => Date.now());
publishCommands(commands);
}
/** Whether any binding starts with `candidate` and continues. */
private isPrefix(candidate: string): boolean {
const marker = `${candidate} `;
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
return false;
}
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
* held as a partial chord. */
handle(e: KeyboardEvent): boolean {
const key = canonicalizeKey(e);
if (key === null) return false;
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
this.lastPress = this.now();
// The leader is a prefix by construction, so it stops being select-all
// before anything is bound behind it (#149 binds the first chord).
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
this.held = [key];
if (this.preventDefault) e.preventDefault();
return true;
}
if (this.isPrefix(candidate)) {
this.held.push(key);
if (this.preventDefault) e.preventDefault();
return true;
}
const wasChord = this.held.length > 0;
this.held = [];
const cmd = this.byChord.get(candidate);
if (!cmd) {
// A chord that resolved to nothing is still consumed: the leader took
// the key, so it must not also fall through to a plain binding.
return wasChord;
}
// Plain single-key bindings are deaf where typing happens; chorded ones are
// not, and neither are the pane's and the overlay's, which are only fed
// from the surface that owns them.
if (
!isChorded(candidate) &&
cmd.scope !== "pane" &&
cmd.scope !== "overlay" &&
isTypingTarget(e.target)
) {
return false;
}
if (this.preventDefault) e.preventDefault();
cmd.run();
return true;
}
}
-40
View File
@@ -3,23 +3,6 @@ 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();
});
@@ -102,26 +85,3 @@ 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,18 +159,6 @@ 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);
+6 -5
View File
@@ -2,8 +2,6 @@
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
import { CommandSet, defineCommands } from "./commands.js";
const STORAGE_KEY = "maestro:hud";
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
@@ -33,9 +31,12 @@ export function toggleHud(): void {
setHudEnabled(!isHudEnabled());
}
const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() }));
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
export function installHudKeybind(): void {
window.addEventListener("keydown", (e) => hudKeys.handle(e));
window.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
e.preventDefault();
toggleHud();
}
});
}
+30 -18
View File
@@ -1,8 +1,14 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { keyCaps, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
import {
SHORTCUTS,
closeHelp,
installHelpKeybind,
isHelpOpen,
isTypingTarget,
openHelp,
} from "./help.js";
beforeEach(() => {
installHelpKeybind();
@@ -24,24 +30,14 @@ function rows(): string[] {
}
describe("help overlay", () => {
it("renders exactly the commands the registry has bound, grouped", () => {
it("renders every registered shortcut, grouped", () => {
openHelp();
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
expect(document.querySelectorAll(".help-desc")).toHaveLength(
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
);
expect(rows()).toEqual(
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
);
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
});
it("names each row by its command id, so a row can't drift from its command", () => {
openHelp();
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
.toEqual(publishedCommands().map((c) => c.id));
});
it("opens on ? and closes on a second ?", () => {
@@ -98,3 +94,19 @@ describe("help overlay", () => {
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
});
describe("isTypingTarget", () => {
it("is true for form fields and editable nodes, false otherwise", () => {
const el = (html: string) => {
const d = document.createElement("div");
d.innerHTML = html;
return d.firstElementChild as HTMLElement;
};
expect(isTypingTarget(el("<input>"))).toBe(true);
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
expect(isTypingTarget(el("<select></select>"))).toBe(true);
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
expect(isTypingTarget(el("<button></button>"))).toBe(false);
expect(isTypingTarget(null)).toBe(false);
});
});
+79 -37
View File
@@ -1,15 +1,69 @@
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
* overlay. It renders the command registry (`commands.ts`) — the same list
* that dispatches the keys — so a binding can never be documented as one
* thing and do another. */
* overlay. One registry drives both the rendering and what we claim is bound,
* so a binding added to `main.ts` without an entry here reads as missing. */
import {
CommandSet,
defineCommands,
keyCaps,
publishedCommands,
type Command,
} from "./commands.js";
export interface Shortcut {
/** Keycaps, rendered left to right as separate <kbd>. */
keys: string[];
description: string;
}
export interface ShortcutGroup {
title: string;
items: Shortcut[];
}
export const SHORTCUTS: ShortcutGroup[] = [
{
title: "Sessions",
items: [
{ keys: ["n"], description: "Open the spawn form" },
{ keys: ["N"], description: "Spawn a session with every default resolved" },
],
},
{
title: "Session list",
items: [
{
keys: ["Ctrl", "Shift", "H"],
description: "Move the keyboard between the session list and the pane",
},
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
{ keys: ["r"], description: "Rename the highlighted session" },
{ keys: ["x"], description: "Kill the highlighted session" },
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
],
},
{
title: "Help",
items: [
{ keys: ["?"], description: "Show or hide this help" },
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
],
},
{
title: "Debug",
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
},
];
/** True when the event target swallows plain keystrokes — a form field, or the
* hidden textarea xterm types into. Shared with `main.ts` so every global
* single-key binding is deaf in the same places. */
export function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
return (
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
el.isContentEditable
);
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
@@ -54,7 +108,7 @@ export function openHelp(): HTMLElement {
head.append(title, close);
panel.appendChild(head);
for (const group of groupedCommands()) {
for (const group of SHORTCUTS) {
const section = document.createElement("section");
section.className = "help-group";
const heading = document.createElement("h3");
@@ -62,20 +116,19 @@ export function openHelp(): HTMLElement {
heading.textContent = group.title;
const list = document.createElement("dl");
list.className = "help-list";
for (const cmd of group.items) {
for (const item of group.items) {
const row = document.createElement("div");
row.className = "help-row";
row.dataset.commandId = cmd.id;
const keys = document.createElement("dt");
keys.className = "help-keys";
for (const key of keyCaps(cmd.keys[0])) {
for (const key of item.keys) {
const cap = document.createElement("kbd");
cap.textContent = key;
keys.appendChild(cap);
}
const desc = document.createElement("dd");
desc.className = "help-desc";
desc.textContent = cmd.label;
desc.textContent = item.description;
row.append(keys, desc);
list.appendChild(row);
}
@@ -100,26 +153,6 @@ export function openHelp(): HTMLElement {
return el;
}
/** The registry's commands, in registry order, bucketed into the help
* overlay's sections. Groups appear in the order their first command does. */
export function groupedCommands(): { title: string; items: Command[] }[] {
const groups: { title: string; items: Command[] }[] = [];
for (const cmd of publishedCommands()) {
let group = groups.find((g) => g.title === cmd.group);
if (!group) {
group = { title: cmd.group, items: [] };
groups.push(group);
}
group.items.push(cmd);
}
return groups;
}
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
// fed keys while the dialog is up.
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
let installed = false;
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
@@ -128,7 +161,16 @@ export function installHelpKeybind(): void {
if (installed) return;
installed = true;
window.addEventListener("keydown", (e) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
if (e.key === "Escape" && open) {
e.preventDefault();
closeHelp();
return;
}
// Shift is how `?` is typed on most layouts; the others would be a
// different binding entirely.
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleHelp();
});
}
+33 -141
View File
@@ -6,9 +6,7 @@ import { renderSidebar } from "./catalog.js";
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, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
@@ -17,7 +15,6 @@ import {
createSession,
fetchNames,
getDaemonInfo,
gitRoot,
projects as fetchProjects,
} from "./api.js";
import {
@@ -36,7 +33,6 @@ import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
@@ -83,15 +79,6 @@ async function bootApp(): Promise<void> {
const store = new CatalogStore();
// The daemon's home dir, for the command bar's `~/…` directory abbreviation.
// Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => (homeDir = info.home_dir))
.catch(() => {
/* absolute paths are a fine fallback */
});
// The daemon's identity and liveness: names the release in the sidebar's status
// dot, and blocks the UI behind the restart overlay while the daemon is away
// (docs/RESTART.md). The events socket is the trigger — it dies first.
@@ -339,16 +326,6 @@ 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[]),
@@ -356,7 +333,13 @@ async function requestFastForward(): Promise<void> {
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
let presets: SpawnPreset[] = FALLBACK_PRESETS;
try {
const info = await getDaemonInfo();
if (info.presets?.length) presets = info.presets;
} catch {
/* keep fallbacks */
}
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
@@ -378,129 +361,38 @@ 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.
}
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
// Ignored while typing anywhere (form fields, the terminal's hidden
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (isTypingTarget(e.target)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
} else if (e.key === "N") {
e.preventDefault();
void requestFastForward();
}
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` inside the set keeps them out of form fields and the terminal's
// hidden textarea.
const spawnKeys = new CommandSet(
defineCommands({
"spawn.open": () => requestOpenSpawn(),
"spawn.fastForward": () => void requestFastForward(),
}),
);
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
//
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
// key as a partial chord and takes select-all off it, so the escape hatch
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
const focusKeys = new CommandSet(
defineCommands({
"focus.toggle": () => {
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
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"),
// The leader itself, pressed twice, tmux-style: sends one literal leader
// byte and clears the pending chord instead of resolving as a dead chord
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a
// hardcoded byte, so a different leader would send its own.
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)),
}),
{ leader: DEFAULT_LEADER },
);
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
// like any other, so the `?` overlay lists it without a second list.
const openBar = () => {
// Opening while a session is previewed discards the preview first, like
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
schedulePreview.cancel();
mv?.previewSession(null);
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
};
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
// phone, so the field the operator already reaches for opens the same bar.
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
// Capture phase: an attached xterm swallows every keystroke it is given, so the
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
window.addEventListener(
"keydown",
(e) => {
// The bar owns the keyboard while it is up; its own input handles the keys.
if (isCommandBarOpen()) return;
if (focusKeys.handle(e)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) 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,
);
window.addEventListener("keydown", (e) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "H" && e.key !== "h") return;
e.preventDefault();
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
});
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
-201
View File
@@ -1,201 +0,0 @@
import { describe, expect, it } from "vitest";
import {
matchAndRank,
type CommandCandidate,
type DirectoryCandidate,
type MatchCandidate,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
function session(
name: string,
directory?: string,
command?: string,
tool?: string,
): SessionCandidate {
return { kind: "session", fields: { name, directory, command, tool } };
}
function command(label: string): CommandCandidate {
return { kind: "command", fields: { label } };
}
function directory(path: string): DirectoryCandidate {
return { kind: "directory", fields: { path } };
}
function label(result: MatchResult): string {
switch (result.candidate.kind) {
case "session":
return `session:${result.candidate.fields.name}`;
case "command":
return `command:${result.candidate.fields.label}`;
case "directory":
return `directory:${result.candidate.fields.path}`;
}
}
function positions(result: MatchResult): number[] {
return result.ranges.flatMap(([start, end]) =>
Array.from({ length: end - start }, (_, offset) => start + offset),
);
}
function expectResults(
query: string,
candidates: MatchCandidate[],
expected: Array<[label: string, score: number, field?: string, positions?: number[]]>,
): void {
const results = matchAndRank(query, candidates);
expect(results.map((result) => [label(result), result.score])).toEqual(
expected.map(([candidateLabel, score]) => [candidateLabel, score]),
);
expect(results.map((result) => result.field)).toEqual(
expected.map(([, , field]) => field),
);
expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? []));
}
describe("matchAndRank fixtures", () => {
it("F1: an empty query keeps input order", () => {
expectResults(
"",
[
session("kill", "~/dev/api", "bash"),
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
command("Kill selected session"),
],
[
["session:kill", 0],
["session:killer-app", 0],
["command:Kill selected session", 0],
],
);
});
it("F2: an exact session name beats a prefix and kind breaks a tie", () => {
expectResults(
"kill",
[
session("kill", "~/dev/api", "bash"),
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
command("Kill selected session"),
],
[
["session:kill", 178, "name", [0, 1, 2, 3]],
["session:killer-app", 146, "name", [0, 1, 2, 3]],
["command:Kill selected session", 146, "label", [0, 1, 2, 3]],
],
);
});
it("F3: path segment boundaries beat mid-segment matches", () => {
expectResults(
"foo",
[directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")],
[
["directory:~/foo", 78, "path", [2, 3, 4]],
["directory:~/dev/foobar", 74, "path", [6, 7, 8]],
["directory:~/src/myfoo", 56, "path", [8, 9, 10]],
],
);
});
it("F4: a query matching nothing returns no candidates", () => {
expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []);
});
it("F5: the highest-scoring embedding can differ from the greedy embedding", () => {
expectResults(
"mtu",
[session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")],
[
["session:mtui-scratch", 122, "name", [0, 1, 2]],
["session:maestro-tui", 91, "name", [0, 8, 9]],
],
);
});
it("F6: a name beats a weighted directory match", () => {
expectResults(
"api",
[session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")],
[
["session:api", 154, "name", [0, 1, 2]],
["session:worker", 50, "directory", [6, 7, 8]],
],
);
});
it("F7: matching is case-insensitive and field length breaks a tie", () => {
expectResults(
"api",
[session("API-Gateway"), session("apiary")],
[
["session:apiary", 122, "name", [0, 1, 2]],
["session:API-Gateway", 122, "name", [0, 1, 2]],
],
);
});
it("F8: the exact tool field beats the command field", () => {
expectResults(
"claude",
[session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")],
[["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]],
);
});
it("F9: a separator earns a word-boundary bonus", () => {
expectResults(
"ws",
[session("websearch"), session("web-search")],
[
["session:web-search", 71, "name", [0, 4]],
["session:websearch", 56, "name", [0, 3]],
],
);
});
it("F10: command labels rank by boundary and initial gap", () => {
expectResults(
"se",
[command("Kill selected session"), command("Create a session"), command("Show help")],
[
["command:Show help", 53, "label", [0, 6]],
["command:Kill selected session", 51, "label", [5, 6]],
["command:Create a session", 47, "label", [9, 10]],
],
);
});
it("F11: directory matches separate on their initial gap", () => {
expectResults(
"dev",
[
session("api", "~/dev/api", "bash"),
session("notes", "~/documents/dev-notes", "nvim"),
session("scratch", "~/srv/devops", "bash"),
],
[
["session:api", 54, "directory", [2, 3, 4]],
["session:scratch", 50, "directory", [6, 7, 8]],
["session:notes", 44, "directory", [12, 13, 14]],
],
);
});
it("F12: an exact field beats a prefix", () => {
expectResults(
"quit",
[command("Quit Maestro"), command("Quit")],
[
["command:Quit", 178, "label", [0, 1, 2, 3]],
["command:Quit Maestro", 146, "label", [0, 1, 2, 3]],
],
);
});
});
-224
View File
@@ -1,224 +0,0 @@
export type CandidateKind = "session" | "command" | "directory";
export interface SessionCandidate {
kind: "session";
fields: {
name: string;
directory?: string;
command?: string;
tool?: string;
};
}
export interface CommandCandidate {
kind: "command";
fields: { label: string };
}
export interface DirectoryCandidate {
kind: "directory";
fields: { path: string };
}
export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate;
export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path";
export type MatchRange = readonly [start: number, end: number];
export interface MatchResult<C extends MatchCandidate = MatchCandidate> {
candidate: C;
score: number;
field: MatchFieldName | undefined;
ranges: MatchRange[];
}
interface Field {
name: MatchFieldName;
text: string;
weight: number;
}
interface Embedding {
score: number;
positions: number[];
}
interface RankedResult<C extends MatchCandidate> extends MatchResult<C> {
matchedText: string;
inputIndex: number;
}
const KIND_ORDER: Record<CandidateKind, number> = {
session: 0,
command: 1,
directory: 2,
};
function fields(candidate: MatchCandidate): Field[] {
switch (candidate.kind) {
case "session": {
const values: Array<[MatchFieldName, string | undefined, number]> = [
["name", candidate.fields.name, 0],
["directory", candidate.fields.directory, -24],
["command", candidate.fields.command, -32],
["tool", candidate.fields.tool, -40],
];
return values.flatMap(([name, text, weight]) =>
text === undefined ? [] : [{ name, text, weight }],
);
}
case "command":
return [{ name: "label", text: candidate.fields.label, weight: 0 }];
case "directory":
return [{ name: "path", text: candidate.fields.path, weight: 0 }];
}
}
function normalizeQuery(query: string): string {
return query.toLowerCase().replace(/\s/g, "");
}
function boundaryBonus(text: string, position: number): number {
if (position === 0) return 18;
const previous = text[position - 1];
if ("/\\-_ .:".includes(previous)) return 16;
const current = text[position];
const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase();
const previousIsLowercase =
previous !== previous.toUpperCase() && previous === previous.toLowerCase();
if (isUppercase && previousIsLowercase) return 12;
const isDigit = current >= "0" && current <= "9";
const previousIsDigit = previous >= "0" && previous <= "9";
if (isDigit && !previousIsDigit) return 12;
return 0;
}
function contribution(text: string, position: number, previousPosition: number): number {
const gap = position - previousPosition - 1;
return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12));
}
function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean {
if (!incumbent || candidate.score !== incumbent.score) {
return !incumbent || candidate.score > incumbent.score;
}
for (let index = 0; index < candidate.positions.length; index++) {
if (candidate.positions[index] !== incumbent.positions[index]) {
return candidate.positions[index] < incumbent.positions[index];
}
}
return false;
}
function bestEmbedding(query: string, text: string): Embedding | undefined {
const lowerText = text.toLowerCase();
let previousStates = new Map<number, Embedding>();
for (let queryIndex = 0; queryIndex < query.length; queryIndex++) {
const states = new Map<number, Embedding>();
for (let position = 0; position < lowerText.length; position++) {
if (lowerText[position] !== query[queryIndex]) continue;
if (queryIndex === 0) {
states.set(position, {
score: contribution(text, position, -1),
positions: [position],
});
continue;
}
for (const [previousPosition, previous] of previousStates) {
if (previousPosition >= position) continue;
const candidate = {
score: previous.score + contribution(text, position, previousPosition),
positions: [...previous.positions, position],
};
if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate);
}
}
if (states.size === 0) return undefined;
previousStates = states;
}
let best: Embedding | undefined;
for (const embedding of previousStates.values()) {
if (isBetterEmbedding(embedding, best)) best = embedding;
}
return best;
}
function toRanges(positions: number[]): MatchRange[] {
const ranges: MatchRange[] = [];
for (const position of positions) {
const previous = ranges[ranges.length - 1];
if (previous && previous[1] === position) {
ranges[ranges.length - 1] = [previous[0], position + 1];
} else {
ranges.push([position, position + 1]);
}
}
return ranges;
}
function compareByCodePoint(left: string, right: string): number {
const leftPoints = Array.from(left, (character) => character.codePointAt(0)!);
const rightPoints = Array.from(right, (character) => character.codePointAt(0)!);
const length = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < length; index++) {
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
}
return leftPoints.length - rightPoints.length;
}
export function matchAndRank<C extends MatchCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
const normalizedQuery = normalizeQuery(query);
if (normalizedQuery === "") {
return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] }));
}
const results: RankedResult<C>[] = [];
candidates.forEach((candidate, inputIndex) => {
let best:
| { field: Field; score: number; positions: number[] }
| undefined;
for (const field of fields(candidate)) {
const embedding = bestEmbedding(normalizedQuery, field.text);
if (!embedding) continue;
const compactText = field.text.toLowerCase().replace(/\s/g, "");
const adjustment =
compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0;
const score = embedding.score + adjustment + field.weight;
if (!best || score > best.score) best = { field, score, positions: embedding.positions };
}
if (best) {
results.push({
candidate,
score: best.score,
field: best.field.name,
ranges: toRanges(best.positions),
matchedText: best.field.text,
inputIndex,
});
}
});
results.sort(
(left, right) =>
right.score - left.score ||
KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] ||
left.matchedText.length - right.matchedText.length ||
compareByCodePoint(left.matchedText, right.matchedText) ||
left.inputIndex - right.inputIndex,
);
return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges }));
}
-135
View File
@@ -1,135 +0,0 @@
// @vitest-environment happy-dom
//
// The sidebar's search field is the touch entry point to the command bar
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
// shell to open the bar with an empty query, instead of filtering the tree.
import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
import { sessionBarCandidates } from "./commandBar.js";
import { matchAndRank } from "./matcher.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(id: string, cwd: string): Session {
return {
id,
name: id,
cwd,
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
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(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
const handle = renderSidebar(storeWith(sessions));
document.body.appendChild(handle.el);
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
const opens: number[] = [];
const listener = () => opens.push(1);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
return {
handle,
search,
opens,
dispose: () => {
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
handle.dispose();
handle.el.remove();
},
};
}
describe("sidebar search field opens the command bar (#157)", () => {
it("asks the shell to open the bar when the field is focused", () => {
const t = mount();
t.search.dispatchEvent(new FocusEvent("focus"));
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("opens on a tap, where a focus event may never arrive", () => {
const t = mount();
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("does not trap the keyboard in the field: it gives focus up on open", () => {
const t = mount();
t.search.focus();
expect(document.activeElement).not.toBe(t.search);
t.dispose();
});
it("opens the bar with an empty query — the field never holds one", () => {
const t = mount();
t.search.focus();
expect(t.search.value).toBe("");
t.dispose();
});
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
const t = mount();
expect(t.search.readOnly).toBe(true);
t.dispose();
});
it("no longer filters the tree in place", () => {
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
t.search.value = "alpha";
t.search.dispatchEvent(new Event("input"));
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
t.dispose();
});
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
const t = mount([]);
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
t.dispose();
});
});
// The inline filter matched a session on its name and its directory, case- and
// whitespace-insensitively. That behaviour did not go away with the field — it
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
describe("what the retired inline filter did, the bar now does", () => {
it("matches a session on both its name and its directory", () => {
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
const candidates = sessionBarCandidates(sessions, "/home/n");
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
expect(byName).toEqual(["alpha"]);
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
expect(byDir).toEqual(["beta"]);
});
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
});
});
-165
View File
@@ -1425,171 +1425,6 @@ input.leaf-rename:focus {
color: var(--muted);
}
/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */
/* Same family of surface as the spawn modal and the help overlay: the overlay
tokens below are theirs, not a third set. */
.cmdbar-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
grid-template-rows: 12vh auto 1fr;
justify-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.cmdbar-panel {
grid-row: 2;
display: flex;
flex-direction: column;
width: min(560px, 100%);
max-height: min(60vh, 520px);
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.cmdbar-input {
appearance: none;
background: none;
border: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-15);
padding: var(--sp-3) var(--sp-4);
}
.cmdbar-input:focus-visible {
outline: none;
}
.cmdbar-results {
overflow-y: auto;
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);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.cmdbar-row {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-1) var(--sp-4);
cursor: pointer;
}
.cmdbar-row.is-active {
background: var(--surface-3);
}
.cmdbar-row-name {
font-size: var(--fs-13);
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A dead session still lists — it just is not where you just were. */
.cmdbar-row.is-dead .cmdbar-row-name {
color: var(--muted);
}
.cmdbar-row-meta {
flex: 1 1 auto;
min-width: 0;
font-size: var(--fs-11);
color: var(--faint);
text-align: right;
white-space: nowrap;
overflow: hidden;
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);
font-weight: 600;
}
.cmdbar-empty {
padding: var(--sp-3) var(--sp-4);
font-size: var(--fs-13);
color: var(--muted);
}
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
search field (#157), so it rides the bottom of the screen. */
@media (max-width: 599px) {
.cmdbar-overlay {
grid-template-rows: 1fr auto;
padding: 0;
}
.cmdbar-panel {
grid-row: 2;
width: 100%;
max-height: 70dvh;
border-radius: var(--r-3) var(--r-3) 0 0;
border-bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Thumb-sized rows and a field that clears the on-screen keyboard's
zoom-on-focus threshold (16px). */
.cmdbar-input {
font-size: var(--fs-17);
min-height: 44px;
}
.cmdbar-row {
min-height: 44px;
}
}
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
+26 -2
View File
@@ -26,6 +26,7 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/work"), session("b", "/home/n/work")],
"/home/n",
"",
) as TreeFolder;
expect(names(root)).toEqual(["~/work"]);
@@ -34,7 +35,7 @@ describe("buildTree", () => {
});
it("collapses a linear chain into one folder", () => {
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
});
@@ -42,6 +43,7 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
"/home/n",
"",
) as TreeFolder;
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
@@ -51,6 +53,7 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
"/home/n",
"",
) as TreeFolder;
expect(names(root)).toEqual(["~/git"]);
@@ -62,11 +65,30 @@ describe("buildTree", () => {
const root = buildTree(
[session("loose", ""), session("nested", "/home/n/work")],
"/home/n",
"",
) as TreeFolder;
expect(root.children[0].kind).toBe("folder");
expect(root.children[1].kind).toBe("leaf");
});
it("filters on both session name and directory", () => {
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
expect(names(byDir)).toEqual(["~/alpha"]);
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
expect(names(byCommand)).toEqual(["~/alpha"]);
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
expect(noMatch.children).toEqual([]);
});
it("ignores case and surrounding whitespace in the filter", () => {
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
expect(root.children).toHaveLength(1);
});
});
describe("mergeLinear", () => {
@@ -105,6 +127,7 @@ describe("worktree sessions group under their repository (#99)", () => {
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
@@ -124,6 +147,7 @@ describe("worktree sessions group under their repository (#99)", () => {
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
@@ -133,7 +157,7 @@ describe("worktree sessions group under their repository (#99)", () => {
});
it("leaves a session without a worktree where it is", () => {
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/arr"]);
});
});
+6 -1
View File
@@ -25,14 +25,19 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
return an.localeCompare(bn);
}
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const parts = dir ? dir.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
-6
View File
@@ -50,12 +50,6 @@ export interface Session {
* create one. `base_repo` is the repository's main checkout, which is what
* the session tree groups by (#99). */
worktree?: SessionWorktree | null;
/** Wall-clock of when this session last became the pane a client was showing
* (#145) — stamped by both `POST /api/sessions/:id/focus` and opening
* `/api/attach/:id`. Absent until the session has been focused once, which is
* how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */