Compare commits

..
Author SHA1 Message Date
Miguel Palhas 9d9aef119d feat(maestro): stamp last-focused time on the session (#145)
Add Session::last_focused_at, shipped on the wire like every other
field. Both POST /api/sessions/:id/focus (focused: true) and opening
/api/attach/:id stamp it and emit SessionUpdated, so a client that
only attaches sorts the same as one that reports focus explicitly.
Losing focus does not clear it, and a never-focused session has None,
which is what lets the command bar's empty-query ordering
(docs/COMMAND-BAR.md) sort it after every session that has focused.
2026-09-03 14:05:36 +01:00
Miguel Palhas 950a899b3c feat(maestro): add command bar matcher 2026-09-03 13:55:02 +01:00
Miguel PalhasandClaude Opus 5 328711cade refactor(maestro): route web keys through a command registry
One entry per command — stable id, plain-language label, key chord, run()
— replacing the ad-hoc keydown listeners in main.ts, catalog.ts, debug.ts
and attach.ts and the parallel SHORTCUTS constant in help.ts. The `?`
overlay now renders the registry, so a binding and its documentation
cannot disagree.

Keys are canonicalised the way the TUI's `InputKey::canonical` spells
them, and chords resolve like its `ChordState`: hold while the sequence
is a strict prefix, otherwise dispatch. That gives the web a leader,
`ctrl-a` by default like `[tui].leader`, held as a partial chord and
`preventDefault`ed so it stops being select-all — the escape hatch in
docs/COMMAND-BAR.md needs somewhere to land (#149). The leader stays out
of real form fields, where select-all is still what the operator wants.

Same keys, same effects: plain single-key bindings remain deaf inside
form fields and the terminal's hidden textarea, chorded ones do not.

Refs #148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:47:35 +01:00
Miguel Palhas b0504eb736 Merge remote-tracking branch 'origin/main' into harmony
ci / web (push) Successful in 1m2s
ci / flake (push) Successful in 3m28s
ci / rust (push) Successful in 7m48s
2026-09-03 13:38:36 +01:00
Miguel PalhasandClaude Opus 5 6075a177f5 perf(maestro): drop the sidebar preview delay
The pane waited 150 ms on hover and 60 ms on a keyboard step before
following the sidebar highlight, so every preview paid for the fast walk
it might have been. Replace both with a leading-edge 120 ms gap: the
first move shows at once, a burst is coalesced to its start and its end.

The row's own hover no longer eases either — `background` was on a 120 ms
transition, which is what read as lag on the row itself.

The sidebar also rebuilt its whole tree on every `SessionUpdated`, a
couple of times a minute per live session, dropping `:hover` from the row
under the pointer. Refreshes that change nothing structural now patch the
activity rail and meta line into the standing rows instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:36:51 +01:00
27 changed files with 1886 additions and 270 deletions
+1
View File
@@ -251,6 +251,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+59 -25
View File
@@ -285,33 +285,50 @@ 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 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
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;
}
}
}))
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 {
@@ -448,6 +465,7 @@ 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) {
@@ -652,6 +670,7 @@ impl SessionManager {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -838,11 +857,26 @@ 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`).
/// An unknown session is [`SessionError::NotFound`].
/// 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`].
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
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);
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 });
}
Ok(())
}
}
+35
View File
@@ -383,6 +383,41 @@ 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;
@@ -1091,3 +1091,109 @@ 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,6 +31,7 @@ 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,
}
}
+1
View File
@@ -248,6 +248,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+31
View File
@@ -108,6 +108,14 @@ 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.
@@ -847,6 +855,7 @@ mod tests {
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
}
}
@@ -896,6 +905,28 @@ 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,6 +46,7 @@ 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,
}
}
+66 -26
View File
@@ -87,10 +87,13 @@ pub struct App {
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>,
/// A sidebar highlight waiting out its debounce before it becomes a
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
/// not open an attach per row it passes over.
/// A sidebar highlight waiting for the pane's minimum gap before it
/// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
/// down must not open an attach per row it passes over.
pending_preview: Option<(Uuid, std::time::Instant)>,
/// When the pane last swapped what it previews. `None` until the first
/// one, which is why a highlight landing on a still sidebar shows at once.
last_preview_at: Option<std::time::Instant>,
/// Sessions attached only because they were previewed, oldest first. Kept
/// connected past the preview so stepping back onto a row is instant, and
/// bounded so a long walk doesn't leave a socket per session behind.
@@ -125,10 +128,11 @@ struct FrameKey {
/// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
/// How long a highlight has to stand still before the pane previews it
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
/// attach to every row it crosses, short enough to read as immediate.
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
/// highlight to land shows at once; this only bounds how often the pane may
/// follow after that, so a fast walk down the list attaches to the row it
/// started on and the row it stopped on rather than every row between.
const PREVIEW_GAP: Duration = Duration::from_millis(120);
/// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3;
@@ -213,6 +217,7 @@ impl App {
clicks: ClickTracker::default(),
focused_session: None,
pending_preview: None,
last_preview_at: None,
preview_lru: Vec::new(),
last_frame: None,
last_drawn_at: std::time::Instant::now(),
@@ -526,9 +531,10 @@ impl App {
}
/// Recompute what the pane should be previewing. A session highlighted in
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
/// group row, the pane holding the keyboard, or the highlight already
/// sitting on the attached session — clears the preview at once.
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
/// last swap — immediately, for a highlight landing on a still list.
/// Anything else — a group row, the pane holding the keyboard, or the
/// highlight already sitting on the attached session — clears at once.
fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
@@ -537,7 +543,12 @@ impl App {
match wanted {
Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) {
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
let now = std::time::Instant::now();
let due = match self.last_preview_at {
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
_ => now,
};
self.pending_preview = Some((sid, due));
}
}
_ => {
@@ -547,8 +558,8 @@ impl App {
}
}
/// The debounce elapsed: show the highlighted session, attaching it if
/// nothing already is.
/// The pane is free to follow: show the highlighted session, attaching it
/// if nothing already is.
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let Some((sid, due)) = self.pending_preview else {
return Vec::new();
@@ -560,6 +571,7 @@ impl App {
if self.state.preview == Some(sid) {
return Vec::new();
}
self.last_preview_at = Some(now);
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
@@ -1653,6 +1665,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
})
.collect(),
});
@@ -1671,24 +1684,22 @@ mod tests {
}
}
fn after_debounce() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
fn after_gap() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
}
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
/// tab taking it on.
#[test]
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
fn highlighting_a_session_previews_it_without_waiting() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
// Nothing yet: the highlight has to stand still first.
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
assert_eq!(app.state.preview, None);
// The list was still, so the pane follows on the next poll — no gap to
// wait out first (docs/PREVIEW.md).
assert_eq!(
app.poll_due_preview_at(after_debounce()),
app.poll_due_preview_at(std::time::Instant::now()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
@@ -1697,6 +1708,28 @@ mod tests {
assert_eq!(app.state.active_tab().session(), None);
}
/// The gap only bounds the *second* swap, so a burst costs two mounts.
#[test]
fn a_second_move_inside_the_gap_waits_for_it() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
let now = std::time::Instant::now();
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
app.dispatch(Action::SidebarNext);
assert_eq!(app.poll_due_preview_at(now), vec![]);
assert_eq!(app.state.preview, Some(a));
assert_eq!(
app.poll_due_preview_at(after_gap()),
vec![Effect::Attach(b)]
);
assert_eq!(app.state.preview, Some(b));
}
/// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed.
#[test]
@@ -1707,7 +1740,7 @@ mod tests {
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce());
let effects = app.poll_due_preview_at(after_gap());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
@@ -1723,7 +1756,7 @@ mod tests {
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
@@ -1743,7 +1776,7 @@ mod tests {
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
@@ -1756,7 +1789,7 @@ mod tests {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
@@ -1775,10 +1808,13 @@ mod tests {
.map(Uuid::from_u128)
.collect();
let mut app = app_with_sessions(&ids);
// Each row is previewed in its own gap window, so all of them land.
let mut clock = std::time::Instant::now();
for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
clock += PREVIEW_GAP + Duration::from_millis(1);
app.poll_due_preview_at(clock);
app.emulators.insert(*id, fake_handle());
}
@@ -1810,6 +1846,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -1836,6 +1873,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -2697,6 +2735,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -2731,6 +2770,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
}
+1
View File
@@ -222,6 +222,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+2
View File
@@ -461,6 +461,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -1123,6 +1124,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
};
let sb = Sidebar {
sessions: vec![s],
+12 -8
View File
@@ -50,15 +50,19 @@ Nothing moves a second time on commit.
## Timing
A preview follows the highlight closely enough to be usable as a way to look
around, and lags a pointer enough not to flash rows crossed on the way
somewhere else.
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
shows it on the next frame, and so does leaving every row. There is no
settling period in front of the first preview, because that period would sit
in front of the one preview the operator actually meant.
| Trigger | Delay |
|---|---|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
| Pointer hover (web, mouse only — never touch) | 150 ms |
| Clearing (highlight left every session) | same delay as the trigger that set it |
What is bounded is how *often* the pane may swap: at most once per 120 ms.
A burst inside one window — `j` held down, or the pointer sweeping the length
of the sidebar — shows the row it started on, then the row it ended on, and
none of the rows in between. So a walk costs a couple of pane mounts rather
than one per row, without the operator ever waiting on one.
The highlight itself is not bounded by any of this: the row lights up on the
same frame as the input, whatever the pane behind it is doing.
Touch has no hover, and a tap already opens a session, so touch never previews.
+15 -7
View File
@@ -5,6 +5,7 @@ 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";
@@ -13,6 +14,15 @@ 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();
@@ -56,8 +66,6 @@ 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 {
@@ -210,12 +218,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.
// 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.
term.attachCustomKeyEventHandler((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;
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
+100 -30
View File
@@ -2,7 +2,8 @@ import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js";
import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
@@ -164,6 +165,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return wrap;
}
// The row outlives the snapshot it was built from: a refresh that changes
// nothing structural patches it in place rather than replacing it, so the
// handlers below have to read the current session, not the one captured here.
let cur = node.session;
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
@@ -197,33 +202,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
void requestKill(s, name);
void requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) },
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)),
},
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]);
});
leafPatches.set(row, (next) => {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
if (meta) meta.outerHTML = metaLine(next, Date.now());
});
return row;
}
/** Refresh a standing card from a newer snapshot of its session, keeping the
* element (and so the pointer's `:hover`) exactly where it is. Registered by
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
/**
* Everything about the tree that a patch cannot fix up.
*
* The daemon re-sends a session on every activity, foreground or cwd shift —
* a couple of times a minute per live session — and rebuilding the sidebar for
* each one drops `:hover` from the row under the pointer. Refreshes whose
* signature is unchanged are patched into the standing rows instead; anything
* that moves, renames, appears or dies changes the signature and rebuilds.
*/
function treeSignature(node: TreeNode): string {
if (node.kind === "folder") {
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
if (node.name && collapsed.has(node.path)) return head;
return `${head}(${node.children.map(treeSignature).join(",")})`;
}
const s = node.session;
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
}
/**
* Kill on the first click (#81): the daemon is the one that decides whether a
* session may go, and it refuses over a worktree holding unsaved work. Only
@@ -368,7 +414,7 @@ export interface SidebarOptions {
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
onPreview?: (sessionId: string | null) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -547,7 +593,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key");
opts.onPreview?.(sessionOf(navKey));
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -578,7 +624,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id, "hover");
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
@@ -595,7 +641,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
@@ -616,24 +662,33 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
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();
});
// 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));
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
@@ -641,6 +696,15 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const state = store.snapshot();
const sessions = [...state.sessions.values()];
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;
if (s) leafPatches.get(row)?.(s);
}
return;
}
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
@@ -664,7 +728,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
/* absolute paths are a fine fallback */
});
const rerenderHandler = () => refresh();
// Dispatched when something outside the store changed what the tree should
// look like — a folder collapsed, a rename ended — none of which the store
// snapshot can show, so the signature check has to be bypassed.
const rerenderHandler = () => {
renderedSignature = null;
refresh();
};
document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
+105
View File
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { 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: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
+236
View File
@@ -0,0 +1,236 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import {
COMMAND_SPECS,
CommandSet,
DEFAULT_LEADER,
canonicalizeKey,
defineCommands,
isTypingTarget,
keyCaps,
publishedCommands,
} 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);
});
});
+386
View File
@@ -0,0 +1,386 @@
// 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;
}
/** 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: "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();
}
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). */
function isTerminalTextarea(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
}
// ---- 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 ----
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
* in a browser, so a set that owns the leader `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a";
/** How long a partial chord waits for its next key — the TUI's
* `chord_timeout_ms` default. */
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
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;
}
}
+5 -6
View File
@@ -2,6 +2,8 @@
// 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";
@@ -31,12 +33,9 @@ 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) => {
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
e.preventDefault();
toggleHud();
}
});
window.addEventListener("keydown", (e) => hudKeys.handle(e));
}
+18 -30
View File
@@ -1,14 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
SHORTCUTS,
closeHelp,
installHelpKeybind,
isHelpOpen,
isTypingTarget,
openHelp,
} from "./help.js";
import { keyCaps, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => {
installHelpKeybind();
@@ -30,14 +24,24 @@ function rows(): string[] {
}
describe("help overlay", () => {
it("renders every registered shortcut, grouped", () => {
it("renders exactly the commands the registry has bound, grouped", () => {
openHelp();
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),
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
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 ?", () => {
@@ -94,19 +98,3 @@ 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);
});
});
+37 -79
View File
@@ -1,69 +1,15 @@
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
* 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. */
* 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. */
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
);
}
import {
CommandSet,
defineCommands,
keyCaps,
publishedCommands,
type Command,
} from "./commands.js";
let open: { el: HTMLElement; dispose: () => void } | null = null;
@@ -108,7 +54,7 @@ export function openHelp(): HTMLElement {
head.append(title, close);
panel.appendChild(head);
for (const group of SHORTCUTS) {
for (const group of groupedCommands()) {
const section = document.createElement("section");
section.className = "help-group";
const heading = document.createElement("h3");
@@ -116,19 +62,20 @@ export function openHelp(): HTMLElement {
heading.textContent = group.title;
const list = document.createElement("dl");
list.className = "help-list";
for (const item of group.items) {
for (const cmd 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 item.keys) {
for (const key of keyCaps(cmd.keys[0])) {
const cap = document.createElement("kbd");
cap.textContent = key;
keys.appendChild(cap);
}
const desc = document.createElement("dd");
desc.className = "help-desc";
desc.textContent = item.description;
desc.textContent = cmd.label;
row.append(keys, desc);
list.appendChild(row);
}
@@ -153,6 +100,26 @@ 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
@@ -161,16 +128,7 @@ export function installHelpKeybind(): void {
if (installed) return;
installed = true;
window.addEventListener("keydown", (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();
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
});
}
+48 -41
View File
@@ -6,7 +6,8 @@ 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, isTypingTarget } from "./help.js";
import { installHelpKeybind, isHelpOpen } from "./help.js";
import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
@@ -29,6 +30,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
@@ -105,20 +107,14 @@ shellEl.dataset.route = "catalog";
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
// mouse sweep waits long enough that rows crossed on the way somewhere else
// never flash up. The same delay applies to clearing, so the gap between two
// rows doesn't blink the previous view back.
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
let previewTimer: number | undefined;
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
// — j held down, or the pointer sweeping the whole list — is coalesced so it
// costs a couple of pane mounts instead of one per row walked.
const schedulePreview = createPreviewSchedule((sessionId) => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
});
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
@@ -366,37 +362,48 @@ async function requestFastForward(): Promise<void> {
});
}
// 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();
}
});
// The global half of the command registry (#148). Two sets, because they
// differ in one thing: the help overlay owns the keyboard while it is up, so
// the plain keys go deaf behind it while the focus chord does not — an
// attached terminal must never be able to trap the focus keys.
//
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
// 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();
}
},
}),
{ leader: DEFAULT_LEADER },
);
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();
}
if (focusKeys.handle(e)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
spawnKeys.handle(e);
});
// Tab in the session list hands the keyboard to the pane; Escape in the pane
@@ -418,7 +425,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
clearTimeout(previewTimer);
schedulePreview.cancel();
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
+201
View File
@@ -0,0 +1,201 @@
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
@@ -0,0 +1,224 @@
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 }));
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
function harness() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let nextHandle = 1;
const emitted: (string | null)[] = [];
const schedule = createPreviewSchedule((id) => emitted.push(id), {
now: () => now,
setTimer: (fn, ms) => {
const h = nextHandle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h) => {
timers.delete(h);
},
});
/** Advance the clock, firing any timer that comes due. */
const advance = (ms: number) => {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
t.fn();
}
}
};
return { schedule, emitted, advance };
}
describe("preview schedule", () => {
it("shows the first preview immediately", () => {
const { schedule, emitted } = harness();
schedule("s1");
expect(emitted).toEqual(["s1"]);
});
it("coalesces a burst into one trailing preview", () => {
const { schedule, emitted, advance } = harness();
// A pointer sweep across the list, or j held down: every row in between
// is walked well inside one gap.
schedule("s1");
schedule("s2");
schedule("s3");
expect(emitted).toEqual(["s1"]);
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s3"]);
});
it("shows the next preview immediately once the gap has passed", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
advance(PREVIEW_MIN_GAP_MS);
schedule("s2");
expect(emitted).toEqual(["s1", "s2"]);
});
it("drops a superseded trailing preview rather than replaying it", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule("s1"); // back where we started inside the same gap
advance(PREVIEW_MIN_GAP_MS);
// s1 is already on screen, so the trailing edge has nothing to do.
expect(emitted).toEqual(["s1"]);
});
it("clears through the same gap, so leaving and re-entering does not blink", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule(null); // pointer crosses the gap between two rows
schedule("s2");
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s2"]);
});
it("cancel drops a pending trailing preview", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule.cancel();
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
*
* The highlight itself is never scheduled — that is a class on a row and lands
* on the same frame as the input. This governs only the pane swap behind it,
* which mounts a terminal and, for a session the connection pool isn't already
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
*
* A plain debounce would put that cost in front of every preview, including the
* one preview the operator actually meant. Instead the first move shows at once
* and the rest of a burst is coalesced: walking a list with `j` held down, or
* sweeping the pointer from the top of the sidebar to the bottom, costs one
* preview at the start and one at the row you stopped on, not one per row.
*/
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
* couple of terminals rather than one per row; short enough that a deliberate
* second move never waits on it. */
export const PREVIEW_MIN_GAP_MS = 120;
export interface PreviewScheduleDeps {
now?: () => number;
setTimer?: (fn: () => void, ms: number) => number;
clearTimer?: (handle: number) => void;
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
minGapMs?: number;
}
export interface PreviewSchedule {
(sessionId: string | null): void;
/** Drop a pending trailing preview without showing it. */
cancel(): void;
}
/**
* Wrap `show` so it fires on the leading edge and at most once per gap.
*
* `show` is called with the session to preview, or `null` to put back whatever
* the operator actually opened.
*/
export function createPreviewSchedule(
show: (sessionId: string | null) => void,
deps: PreviewScheduleDeps = {},
): PreviewSchedule {
const now = deps.now ?? (() => Date.now());
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
let lastShownAt = Number.NEGATIVE_INFINITY;
let shown: string | null | undefined;
let pendingTimer: number | undefined;
let pendingId: string | null = null;
const emit = (id: string | null) => {
// Nothing to do when the burst ended on the view already up: re-showing it
// would tear down and re-mount the pane for no visible change.
if (shown === id) return;
shown = id;
lastShownAt = now();
show(id);
};
const cancelPending = () => {
if (pendingTimer === undefined) return;
clearTimer(pendingTimer);
pendingTimer = undefined;
};
const schedule = ((sessionId: string | null) => {
pendingId = sessionId;
const waited = now() - lastShownAt;
if (waited >= minGap) {
cancelPending();
emit(sessionId);
return;
}
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
pendingTimer = setTimer(() => {
pendingTimer = undefined;
emit(pendingId);
}, minGap - waited);
}) as PreviewSchedule;
schedule.cancel = cancelPending;
return schedule;
}
+8 -11
View File
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([
[A, "key"],
[B, "key"],
]);
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]);
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]);
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]);
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
+8 -7
View File
@@ -1064,7 +1064,6 @@ textarea {
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-12);
transition: color var(--dur-fast) var(--ease-out);
}
.tree-folder-header:hover {
@@ -1076,9 +1075,9 @@ textarea {
text-align: center;
color: var(--faint);
font-size: var(--fs-11);
transition:
transform var(--dur) var(--ease-out),
color var(--dur-fast) var(--ease-out);
/* The rotation is the collapse animation and stays; the hover colour is
immediate, like the row's own hover. */
transition: transform var(--dur) var(--ease-out);
}
.tree-folder-header[aria-expanded="false"] .chevron {
@@ -1115,9 +1114,11 @@ textarea {
border-radius: var(--r-1);
color: var(--ink);
position: relative;
transition:
background var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
/* No transition on `background`: it is what hover paints, and easing it puts
a visible delay between the pointer landing on a row and the row admitting
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
follows a keyboard walk, not the pointer. */
transition: box-shadow var(--dur-fast) var(--ease-out);
}
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */