Compare commits

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

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

TUI: `palette.pane_row_bg()`, blended partway from the card surface to the
selection surface, so the sidebar reads unfilled / half filled / filled. It
borrows the selection surface's foregrounds, since those are the ones picked
for a filled row, and the cursor still wins on its own row. Blended rather
than shipped as a third `Surface` — that would be eight more colours in each
of seventeen presets — with palette tests asserting it stays a visible step
from both surfaces and stays legible in every preset.
2026-09-03 15:31:44 +01:00
Miguel PalhasandClaude Opus 5 8dde63a2ab feat(maestro): add the web ctrl-k command bar
The overlay, the query line and the session half of the results
(docs/COMMAND-BAR.md): an empty query lists sessions most recently
focused first, typing ranks through `matchAndRank` with the winning
field's ranges highlighted, and results render under kind headers
ordered by their best-scoring member.

Enter dispatches `SESSION_SELECT_EVENT`, the path the sidebar's click
and the hash router already share, so a bar attach cannot leave the
layout in a state a click could not produce.

The global key listener moves to the capture phase, which #148 left
alone: an attached xterm swallows every keystroke it is given, so
ctrl-k has to be seen ahead of the terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:28:42 +01:00
Miguel Palhas 1fe2ae6575 Merge remote-tracking branch 'origin/blitz/command-bar' into command-bar/149-web-bar 2026-09-03 14:17:21 +01:00
Miguel Palhas 5df23b8714 fix(maestro): mirror last_focused_at in the web session type
The daemon ships the field (#145) but web/src/types.ts is a hand-kept
mirror of the protocol crate, so it did not know about it. #149 sorts the
command bar's empty query by it.
2026-09-03 14:16:12 +01:00
Miguel Palhas 46a000aa68 docs(maestro): correct the row-transition comment after the fill change
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m9s
ci / rust (push) Successful in 7m0s
2026-09-03 14:08:37 +01:00
Miguel Palhas b46ba9dd26 Merge remote-tracking branch 'origin/main' into dominant 2026-09-03 14:06:58 +01:00
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 cd37ac5d6d fix(maestro): say preview with a fill, not a ring
The previewed row and the keyboard cursor both drew a 2px inset ring, which
read as a border on hover rather than as a colour. Rows now carry the preview
hue as a fill: a light wash under the pointer or the cursor, deepening to an
opaque `--preview-tint` once the preview lands.

That leaves `.is-active`'s ring as the only one in the session list, where it
now means exactly one thing — open in a pane. The pane's titlebar drops its
hairline for the same reason; the dashed edges that remain belong to the
canvas and the throwaway tab.
2026-09-03 13:46:49 +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
Miguel Palhas 6f0ac2bf4a docs(maestro): add the command bar contract
ci / rust (push) Successful in 10m8s
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m6s
Specifies the ctrl-k command bar for both clients: opening and the
leader escape hatch for a literal Ctrl-K, sigils, empty-query recency,
the key model including the session action page, and touch entry.

The match-and-rank algorithm is pinned exactly — normalization, word
boundaries, the per-character score, embedding choice, tie-break — and
closed with twelve fixtures the TypeScript (#146) and Rust (#147)
matchers are both tested against, so their orders cannot drift.
2026-09-03 13:36:15 +01:00
Miguel Palhas 7a3287f231 feat(maestro): colour the previewed pane and its sidebar row
A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
2026-09-03 13:35:55 +01:00
Miguel Palhas f443f625a8 Merge remote-tracking branch 'origin/main' into coda
ci / web (push) Successful in 30s
ci / flake (push) Successful in 2m48s
ci / rust (push) Successful in 6m3s
2026-09-03 13:21:53 +01:00
Miguel PalhasandClaude Opus 5 655b7b790e fix(maestro): let Escape reach the session in the TUI
Insert mode bound `esc` to LeaveInsert, so full-screen apps in a session
(vim, Claude Code) never saw Escape — the only way out of Insert is now
the leader, which rebinding still follows.

A key bound to `Noop` in Insert mode is also forwarded to the pty now.
`Noop` documents itself as the way to suppress a default binding, and in
Insert mode an unbound key belongs to the session, so this is the escape
hatch for taking any other default back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:32:46 +01:00
Miguel Palhas 71e0e8d0ce Merge branch 'main' of https://git.naps.pt/naps62/maestro into oboe
ci / web (push) Successful in 1m17s
ci / flake (push) Successful in 3m22s
ci / rust (push) Successful in 6m58s
2026-09-03 11:12:02 +01:00
Miguel Palhas 582a7ca888 Merge remote-tracking branch 'origin/main' into oboe
# Conflicts:
#	web/src/catalog.ts
2026-09-03 11:11:40 +01:00
Miguel Palhas d8a5d95db4 Merge remote-tracking branch 'origin/main' into quintet
ci / rust (push) Failing after 7s
ci / web (push) Successful in 32s
ci / flake (push) Successful in 2m2s
2026-09-03 11:08:32 +01:00
Miguel Palhas 51002fece8 docs(maestro): correct the tracker described in CLAUDE.md
Issues moved to Gitea; the file still named a Linear project and team.
Adds the label taxonomy and the milestone/blitz workflow while there.
2026-09-03 11:06:22 +01:00
Miguel Palhas 2319b4e47b fix(maestro): keep the keyboard in the list while previewing
Two mounts were taking focus out of the sidebar mid-walk: the render that
put the multi-view on screen before the preview applied, and the render
that restores the previous pane when a preview ends. Both now mount
without focusing, so j/k keeps walking. Also marks the preview with a
badge on the canvas and drops the close button from its tab.
2026-09-03 10:52:43 +01:00
Miguel Palhas 6635cff46e feat(maestro): preview the highlighted session in the TUI pane
The sidebar highlight now shows its session in the pane after a 120ms
debounce, attaching if nothing already is, without the tab taking it on
and without reporting focus to the daemon. Preview-only attaches stay
warm behind the current one, bounded at three.

docs/PREVIEW.md.
2026-09-03 10:46:45 +01:00
Miguel Palhas f6b2cf3ab4 feat(maestro): preview the highlighted session in the web pane
Moving the sidebar highlight (keyboard or hover) now shows that session in
the content pane without opening it: its own tab comes forward, or a
throwaway tab carries it. The layout, the saved layout and the daemon's
focus report are all left untouched, and the keyboard stays in the list.

Contract in docs/PREVIEW.md.
2026-09-03 10:42:53 +01:00
44 changed files with 5402 additions and 313 deletions
+18 -3
View File
@@ -36,10 +36,16 @@ optional.
## Where the work is
Linear project **Maestro** under team **Naps62**.
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
into milestones.
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
"available now" (status backlog/pending, not blocked).
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
`/work` to that tracker — they pick from open, unassigned issues whose
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
`difficulty` drives model selection downstream, so it tracks the shape of the
work, not its size.
## Repo layout
@@ -72,11 +78,20 @@ before changing anything that creates or removes one.
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`docs/PREVIEW.md` is the contract for what the content pane shows while the
operator moves the sidebar highlight — what a preview may touch, what ends it,
what commits it. Read it before changing sidebar-driven navigation.
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
or is upgraded under them — the build stamp on `/api/info`, the status
indicator's states, the blocking overlay, grace and poll timings. Read it
before changing reconnect behaviour on either client.
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
the two matchers must agree on, and the fixture table they are tested against.
Read it before changing either bar or either matcher.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+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,
}
}
+427 -23
View File
@@ -87,6 +87,17 @@ 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 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.
preview_lru: Vec<Uuid>,
/// Fingerprint of the last frame drawn. The loop compares a freshly
/// computed key against this and skips the repaint when they match — an
/// idle TUI used to emit a full diff 30 times a second forever, which over
@@ -117,6 +128,15 @@ struct FrameKey {
/// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
/// 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;
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
@@ -196,6 +216,9 @@ impl App {
last_sent_size: HashMap::new(),
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(),
epoch: 0,
@@ -234,7 +257,9 @@ impl App {
self.epoch = self.epoch.wrapping_add(1);
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
let effects = self.poll_due_probes_at(std::time::Instant::now());
let now = std::time::Instant::now();
let mut effects = self.poll_due_probes_at(now);
effects.extend(self.poll_due_preview_at(now));
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
@@ -291,16 +316,26 @@ impl App {
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
ChordResult::Resolved(canon) => {
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
if matches!(act, Action::Quit) {
return Ok(true);
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
let effects = self.dispatch(act);
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
None => {}
}
Ok(false)
}
@@ -332,6 +367,7 @@ impl App {
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
}
}
self.refresh_preview();
true
}
@@ -453,8 +489,15 @@ impl App {
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
effects.push(Effect::Attach(sid));
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if self.emulators.get(sid).is_none() {
effects.push(Effect::Attach(sid));
}
}
// It is the pane's own session now, not a loan from the
// preview budget.
self.preview_lru.retain(|s| *s != sid);
self.state.attach_to_focused(sid);
}
}
@@ -481,9 +524,96 @@ impl App {
}
}
}
// Every action can move the highlight or the focus, and the preview is
// a function of both (`docs/PREVIEW.md`).
self.refresh_preview();
effects
}
/// Recompute what the pane should be previewing. A session highlighted in
/// 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),
_ => None,
};
match wanted {
Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) {
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));
}
}
_ => {
self.pending_preview = None;
self.clear_preview();
}
}
}
/// 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();
};
if now < due {
return Vec::new();
}
self.pending_preview = None;
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);
let fresh = self.emulators.get(sid).is_none();
if fresh {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
}
self.trim_preview_budget();
if fresh {
vec![Effect::Attach(sid)]
} else {
Vec::new()
}
}
fn clear_preview(&mut self) {
if self.state.preview.take().is_some() {
self.epoch = self.epoch.wrapping_add(1);
}
self.trim_preview_budget();
}
/// Drop preview-only attaches past the budget. What a tab holds, and what
/// is on screen right now, are never candidates.
fn trim_preview_budget(&mut self) {
while self.preview_lru.len() > PREVIEW_BUDGET {
let held: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.chain(self.state.preview)
.collect();
let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else {
break;
};
let victim = self.preview_lru.remove(pos);
self.release_session(victim);
}
}
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
if self.emulators.get(sid).is_some() {
@@ -1000,7 +1130,7 @@ impl App {
/// Sample everything a frame depends on. Cheap: two atomic loads, an
/// `RwLock` read of a `u64`, and the backend's size.
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
let pane = self.state.active_session().and_then(|sid| {
let pane = self.state.displayed_session().and_then(|sid| {
self.emulators
.get(sid)
.map(|h| (sid, h.revision(), h.state()))
@@ -1049,22 +1179,36 @@ impl App {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight.clone(),
attached,
// The active tab's session, not the displayed one: a preview is
// shown in the pane but never becomes what the operator picked,
// so it must not move this fill (`docs/PREVIEW.md`).
pane_session: self.state.active_session(),
};
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
let pane = shown_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
status: PaneStatus::from(h.state()),
focused: matches!(self.state.focus, Focus::Pane),
// A preview never owns the keyboard, so it never draws a
// cursor either (`docs/PREVIEW.md`).
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let pane_title = match active_sid {
Some(sid) => snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()),
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
if previewing {
format!("{name} · preview")
} else {
name
}
}
None => "session".into(),
};
@@ -1092,7 +1236,7 @@ impl App {
// Resize propagation: record the active pane's rect, recompute the
// per-dimension min for every attached session, send Resize when
// changed.
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) {
self.emulators.resize_tracker_mut().set_pane_size(
sid,
"active",
@@ -1129,6 +1273,7 @@ fn visible_sidebar_order(
sessions: snap.sessions.clone(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
}
.rendered_items_with_collapsed(collapsed)
}
@@ -1501,6 +1646,189 @@ mod tests {
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
}
/// Seed the catalog with `ids` as sessions in one directory.
fn app_with_sessions(ids: &[Uuid]) -> App {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: ids
.iter()
.map(|id| Session {
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
})
.collect(),
});
app
}
fn fake_handle() -> crate::session_task::SessionHandle {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
SessionHandle {
tx: tokio::sync::mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
}
}
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_without_waiting() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
// 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(std::time::Instant::now()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
assert_eq!(app.state.displayed_session(), Some(a));
// The tab is untouched — the preview is a view, not a layout change.
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]
fn walking_past_rows_only_previews_the_one_it_lands_on() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_gap());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
/// The pane's own session is not something to preview.
#[test]
fn highlighting_the_attached_session_clears_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_gap());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
app.refresh_preview();
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Moving off every session — onto a group row, or into the pane — puts
/// back what the tab was showing.
#[test]
fn leaving_the_session_rows_ends_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_gap());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Committing keeps the stream the preview already opened.
#[test]
fn attaching_to_a_previewed_session_reuses_its_stream() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_gap());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
let effects = app.dispatch(Action::AttachHighlighted);
assert_eq!(effects, vec![]);
assert_eq!(app.state.active_tab().session(), Some(a));
assert_eq!(app.state.preview, None);
// No longer on loan from the preview budget.
assert!(!app.preview_lru.contains(&a));
}
/// Preview-only attaches are kept for a step back, but bounded.
#[test]
fn preview_attaches_are_dropped_past_the_budget() {
let ids: Vec<Uuid> = (1..=(PREVIEW_BUDGET as u128 + 2))
.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();
clock += PREVIEW_GAP + Duration::from_millis(1);
app.poll_due_preview_at(clock);
app.emulators.insert(*id, fake_handle());
}
assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET);
// The oldest rows previewed are gone; the newest are still warm.
assert!(app.emulators.get(ids[0]).is_none());
assert!(app.emulators.get(*ids.last().unwrap()).is_some());
}
/// Seed the catalog with one session and highlight it.
fn app_with_highlighted_session(sid: Uuid) -> App {
use chrono::Utc;
@@ -1523,6 +1851,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -1549,6 +1878,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -2245,6 +2575,78 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Drive one key event through an app in Insert mode with a session
/// attached, and report the bytes that reached that session.
async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option<Vec<u8>> {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(41);
let mut app = App::new(
TuiConfig::load(section).unwrap(),
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
DaemonLink::default(),
);
let (tx, mut rx) = mpsc::channel(4);
app.emulators.insert(
sid,
SessionHandle {
tx,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
},
);
app.state.attach_to_focused(sid);
app.state.mode = Mode::Insert;
app.state.focus = Focus::Pane;
app.handle_event(ev).await.unwrap();
match rx.try_recv() {
Ok(SessionInput::Bytes(bytes)) => Some(bytes),
_ => None,
}
}
fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
}
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
/// it constantly, so no Insert-mode default may swallow it. The leader is
/// the way back to the sidebar.
#[tokio::test]
async fn escape_reaches_the_attached_session() {
let sent = bytes_sent_for(
&TuiSection::default(),
key_event(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
)
.await;
assert_eq!(sent, Some(vec![0x1b]));
}
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty.
#[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
let sent = bytes_sent_for(
&section,
key_event(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::CONTROL,
),
)
.await;
assert_eq!(sent, Some(vec![0x01]));
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
@@ -2338,6 +2740,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -2372,6 +2775,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,
}
}
+9 -9
View File
@@ -75,10 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
/// 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.
const DEFAULT_INSERT: &[(&str, &str)] = &[];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
@@ -232,7 +235,7 @@ mod tests {
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
/// mode only exists while the sidebar is focused, so neither can leak
/// into a pty; `esc` is a second way out of Insert alongside the leader.
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
#[test]
fn aoe_style_defaults_are_bound() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
@@ -241,10 +244,7 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "tab"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "esc"),
Some(Action::LeaveInsert),
);
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
}
#[test]
+1
View File
@@ -13,6 +13,7 @@ pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod matcher;
pub mod overlays;
pub mod palette;
pub mod pane;
+750
View File
@@ -0,0 +1,750 @@
//! Pure match-and-rank function for the `ctrl-k` command bar.
//!
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
//! and scores as `web/src/matcher.ts` for the same input.
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
Session,
Command,
Directory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MatchFieldName {
Name,
Directory,
Command,
Tool,
Label,
Path,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCandidate {
pub name: String,
pub directory: Option<String>,
pub command: Option<String>,
pub tool: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandCandidate {
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectoryCandidate {
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchCandidate {
Session(SessionCandidate),
Command(CommandCandidate),
Directory(DirectoryCandidate),
}
pub type MatchRange = (usize, usize);
#[derive(Debug, PartialEq, Eq)]
pub struct MatchResult<'c> {
pub candidate: &'c MatchCandidate,
pub score: i32,
pub field: Option<MatchFieldName>,
pub ranges: Vec<MatchRange>,
}
struct Field<'c> {
name: MatchFieldName,
text: &'c str,
weight: i32,
}
impl MatchCandidate {
fn kind(&self) -> CandidateKind {
match self {
MatchCandidate::Session(_) => CandidateKind::Session,
MatchCandidate::Command(_) => CandidateKind::Command,
MatchCandidate::Directory(_) => CandidateKind::Directory,
}
}
fn fields(&self) -> Vec<Field<'_>> {
match self {
MatchCandidate::Session(session) => {
let mut fields = vec![Field {
name: MatchFieldName::Name,
text: &session.name,
weight: 0,
}];
if let Some(directory) = &session.directory {
fields.push(Field {
name: MatchFieldName::Directory,
text: directory,
weight: -24,
});
}
if let Some(command) = &session.command {
fields.push(Field {
name: MatchFieldName::Command,
text: command,
weight: -32,
});
}
if let Some(tool) = &session.tool {
fields.push(Field {
name: MatchFieldName::Tool,
text: tool,
weight: -40,
});
}
fields
}
MatchCandidate::Command(command) => vec![Field {
name: MatchFieldName::Label,
text: &command.label,
weight: 0,
}],
MatchCandidate::Directory(directory) => vec![Field {
name: MatchFieldName::Path,
text: &directory.path,
weight: 0,
}],
}
}
}
fn kind_order(kind: CandidateKind) -> u8 {
match kind {
CandidateKind::Session => 0,
CandidateKind::Command => 1,
CandidateKind::Directory => 2,
}
}
fn lower_char(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn lower_chars(chars: &[char]) -> Vec<char> {
chars.iter().copied().map(lower_char).collect()
}
fn normalize_query(query: &str) -> Vec<char> {
query
.chars()
.filter(|c| !c.is_whitespace())
.map(lower_char)
.collect()
}
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
fn boundary_bonus(text: &[char], position: usize) -> i32 {
if position == 0 {
return 18;
}
let previous = text[position - 1];
if previous == ' ' || SEPARATORS.contains(&previous) {
return 16;
}
let current = text[position];
if current.is_uppercase() && previous.is_lowercase() {
return 12;
}
let is_digit = current.is_ascii_digit();
let previous_is_digit = previous.is_ascii_digit();
if is_digit && !previous_is_digit {
return 12;
}
0
}
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
let gap = position as isize - previous_position - 1;
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
}
#[derive(Clone)]
struct Embedding {
score: i32,
positions: Vec<usize>,
}
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
let Some(incumbent) = incumbent else {
return true;
};
if candidate.score != incumbent.score {
return candidate.score > incumbent.score;
}
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
if c != i {
return c < i;
}
}
false
}
/// The dynamic program over `(query index, text position)` described in
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
/// `mtu` against `maestro-tui`), so every reachable position at each query
/// index carries its best score and position vector forward.
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
let lower_text = lower_chars(text);
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
for (query_index, &qc) in query.iter().enumerate() {
let mut states: HashMap<usize, Embedding> = HashMap::new();
for (position, &text_char) in lower_text.iter().enumerate() {
if text_char != qc {
continue;
}
if query_index == 0 {
states.insert(
position,
Embedding {
score: contribution(text, position, -1),
positions: vec![position],
},
);
continue;
}
for (&previous_position, previous) in previous_states.iter() {
if previous_position >= position {
continue;
}
let mut positions = previous.positions.clone();
positions.push(position);
let candidate = Embedding {
score: previous.score
+ contribution(text, position, previous_position as isize),
positions,
};
if is_better_embedding(&candidate, states.get(&position)) {
states.insert(position, candidate);
}
}
}
if states.is_empty() {
return None;
}
previous_states = states;
}
let mut best: Option<Embedding> = None;
for embedding in previous_states.into_values() {
if is_better_embedding(&embedding, best.as_ref()) {
best = Some(embedding);
}
}
best
}
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
let mut ranges: Vec<MatchRange> = Vec::new();
for &position in positions {
if let Some(last) = ranges.last_mut() {
if last.1 == position {
last.1 = position + 1;
continue;
}
}
ranges.push((position, position + 1));
}
ranges
}
fn compact(text: &str) -> String {
text.chars()
.filter(|c| !c.is_whitespace())
.map(lower_char)
.collect()
}
struct Ranked<'c> {
candidate: &'c MatchCandidate,
score: i32,
field: Option<MatchFieldName>,
ranges: Vec<MatchRange>,
matched_text: String,
input_index: usize,
}
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
let normalized_query = normalize_query(query);
if normalized_query.is_empty() {
return candidates
.iter()
.map(|candidate| MatchResult {
candidate,
score: 0,
field: None,
ranges: Vec::new(),
})
.collect();
}
let mut results: Vec<Ranked<'c>> = Vec::new();
for (input_index, candidate) in candidates.iter().enumerate() {
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
for field in candidate.fields() {
let text_chars: Vec<char> = field.text.chars().collect();
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
continue;
};
let compact_text = compact(field.text);
let normalized_query_str: String = normalized_query.iter().collect();
let adjustment = if compact_text == normalized_query_str {
64
} else if compact_text.starts_with(&normalized_query_str) {
32
} else {
0
};
let score = embedding.score + adjustment + field.weight;
let is_better = match &best {
None => true,
Some((_, _, best_score, _)) => score > *best_score,
};
if is_better {
best = Some((field.name, field.text, score, embedding.positions));
}
}
if let Some((field_name, field_text, score, positions)) = best {
results.push(Ranked {
candidate,
score,
field: Some(field_name),
ranges: to_ranges(&positions),
matched_text: field_text.to_string(),
input_index,
});
}
}
results.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
.then_with(|| {
a.matched_text
.chars()
.count()
.cmp(&b.matched_text.chars().count())
})
.then_with(|| a.matched_text.cmp(&b.matched_text))
.then_with(|| a.input_index.cmp(&b.input_index))
});
results
.into_iter()
.map(|ranked| MatchResult {
candidate: ranked.candidate,
score: ranked.score,
field: ranked.field,
ranges: ranked.ranges,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn session(
name: &str,
directory: Option<&str>,
command: Option<&str>,
tool: Option<&str>,
) -> MatchCandidate {
MatchCandidate::Session(SessionCandidate {
name: name.to_string(),
directory: directory.map(str::to_string),
command: command.map(str::to_string),
tool: tool.map(str::to_string),
})
}
fn command(label: &str) -> MatchCandidate {
MatchCandidate::Command(CommandCandidate {
label: label.to_string(),
})
}
fn directory(path: &str) -> MatchCandidate {
MatchCandidate::Directory(DirectoryCandidate {
path: path.to_string(),
})
}
fn label(candidate: &MatchCandidate) -> String {
match candidate {
MatchCandidate::Session(s) => format!("session:{}", s.name),
MatchCandidate::Command(c) => format!("command:{}", c.label),
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
}
}
fn positions(result: &MatchResult) -> Vec<usize> {
result
.ranges
.iter()
.flat_map(|&(start, end)| start..end)
.collect()
}
#[allow(clippy::type_complexity)]
fn expect_results(
query: &str,
candidates: &[MatchCandidate],
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
) {
let results = match_and_rank(query, candidates);
let actual: Vec<(String, i32)> = results
.iter()
.map(|r| (label(r.candidate), r.score))
.collect();
let expected_labels: Vec<(String, i32)> = expected
.iter()
.map(|(l, s, _, _)| (l.to_string(), *s))
.collect();
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
let expected_fields: Vec<Option<MatchFieldName>> =
expected.iter().map(|(_, _, f, _)| *f).collect();
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
let expected_positions: Vec<Vec<usize>> =
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
assert_eq!(
actual_positions, expected_positions,
"positions for query {query:?}"
);
}
#[test]
fn f1_empty_query_keeps_input_order() {
expect_results(
"",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
session(
"killer-app",
Some("~/dev/killer-app"),
Some("claude --resume"),
Some("claude"),
),
command("Kill selected session"),
],
&[
("session:kill", 0, None, &[]),
("session:killer-app", 0, None, &[]),
("command:Kill selected session", 0, None, &[]),
],
);
}
#[test]
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
expect_results(
"kill",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
session(
"killer-app",
Some("~/dev/killer-app"),
Some("claude --resume"),
Some("claude"),
),
command("Kill selected session"),
],
&[
(
"session:kill",
178,
Some(MatchFieldName::Name),
&[0, 1, 2, 3],
),
(
"session:killer-app",
146,
Some(MatchFieldName::Name),
&[0, 1, 2, 3],
),
(
"command:Kill selected session",
146,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
],
);
}
#[test]
fn f3_path_segment_boundaries_beat_mid_segment() {
expect_results(
"foo",
&[
directory("~/dev/foobar"),
directory("~/src/myfoo"),
directory("~/foo"),
],
&[
(
"directory:~/foo",
78,
Some(MatchFieldName::Path),
&[2, 3, 4],
),
(
"directory:~/dev/foobar",
74,
Some(MatchFieldName::Path),
&[6, 7, 8],
),
(
"directory:~/src/myfoo",
56,
Some(MatchFieldName::Path),
&[8, 9, 10],
),
],
);
}
#[test]
fn f4_no_match_returns_empty() {
expect_results(
"zzz",
&[
session("kill", Some("~/dev/api"), Some("bash"), None),
command("Kill selected session"),
],
&[],
);
}
#[test]
fn f5_highest_scoring_embedding_beats_greedy() {
expect_results(
"mtu",
&[
session("maestro-tui", None, None, None),
session("maestro-daemon", None, None, None),
session("mtui-scratch", None, None, None),
],
&[
(
"session:mtui-scratch",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
(
"session:maestro-tui",
91,
Some(MatchFieldName::Name),
&[0, 8, 9],
),
],
);
}
#[test]
fn f6_name_beats_weighted_directory() {
expect_results(
"api",
&[
session("api", Some("~/dev/web"), Some("bash"), None),
session("worker", Some("~/dev/api"), Some("bash"), None),
],
&[
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
(
"session:worker",
50,
Some(MatchFieldName::Directory),
&[6, 7, 8],
),
],
);
}
#[test]
fn f7_case_insensitive_length_breaks_tie() {
expect_results(
"api",
&[
session("API-Gateway", None, None, None),
session("apiary", None, None, None),
],
&[
(
"session:apiary",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
(
"session:API-Gateway",
122,
Some(MatchFieldName::Name),
&[0, 1, 2],
),
],
);
}
#[test]
fn f8_exact_tool_beats_command_field() {
expect_results(
"claude",
&[
session(
"nameless",
Some("~/dev/x"),
Some("claude --resume"),
Some("claude"),
),
session("clay", Some("~/dev/y"), Some("bash"), None),
],
&[(
"session:nameless",
186,
Some(MatchFieldName::Tool),
&[0, 1, 2, 3, 4, 5],
)],
);
}
#[test]
fn f9_separator_earns_word_boundary_bonus() {
expect_results(
"ws",
&[
session("websearch", None, None, None),
session("web-search", None, None, None),
],
&[
(
"session:web-search",
71,
Some(MatchFieldName::Name),
&[0, 4],
),
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
],
);
}
#[test]
fn f10_command_labels_rank_by_boundary_and_gap() {
expect_results(
"se",
&[
command("Kill selected session"),
command("Create a session"),
command("Show help"),
],
&[
(
"command:Show help",
53,
Some(MatchFieldName::Label),
&[0, 6],
),
(
"command:Kill selected session",
51,
Some(MatchFieldName::Label),
&[5, 6],
),
(
"command:Create a session",
47,
Some(MatchFieldName::Label),
&[9, 10],
),
],
);
}
#[test]
fn f11_directory_matches_separate_on_initial_gap() {
expect_results(
"dev",
&[
session("api", Some("~/dev/api"), Some("bash"), None),
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
session("scratch", Some("~/srv/devops"), Some("bash"), None),
],
&[
(
"session:api",
54,
Some(MatchFieldName::Directory),
&[2, 3, 4],
),
(
"session:scratch",
50,
Some(MatchFieldName::Directory),
&[6, 7, 8],
),
(
"session:notes",
44,
Some(MatchFieldName::Directory),
&[12, 13, 14],
),
],
);
}
#[test]
fn f12_exact_beats_prefix() {
expect_results(
"quit",
&[command("Quit Maestro"), command("Quit")],
&[
(
"command:Quit",
178,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
(
"command:Quit Maestro",
146,
Some(MatchFieldName::Label),
&[0, 1, 2, 3],
),
],
);
}
}
+62
View File
@@ -813,6 +813,21 @@ impl Palette {
}
}
/// Background for the card whose session the pane is showing
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
/// selection surface, so the sidebar reads as one ladder: unfilled, half
/// filled for the session in the pane, fully filled under the cursor.
///
/// Blended rather than added as a third `Surface` — a new surface means
/// eight more colours hand-picked seventeen times — which is also why it
/// borrows the *selection* surface's foregrounds: those are the ones
/// already picked for a filled row. A preset whose colours the terminal
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
/// says "in the pane" with the filled glyph alone.
pub fn pane_row_bg(&self) -> Color {
mix(self.card.bg, self.selection.bg, 0.55)
}
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned")
}
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
/// there is nothing to blend and `from` comes back unchanged.
fn mix(from: Color, to: Color, amount: f64) -> Color {
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
return from;
};
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked.
@@ -937,6 +962,43 @@ mod tests {
}
}
/// The pane row is the middle rung: a visible fill against the card, still
/// a different surface from the cursor's, and carrying the selection
/// surface's foregrounds, which it borrows rather than shipping its own.
#[test]
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
for (preset, p) in rgb_presets() {
let bg = p.pane_row_bg();
let from_card = contrast_ratio(p.card.bg, bg)
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
assert!(
from_card >= 1.15,
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
);
let from_selection =
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
assert!(
from_selection >= 1.15,
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
);
for (role, color) in p.selection.roles() {
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
);
}
}
}
/// A preset the terminal themes has nothing to blend from.
#[test]
fn ansi_keeps_the_card_background_for_the_pane_row() {
let p = Palette::ANSI;
assert_eq!(p.pane_row_bg(), p.card.bg);
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
+4
View File
@@ -415,6 +415,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
@@ -511,6 +512,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
@@ -561,6 +563,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
@@ -601,6 +604,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
+175 -19
View File
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
/// on its own row: a highlight that lands on the session already in the pane
/// still has to be visible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CardFill {
/// An ordinary card, on the card surface.
None,
/// The session the pane is showing.
Pane,
/// Under the cursor, with the sidebar holding the keyboard.
Cursor,
}
impl CardFill {
/// Both filled tiers carry the selection surface's foregrounds — those are
/// the colours picked for a filled row, and a colour chosen for the card
/// background is not legible on one.
fn filled(self) -> bool {
self != CardFill::None
}
}
/// Cards take the cursor's fill only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
fn card_base(fill: CardFill, focused: bool) -> Style {
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
let mut style = palette.surface(fill.filled()).text_style();
match fill {
CardFill::Cursor => {}
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
CardFill::None => {}
}
style
}
@@ -55,6 +80,9 @@ pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
/// The session the active tab's pane is showing. It wears a fill of its
/// own, so "in the pane" is not carried by the ● glyph alone.
pub pane_session: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,8 +141,7 @@ impl Sidebar {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let base = card_base(self.card_fill(s, focused), focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
@@ -226,6 +253,18 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// Which fill this session's card wears. The cursor wins on its own row,
/// so a highlight parked on the session already in the pane stays visible.
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
CardFill::Cursor
} else if self.pane_session == Some(s.id) {
CardFill::Pane
} else {
CardFill::None
}
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
@@ -233,12 +272,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let rail_style = base.fg(activity_color(s.activity, fill));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -257,7 +297,7 @@ impl Sidebar {
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
@@ -283,11 +323,11 @@ impl Sidebar {
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
@@ -308,7 +348,7 @@ impl Sidebar {
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
let surface = palette.surface(fill.filled());
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
@@ -352,10 +392,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
palette::active().surface(fill.filled()).activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -461,6 +499,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -520,6 +559,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 12));
assert!(
@@ -544,6 +584,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
})
.groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
@@ -555,6 +596,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: Default::default(),
pane_session: None,
};
assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty());
@@ -566,6 +608,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new();
@@ -590,6 +633,7 @@ mod tests {
sessions: vec![session],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]);
@@ -620,6 +664,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp"));
@@ -635,6 +680,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude");
@@ -645,6 +691,87 @@ mod tests {
);
}
/// Whether any cell in the buffer is painted on `bg`.
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
(0..buf.area.height)
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
}
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
/// not the ● glyph alone carrying "this is the one on screen".
#[test]
fn the_session_in_the_pane_wears_the_pane_fill() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
let none = Sidebar {
pane_session: None,
..sb
};
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
}
/// The cursor's fill wins on its own row: a highlight that lands on the
/// session already in the pane has to stay visible.
#[test]
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(sid)),
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw_with_focus(&sb, 28, 6, true);
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
assert!(
!any_cell_on(&buf, p.pane_row_bg()),
"the pane fill is showing through the cursor's own row"
);
}
/// A card the pane is showing is a filled row, so it takes the selection
/// surface's foregrounds — a colour picked for the card background is not
/// legible on a fill.
#[test]
fn the_pane_row_carries_selection_foregrounds() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let mut sess = session_named(1, "claude");
sess.activity = ActivityState::AwaitingInput;
let sb = Sidebar {
sessions: vec![sess],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw(&sb, 28, 6);
let on_pane_row = |fg: Color| {
(0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| {
let st = buf[(x, y)].style();
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
})
})
};
assert!(
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
"the rail must carry the state in the filled surface's colours"
);
assert!(
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
"a card surface colour is painted onto the pane fill"
);
}
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "claude");
@@ -653,6 +780,7 @@ mod tests {
sessions: vec![sess],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 6);
let all = rows(&buf);
@@ -681,6 +809,7 @@ mod tests {
sessions: vec![blocked, clear, unprobed],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 44, 10));
@@ -727,6 +856,7 @@ mod tests {
sessions: vec![generated, settled],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -759,6 +889,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -785,6 +916,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
@@ -816,6 +948,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -855,6 +988,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height)
@@ -883,6 +1017,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let on_selection = |fg: Color| {
@@ -922,6 +1057,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -941,6 +1077,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
@@ -969,6 +1106,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -998,6 +1136,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1025,6 +1164,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1053,6 +1193,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 42, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1086,6 +1227,7 @@ mod tests {
sessions: vec![legacy],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 40, 8));
assert!(
@@ -1123,11 +1265,13 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
};
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 6));
assert!(
@@ -1143,6 +1287,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(
@@ -1164,6 +1309,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 9, 6));
assert!(
@@ -1190,6 +1336,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1214,6 +1361,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1235,6 +1383,7 @@ mod tests {
sessions: vec![highlighted],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height {
@@ -1260,6 +1409,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
@@ -1295,6 +1445,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height)
@@ -1314,6 +1465,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
},
28,
8,
@@ -1334,6 +1486,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
@@ -1352,6 +1505,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
@@ -1369,6 +1523,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash");
@@ -1388,6 +1543,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
+16
View File
@@ -63,6 +63,11 @@ pub struct AppState {
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<SidebarItem>,
/// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because
/// the sidebar highlight is on it, not because a tab holds it. It never
/// enters `tabs`, is never reported to the daemon as focused, and is
/// dropped the moment the highlight moves on.
pub preview: Option<Uuid>,
/// Group paths whose cards are hidden. This is UI state rather than
/// catalog state, so it survives every catalog redraw.
pub collapsed_sidebar_groups: HashSet<PathBuf>,
@@ -91,6 +96,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
preview: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
@@ -126,6 +132,9 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
// Committing to what was previewed: the pane owns it now, so the
// preview has nothing left to say.
self.preview = None;
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -150,6 +159,13 @@ impl AppState {
self.active_tab().session()
}
/// What the pane actually draws: the preview when one is up, otherwise the
/// active tab's session. A preview covers the pane without replacing it,
/// so ending one gives back exactly what was there (`docs/PREVIEW.md`).
pub fn displayed_session(&self) -> Option<Uuid> {
self.preview.or_else(|| self.active_session())
}
/// Move the sidebar highlight one step through the rendered item
/// sequence, in the direction of `delta` (positive = down), landing only
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
+479
View File
@@ -0,0 +1,479 @@
# COMMAND-BAR.md — the command bar contract
One rule for the `ctrl-k` command bar in both clients, so the web bar and the
TUI bar open the same way, rank the same results in the same order, and act on
the same keys. Read this before changing either. Both clients implement exactly
what is written here; if the behaviour needs to change, change it here first.
The bar is one surface with three jobs: **go to a session**, **run a command**,
**spawn into a directory**. It is not a new place to configure anything and it
does not own any action — it chooses one and hands off to the flow that already
implements it.
## Opening
`ctrl-k` opens the bar from anywhere:
| Client | From | Notes |
| --- | --- | --- |
| TUI | Normal mode | Sidebar has the keyboard. |
| TUI | Insert mode | The pane has the keyboard; the bar still opens. |
| Web | Sidebar focused | Ordinary global binding. |
| Web | Terminal focused | Intercepted before xterm sees it. |
It is a **keymap entry like any other**, not a hard-wired key. In the TUI it is
bound in both `DEFAULT_NORMAL` and `DEFAULT_INSERT` and is rebindable through
`[tui.keymap.*]`; because the help overlay builds its lines from the live
keymap, it appears there without a second list to maintain. In the web it is an
entry in the command registry, so the `?` overlay lists it for the same reason.
An attached xterm swallows every keystroke it is given, so the web binding is
handled in the **capture phase**, ahead of the terminal — the same way the
restart overlay swallows the app's shortcuts (`docs/RESTART.md`).
### Escape hatch — sending a literal Ctrl-K
Binding `ctrl-k` takes readline's kill-to-end-of-line away from every pane. The
leader gives it back: **leader then `ctrl-k`** — `ctrl-a ctrl-k` by default —
sends the literal `0x0B` byte to the focused pane and does not open the bar.
The TUI already has a leader (`ctrl-a`, `[tui].leader`), and this chord goes
through the existing chord resolution, so rebinding the leader rebinds the
escape hatch with it — exactly as the leader's Insert-mode escape already
works. **The web gains a leader for this**, defaulting to `ctrl-a` and
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.
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
none of them needs one.
### Focus round-trip
Closing the bar returns the keyboard to whatever held it when the bar opened,
the terminal included. A round trip through the bar never silently moves focus,
and never counts as a focus change for the daemon: opening the bar does not
report the pane unfocused, and closing it does not report a new focus. The bar
is a view over the app, not a place in it.
Opening the bar while a session is previewed (`docs/PREVIEW.md`) discards the
preview first, like any other real change; the bar is not previewed into.
## Structure
The bar is a query line over a result list. By default the list is **flat and
mixed** — sessions and commands ranked together — and rendered under **kind
headers**: a header row naming the kind, then that kind's results.
Grouping is a rendering of the ranking, never a reordering of it:
- Results are ranked as one list by the algorithm below.
- A kind's group holds its matches in that rank order.
- **Groups are ordered by their best-scoring member.** The first row under the
first header is therefore the globally best result, and the highlight starts
on it.
Two sigils, typed as the **first character** of the query, narrow the list to
one kind:
| Sigil | Kind | Candidates | Enter |
| --- | --- | --- | --- |
| `>` | commands | Every entry in the client's command registry — the web registry from the `?` overlay, the TUI's live `(chord, Action)` keymap for the current mode. | Runs the command. |
| `~` | directories | Recent dirs (`GET /api/recent-dirs`) and the project list the spawn form already uses, with `GET /api/dirs` filling in as a path is typed. | Spawns a session there with every default resolved. |
While a sigil is in force there is one kind, so the kind header is dropped. A
command row shows its **key chord** beside its label, so the bar teaches the
keymap instead of replacing it. A `~` spawn takes the same fast-forward path as
`N` / `FastForwardSpawn`: an unresolvable default opens the pre-filled spawn
form rather than failing silently.
**Backspacing past a sigil returns to the mixed list.** The sigil is one
character of the query like any other — deleting it is not a special key, it is
the query becoming empty again.
The sigil is only a sigil in first position. `>` or `~` typed anywhere else is
a literal query character (a directory query is where a `~` is most likely to
be typed twice, and the second one must reach the matcher).
## Empty query
With no sigil and an empty query the bar lists **sessions only, most recently
focused first**, so `ctrl-k enter` is "back to where I just was". Commands and
directories need a sigil or a query to appear; an empty bar is a session
switcher.
**Recency comes from the daemon**, not from client state — the same order in
every client, on every device, including one that just loaded the page. The
daemon stamps a last-focused time per session and ships it on the session like
any other field (#145). Both of the ways focus moves stamp the same field: a
client opening `/api/attach/:id`, and a client reporting focus explicitly at
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
**A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live
ones regardless of recency: a session you cannot attach to is not where you
just were.
Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list.
## Keys
| Key | Action |
| --- | --- |
| `enter` | Open the highlighted result — attach the session, run the command, spawn into the directory. |
| `tab` | Drill into the highlighted **session**'s action page. No effect on any other kind. |
| `esc` | At the top level, close the bar. On an action page, return to the result list. |
| `backspace` | Ordinary editing; at an empty query on an action page, return to the result list. |
| `up` / `down` | Move the highlight. |
| `ctrl-p` / `ctrl-n` | Move the highlight. |
The highlight does not wrap: it stops at the first and last row. Kind headers
are never landed on — they are labels, not rows.
Enter on a session goes through the **same open path** the sidebar's click and
the hash router already share, so a bar attach can never leave the layout in a
state a click could not produce.
### Drill-in — the session action page
`tab` on a highlighted session opens that session's action page inside the same
overlay: **kill**, **rename**, **spawn similar**. The page is still fuzzy
searchable — the query line keeps working, now over those actions.
- The overlay names the session being acted on, so the target is never
ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
existing prompt pre-filled with the session's own name
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
session's directory and command. The bar picks the action; it never
reimplements one.
No actions beyond those three, and no action across more than one session.
## Touch entry
On the web, **tapping or focusing the sidebar's search field opens the bar**
with an empty query. There is no `ctrl-k` on a phone and the product is
mobile-first, so the field the operator already reaches for is the entry point.
This **replaces** the sidebar's inline substring filter — one search affordance
in the header, not two beside each other. The tree's "No matches" empty state
belonged to that filter and goes with it; the bar's own no-results state takes
over (see the fixture `F4`).
At phone width the bar follows the bottom-sheet treatment the spawn modal
already uses below 600px. Visuals otherwise come from `DESIGN.md`'s existing
overlay tokens — the bar is the same family of surface as the spawn modal and
the help overlay, not a new one.
## What a candidate is
The matcher is a pure function: `(query, candidates) → ranked subset with
matched ranges`. It knows nothing about sessions, panes or the network. A
candidate is a **kind** plus a set of **named text fields**, each with a fixed
weight.
| Kind | Field | Weight | Text |
| --- | --- | --- | --- |
| session | `name` | `0` | The name as the sidebar shows it: `metadata.name` if set, else the command line (`docs/SIDEBAR.md`'s fallback). |
| session | `directory` | `-24` | The directory the sidebar groups it under, abbreviated: `~/dev/api`, not `/home/me/dev/api`. |
| session | `command` | `-32` | The full command line, arguments included. |
| session | `tool` | `-40` | The tool id the card's icon stands for (`claude`, `codex`, `opencode`, …), absent when the program is not one we have a mark for. |
| command | `label` | `0` | The plain-language label — the web registry's label, the TUI's `Action::help_label`. |
| directory | `path` | `0` | The path as displayed, abbreviated the same way. |
A command's **key chord is displayed, not matched**. It is there to teach the
keymap; matching it would make `>` results depend on a user's rebinds, and the
two clients do not share a keymap.
A candidate **matches** if at least one of its fields matches. Its score is the
**best** `field score + field weight` across its fields, and the matched ranges
it returns are those of that winning field — so a session found through its
directory highlights inside the directory, not inside its name.
## Match and rank
Two independent implementations (#146 in TypeScript, #147 in Rust) must produce
the same order for the same input. Everything below is exact.
### Normalization
- **Query**: lowercase, then remove every whitespace character. The result is
the *normalized query*. If it is empty, see "Empty query" below.
- **Field text**: matched against its lowercased form. Bonuses are computed
against the **original** text, because case is a signal (`camelCase`) that
lowercasing destroys.
- **Compact form** of a string: lowercase with whitespace removed. Used only
for the exact and prefix tests.
Lowercasing is per-character simple lowercase (`char::to_lowercase` /
`String.prototype.toLowerCase`). Positions are **character** positions —
Unicode scalar values in Rust, UTF-16 code units in TypeScript, which agree for
every character these fields realistically hold; ranges are returned in the
host language's own unit, since they exist to slice that language's string.
### What a match is
Case-insensitive **subsequence**: every character of the normalized query
appears in the lowercased field text, in order, not necessarily adjacent. A
field with no such embedding does not match.
### Word boundaries
A matched character at position `p` earns exactly one character bonus, taking
the first rule that applies:
| Rule | Bonus |
| --- | --- |
| `p == 0` — first character of the field | `+18` |
| The character before it is a **separator**: `/` `\` `-` `_` `.` `:` or a space | `+16` |
| It is uppercase and the character before it is lowercase (a camelCase hump) | `+12` |
| It is a digit and the character before it is not | `+12` |
| Otherwise | `0` |
That is one rule for both shapes the doc has to cover. In a **path**, `/` is a
separator, so the first character of every segment is a word start: `foo` in
`~/dev/foobar` starts a segment and outscores `foo` in `~/src/myfoo`, which
starts nothing. In a **session name**, `-`, `_`, `.` and spaces do the same
work, so `ws` reaches `web-search` more cheaply than `websearch`.
### Score
Let the query characters be matched at positions `p₀ < p₁ < … < pₘ₋₁`, and let
`p₋₁ = -1`. For each matched character `i`, with `gap = pᵢ - pᵢ₋₁ - 1`:
```
contribution(i) = 16 // every matched character
+ bonus(pᵢ) // the table above
+ (gap == 0 ? +8 // adjacent to the previous match
: -min(gap, 12))
```
`gap` for `i = 0` is the number of characters skipped before the first match, so
a match that starts late is penalised exactly like an internal gap, and the cap
is the same one.
The **field score** is the sum of the contributions, plus at most one whole-field
adjustment:
- compact field text **equals** the normalized query → `+64`
- else compact field text **starts with** the normalized query → `+32`
Length is deliberately not in the score — it is a tie-break, so a long field
never loses to a short one that matched worse.
### Which embedding
A query can sit in a field many ways. The one that counts is the
**highest-scoring** embedding; among embeddings that score the same, the
**leftmost** — compare position vectors element by element, smaller wins.
Greedy leftmost-first matching is not sufficient and must not be used: in
`maestro-tui`, `mtu` greedily takes `m₀ a₁ …` and never reaches `tui`. Both
implementations use the obvious dynamic program over
`(query index, text position)`, carrying the best score and its position
vector, since the gap term depends only on the previous matched position.
The whole-field adjustment is constant for a `(query, field)` pair, so adding it
after the dynamic program cannot change which embedding wins.
### Ordering
Sort by score, highest first. Equal scores break in this order:
1. **Kind** — `session`, then `command`, then `directory`.
2. **Length of the matched field text**, shorter first.
3. **The matched field text**, ascending, compared by code point, case
sensitive.
4. **Input order.** The sort is stable, so candidates the caller ordered
deliberately — sessions most recently focused first, commands in registry
order — keep that order when nothing else separates them.
Kind leads because that is the ruling the ambiguous cases need: a session named
`kill` and the `kill` command that score alike put the session first, because
the operator who typed a session's name meant the session.
### Empty query
A normalized query that is empty **matches every candidate**, each with score
`0` and no ranges, in input order. The bar's own rules decide what is in the
candidate list at that point (sessions only at the top level); the matcher does
not filter by kind.
## Fixtures
These are the contract. Both matchers are tested against exactly these cases;
the prose above is the explanation of why they come out this way.
Candidates are written as `kind(fields)`; unlisted fields are absent.
`session(name, dir, command, tool)`. Scores are shown for diagnosis — **the
order is what is being asserted**, and an implementation that ranks correctly
with different absolute numbers has still failed, because the numbers are how
the next fixture stays predictable.
### F1 — empty query keeps input order
```
query: ""
candidates: session("kill", "~/dev/api", "bash")
session("killer-app", "~/dev/killer-app", "claude --resume", "claude")
command("Kill selected session")
expected: session("kill") score 0
session("killer-app") score 0
command("Kill selected session") score 0
```
Every candidate matches, in the order given. No ranges.
### F2 — a session named `kill` against the `kill` command
```
query: "kill"
candidates: (as F1)
expected: session("kill") 178 name
session("killer-app") 146 name
command("Kill selected session") 146 label
```
`kill` wins on the exact bonus. The other two **tie at 146** — both are a
contiguous prefix match — and the kind tie-break puts the session first.
### F3 — a fragment matching mid-segment inside a path
```
query: "foo"
candidates: directory("~/dev/foobar")
directory("~/src/myfoo")
directory("~/foo")
expected: directory("~/foo") 78 ranges [2,3,4]
directory("~/dev/foobar") 74 ranges [6,7,8]
directory("~/src/myfoo") 56 ranges [8,9,10]
```
The first two start a path segment; the third is mid-segment and loses by the
whole boundary bonus. `~/foo` beats `~/dev/foobar` only because less was
skipped before the match.
### F4 — a query matching nothing
```
query: "zzz"
candidates: session("kill", "~/dev/api", "bash")
command("Kill selected session")
expected: (empty)
```
The matcher returns an empty list, never a list of zero-scored candidates. The
bar renders its no-results state.
### F5 — a non-contiguous subsequence
```
query: "mtu"
candidates: session("maestro-tui")
session("maestro-daemon")
session("mtui-scratch")
expected: session("mtui-scratch") 122 ranges [0,1,2]
session("maestro-tui") 91 ranges [0,8,9]
```
`maestro-daemon` has no `u` after its `t` and does not match. `maestro-tui`
does, across a gap — which greedy matching would miss entirely.
### F6 — a name beats a directory
```
query: "api"
candidates: session("api", "~/dev/web", "bash")
session("worker", "~/dev/api", "bash")
expected: session("api") 154 name
session("worker") 50 directory
```
The second session is found through its directory, and highlights there.
### F7 — case-insensitive, tie broken by length
```
query: "api"
candidates: session("API-Gateway")
session("apiary")
expected: session("apiary") 122 name
session("API-Gateway") 122 name
```
Both are prefix matches and score identically. Same kind, so length decides:
6 characters before 11. Input order does not save `API-Gateway`.
### F8 — the tool field
```
query: "claude"
candidates: session("nameless", "~/dev/x", "claude --resume", "claude")
session("clay", "~/dev/y", "bash")
expected: session("nameless") 186 tool
```
`clay` has no `claude` subsequence anywhere and drops out. `nameless` matches
its `tool` exactly (`+64`, weight `-40`, giving 186) which beats the prefix
match on its `command` field (`+32`, weight `-32`, giving 162).
### F9 — a word boundary inside a session name
```
query: "ws"
candidates: session("websearch")
session("web-search")
expected: session("web-search") 71 ranges [0,4]
session("websearch") 56 ranges [0,3]
```
The `s` after `-` earns the boundary bonus; the `s` inside `websearch` earns
nothing.
### F10 — command labels
```
query: "se"
candidates: command("Kill selected session")
command("Create a session")
command("Show help")
expected: command("Show help") 53 ranges [0,6]
command("Kill selected session") 51 ranges [5,6]
command("Create a session") 47 ranges [9,10]
```
`Show help` starts at position 0; the other two start at a word boundary but
later, and pay for the skipped characters.
### F11 — a query against session directories
```
query: "dev"
candidates: session("api", "~/dev/api", "bash")
session("notes", "~/documents/dev-notes", "nvim")
session("scratch", "~/srv/devops", "bash")
expected: session("api") 54 directory, ranges [2,3,4]
session("scratch") 50 directory, ranges [6,7,8]
session("notes") 44 directory, ranges [12,13,14]
```
All three start a path segment. They separate purely on how much was skipped
first, and `notes` hits the `-12` gap cap.
### F12 — exact beats prefix
```
query: "quit"
candidates: command("Quit Maestro")
command("Quit")
expected: command("Quit") 178 label
command("Quit Maestro") 146 label
```
The 32-point difference between the exact and prefix adjustments is the whole
gap; nothing else about the two matches differs.
+102
View File
@@ -0,0 +1,102 @@
# PREVIEW.md — the sidebar preview contract
One rule for what the content pane shows while the operator is only moving
around the sidebar, so the web client and the TUI behave the same. Read this
before changing either. If the behaviour needs to change, change it here first.
## The rule
Highlighting a session in the sidebar shows that session in the content pane,
as if it were focused, **without focusing it and without changing the layout**.
Moving the highlight elsewhere puts back exactly what was on screen before.
A preview is a view, not a state. Nothing about it is durable:
- The layout is byte-for-byte identical before the preview and after it ends.
- Nothing is persisted while a preview is up (the web client's layout is saved
to `localStorage`; a preview never reaches it).
- The daemon is never told the previewed pane is focused, so a preview never
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
- The keyboard stays in the sidebar: the previewed terminal is on screen but
does not take focus, so the next `j`/`k` still walks the list.
## Where the preview is shown
| The previewed session is… | What happens |
|---|---|
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
## How a preview reads
A preview is provisional, so it never wears the accent — the accent is what
says "this is the one you picked". It has a hue of its own, and every surface
involved in the preview carries that same hue, so the pane and the row it came
from read as one thing rather than two unrelated highlights.
Web client (`--preview` and friends in `web/src/styles.css`):
| Surface | While previewing |
|---|---|
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
belong to the pane: the canvas ring and the throwaway tab.
The hue is a palette token, not a fixed colour: a palette whose accent already
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
The TUI marks a preview in text only — the title bar reads `<name> · preview`
— and has no preview colour role yet.
## What ends a preview
- The highlight moves to another session — that session is previewed instead.
- The highlight leaves every session (a folder row, or the list losing the
keyboard, or the pointer leaving the list): the previous view comes back.
- Any real layout change (split, close, new tab, tab switch, drag): the preview
is discarded first, so a throwaway tab is never baked into the arrangement.
## What commits a preview
Choosing the previewed session — clicking or pressing Enter on its row,
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
the list; TUI: the attach binding) — keeps exactly what is on screen. A
throwaway tab becomes a real one; a focus move inside an existing tab stands.
Nothing moves a second time on commit.
## Timing
**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.
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.
## Cost
A previewed session that no pane holds needs a live attach. Both clients keep
it connected after the preview ends — the web client parks it in the connection
pool's LRU, the TUI keeps its emulator until it falls out of the preview
budget — so stepping back onto a row is instant rather than re-fetching
scrollback.
+32
View File
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
selection background. Contrast is a property of the palette (values are RGB)
and is asserted in the palette's tests, not left to the terminal theme.
## Which row is filled
Both clients say every one of these with a **background fill**, never an edge.
A row is usually several of them at once, so they form a ladder: the tier
furthest down wins the paint.
| Tier | Web | TUI |
|---|---|---|
| Alive | 6% accent wash | — (the rail carries it) |
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in
*is* the precedence. Add a tier where it belongs in that run, never by piling
on another class to out-weigh the rule above it.
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
since those are the colours picked for a filled row. `pane_row_bg()` is
blended from the two surfaces rather than shipped as a third one — a third
surface would mean eight more colours hand-picked seventeen times — and the
palette tests assert it stays a visible step from both. The cursor's fill wins
on its own row, so a highlight parked on the session already in the pane is
still visible.
The one edge left in either list is the TUI's `▏` marker, which stands in for
the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
+27 -12
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();
@@ -32,8 +42,10 @@ export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size. */
onMount(): void;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
@@ -54,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 {
@@ -208,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
@@ -309,6 +319,10 @@ export function createAttachConnection(
}
let mounted = false;
// A previewed pane is visible but must not steal the keyboard from the
// sidebar, so mounting can opt out of the focus grab (and out of the one
// the socket does when it finishes connecting).
let focusOnMount = true;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
@@ -387,7 +401,7 @@ export function createAttachConnection(
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted) term.focus();
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
@@ -472,14 +486,15 @@ export function createAttachConnection(
return {
sessionId,
el,
onMount() {
onMount(mountOpts?: { focus?: boolean }) {
mounted = true;
focusOnMount = mountOpts?.focus !== false;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (ws.readyState === WebSocket.OPEN) term.focus();
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
+139 -26
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
@@ -342,6 +388,9 @@ export interface SidebarHandle {
setActive(id: string | null): void;
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
@@ -364,6 +413,11 @@ export interface SidebarOptions {
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
/** The highlight moved onto a session, or off every session (`null`): the
* 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) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -496,6 +550,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -504,6 +562,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
@@ -528,6 +587,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
};
const sessionOf = (key: string | null): string | null =>
key?.startsWith("session:") ? key.slice("session:".length) : null;
const moveNav = (delta: number) => {
const rows = navRows();
if (rows.length === 0) return;
@@ -539,6 +601,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey));
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -563,9 +626,31 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
treeHost.addEventListener("pointerleave", (e) => {
if (e.pointerType === "touch") return;
setHovered(null);
});
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
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));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
@@ -585,24 +670,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.
@@ -610,6 +704,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(
@@ -633,7 +736,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
@@ -661,6 +770,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
+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");
});
});
+309
View File
@@ -0,0 +1,309 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_SELECT_EVENT } from "./catalog.js";
import {
barGroups,
barRows,
closeCommandBar,
isCommandBarOpen,
openCommandBar,
orderSessionsForBar,
sessionBarCandidates,
sessionCandidate,
} from "./commandBar.js";
import { COMMAND_SPECS } 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" }),
];
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
return openCommandBar(over ?? { sessions, homeDir: "/home/me", 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" });
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", 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", onOpenSession });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
});
+381
View File
@@ -0,0 +1,381 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list, opened from the sidebar, closed with Escape.
//
// Sessions are the only kind here (#149); the sigils, the action page and the
// terminal-focus binding land in #150-#152.
import { SESSION_SELECT_EVENT } from "./catalog.js";
import {
matchAndRank,
type CandidateKind,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
/** A matcher candidate that remembers which session it came from, so a result
* can be acted on without a second lookup. The extra property is invisible to
* the matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type BarCandidate = SessionBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup {
kind: CandidateKind;
title: string;
results: BarResult[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
directory: "Directories",
};
// ---- 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));
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups(results: readonly BarResult[]): BarGroup[] {
const groups: BarGroup[] = [];
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(query: string, candidates: readonly BarCandidate[]): BarResult[] {
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 secondary line under a row's name: the field the match was found in when
* that was not the name, so a session found through its directory says so. */
function metaText(result: BarResult): string | 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 row = document.createElement("div");
row.className = "cmdbar-row";
row.setAttribute("role", "option");
row.id = `cmdbar-row-${index}`;
row.dataset.index = String(index);
row.dataset.sessionId = result.candidate.session.id;
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
const name = document.createElement("span");
name.className = "cmdbar-row-name";
const nameText = result.candidate.fields.name;
name.append(
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
);
row.appendChild(name);
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;
}
// ---- 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;
}
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 candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
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");
input.setAttribute("autocomplete", "off");
input.spellcheck = false;
input.placeholder = "Go to a session…";
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;
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");
}
function render(): void {
rows = barRows(input.value, candidates);
// 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)) {
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();
}
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const sessionId = result.candidate.session.id;
close();
openSession(sessionId);
}
function onKeydown(e: KeyboardEvent): void {
switch (e.key) {
case "Escape":
e.preventDefault();
close();
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;
}
+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);
});
});
+393
View File
@@ -0,0 +1,393 @@
// 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: "bar.open",
label: "Open the command bar",
group: "Sessions",
keys: ["ctrl-k"],
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;
}
}
+9 -5
View File
@@ -74,7 +74,7 @@ export class ConnectionPool {
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
@@ -93,9 +93,9 @@ export class ConnectionPool {
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount();
conn.onMount(opts);
} else if (!this.mounted.has(id)) {
conn.onMount();
conn.onMount(opts);
}
this.mounted.add(id);
this.evict();
@@ -114,13 +114,17 @@ export class ConnectionPool {
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
* `focus: false` shows the panes without taking the keyboard (preview).
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
setVisible(
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
opts?: { focus?: boolean },
): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...this.mounted]) {
if (!next.has(id)) this.unmount(id);
}
for (const e of entries) this.mount(e.id, e.pane, e.info);
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
}
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
+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);
});
}
+1
View File
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
"--muted",
"--faint",
"--accent",
"--preview",
"--danger",
"--warn",
];
+107 -31
View File
@@ -6,7 +6,9 @@ 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, isTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
@@ -29,6 +31,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,
@@ -78,6 +81,15 @@ 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.
@@ -104,9 +116,20 @@ shellEl.dataset.route = "catalog";
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
// 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),
link,
onPreview: schedulePreview,
});
const content = document.createElement("section");
content.className = "content";
@@ -184,7 +207,9 @@ function infoFor(sessionId: string) {
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
// not swap the list out for a terminal on the way past a row.
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
@@ -244,11 +269,18 @@ mv = createMultiView({
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
/** Put the multi-view in the content pane, without rendering it. A preview
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
* first re-mount the pane it is about to replace, and that mount takes the
* keyboard away from the list being walked. */
function ensureMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
}
function showMultiView(): void {
ensureMultiView();
mv.render();
updateShellRoute();
}
@@ -340,42 +372,85 @@ 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.
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();
}
});
//
// 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 },
);
// 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 barKeys = new CommandSet(
defineCommands({
"bar.open": () => {
// 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 });
},
}),
);
// 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;
// Opening the bar over a focused terminal is #152; until then the pane's
// hidden textarea keeps its Ctrl+K, like any other typing target.
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
spawnKeys.handle(e);
},
true,
);
// 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`.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
const previewed = mv.previewedSessionId();
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
@@ -386,6 +461,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 {
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 }));
}
+1
View File
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview() {},
focusList() {},
hasListFocus() {
return false;
+193 -13
View File
@@ -11,6 +11,7 @@ import {
addTab,
closeTab,
findLeaf,
freshId,
type Dir,
type Layout,
leaves,
@@ -19,6 +20,7 @@ import {
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.js";
import type { AttachInfo } from "./attach.js";
@@ -57,11 +59,22 @@ export interface MultiView {
/** Rebuild the view from the current layout. */
render: () => void;
focusedSessionId: () => string | null;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
const el = document.createElement("div");
el.className = "mv";
@@ -83,8 +96,119 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -97,12 +221,23 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
pool.setFocus(focusedLeafSession());
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: openSessions(layout),
open: realOpenSessions(),
focused: focusedLeafSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
@@ -168,6 +303,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pane.addEventListener(
"pointerdown",
(e) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
focusLeaf(leaf.id);
// Drag a pane by its title bar (desktop only — no docking on mobile).
if (isMobile) return;
@@ -252,24 +392,31 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
tab.className =
"mv-tab" +
(t.id === layout.activeTabId ? " is-active" : "") +
(isTempTab(t.id) ? " is-preview" : "");
const n = leaves(t.root).filter((l) => l.sessionId).length;
const label = document.createElement("span");
label.textContent = t.title + (n ? ` · ${n}` : "");
tab.appendChild(label);
tab.addEventListener("click", () => {
if (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
render();
}
render();
});
if (layout.tabs.length > 1) {
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
closeTab(layout, t.id);
render();
});
@@ -283,6 +430,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
add.textContent = "+";
add.title = "New tab";
add.addEventListener("click", () => {
dropPreview();
addTab(layout);
render();
});
@@ -292,12 +440,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
dropPreview();
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
@@ -307,6 +457,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function openSession(sessionId: string): void {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -337,6 +493,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -384,13 +545,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
pool.setFocus(null);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null });
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
persist();
return;
}
@@ -408,7 +576,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
@@ -425,18 +598,22 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
{ focus: !preview && !quietRender },
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
dropPreview();
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
@@ -628,6 +805,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
@@ -636,9 +814,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSession,
openSessionBeside,
closeSession,
openSessions: () => openSessions(layout),
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}
+242
View File
@@ -0,0 +1,242 @@
// @vitest-environment happy-dom
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
// content pane without changing the layout the operator built.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
import { serializeLayout } from "./persistence.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
interface Spy {
pool: ConnectionPool;
visible: () => string[];
focusCalls: (string | null)[];
mountedWithFocus: (boolean | undefined)[];
}
function fakePool(): Spy {
let visible: string[] = [];
const focusCalls: (string | null)[] = [];
const mountedWithFocus: (boolean | undefined)[] = [];
const pool = {
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
visible = entries.map((e) => e.id);
mountedWithFocus.push(opts?.focus);
},
setFocus(id: string | null) {
focusCalls.push(id);
},
} as unknown as ConnectionPool;
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview(id: string | null) {
previewRow.id = id;
},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
onLayoutChange,
});
document.body.appendChild(mv.el);
mv.render();
return mv;
}
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
function twoTabs(): Layout {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
layout.activeTabId = second.id;
return layout;
}
describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("brings a session's own tab forward, then puts the layout back", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(spy.visible()).toEqual(["s1"]);
expect(mv.previewedSessionId()).toBe("s1");
mv.previewSession(null);
expect(spy.visible()).toEqual(["s2"]);
expect(mv.previewedSessionId()).toBeNull();
expect(serializeLayout(layout)).toEqual(before);
});
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(previewRow.id).toBe("s1");
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
expect(
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
).toBe("s1");
mv.previewSession(null);
expect(previewRow.id).toBeNull();
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
expect(spy.visible()).toEqual(["s9"]);
expect(layout.tabs).toHaveLength(3);
// The throwaway tab is not something the operator has open.
expect(mv.openSessions().has("s9")).toBe(false);
mv.previewSession(null);
expect(layout.tabs).toHaveLength(2);
expect(serializeLayout(layout)).toEqual(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.previewSession("s1");
mv.previewSession("s8");
expect(spy.visible()).toEqual(["s8"]);
expect(layout.tabs).toHaveLength(3);
mv.previewSession(null);
expect(serializeLayout(layout)).toEqual(before);
});
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.openSession("s9");
expect(mv.previewedSessionId()).toBeNull();
expect(layout.tabs).toHaveLength(3);
expect(mv.openSessions().has("s9")).toBe(true);
expect(spy.visible()).toEqual(["s9"]);
// Nothing the operator had open was evicted to make room.
expect(mv.openSessions().has("s2")).toBe(true);
});
it("never persists, and saves again once the preview is settled", () => {
const layout = twoTabs();
const spy = fakePool();
const saves = vi.fn();
const mv = makeMv(layout, spy.pool, saves);
saves.mockClear();
mv.previewSession("s9");
expect(saves).not.toHaveBeenCalled();
mv.previewSession(null);
expect(saves).toHaveBeenCalled();
});
it("never reports a previewed pane as focused to the daemon", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.focusCalls.length = 0;
mv.previewSession("s1");
mv.previewSession("s9");
expect(spy.focusCalls).toEqual([]);
mv.previewSession(null);
expect(spy.focusCalls).toEqual(["s2"]);
});
it("mounts a preview without taking the keyboard", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.mountedWithFocus.length = 0;
mv.previewSession("s1");
expect(spy.mountedWithFocus).toEqual([false]);
mv.openSession("s1");
expect(spy.mountedWithFocus.at(-1)).toBe(true);
});
it("keeps the tab bar hidden while a lone tab is previewing", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
mv.previewSession("s9");
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
mv.previewSession(null);
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
});
});
+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;
}
+42 -2
View File
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
el: HTMLElement;
handle: ReturnType<typeof renderSidebar>;
} {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
@@ -163,3 +166,40 @@ describe("sidebar keyboard navigation", () => {
expect(highlighted(el)).toBe(`session:${A}`);
});
});
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)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
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)[] = [];
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]);
// 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)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
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: "touch" }));
expect(seen).toEqual([]);
});
});
+236 -30
View File
@@ -87,6 +87,30 @@
--accent-tint: oklch(0.92 0.005 256 / 0.1);
--accent-ring: oklch(0.92 0.005 256 / 0.5);
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
the accent, because the accent is what says "this is the one you picked".
Defined once, off a single hue variable, so a palette whose accent already
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
keeps the light pair here instead of in all eighteen theme blocks. */
--preview-h: 305;
--preview: light-dark(
oklch(0.55 0.16 var(--preview-h)),
oklch(0.78 0.14 var(--preview-h))
);
--preview-ink: light-dark(
oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h))
);
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
the same colour whatever tier the row was already painted. `--accent-fill`
is the same recipe on the accent, so the two tiers differ by hue alone. */
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
--preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6)
);
--live: oklch(0.84 0.17 152);
--live-glow: oklch(0.84 0.17 152 / 0.55);
@@ -256,6 +280,9 @@
--accent-ink: oklch(0.18 0.04 278);
--accent-tint: oklch(0.75 0.125 275 / 0.14);
--accent-ring: oklch(0.75 0.125 275 / 0.55);
/* This accent is already violet, so preview moves to cyan to stay legible
as "not the accent". Set once here: the light blocks below don't touch it. */
--preview-h: 195;
--term-bg: oklch(0.155 0.018 278);
}
:root[data-palette="periwinkle"][data-theme="light"],
@@ -437,6 +464,8 @@
--accent-ink: oklch(0.22 0.05 310);
--accent-tint: oklch(0.78 0.13 310 / 0.14);
--accent-ring: oklch(0.78 0.13 310 / 0.55);
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
--preview-h: 195;
--term-bg: oklch(0.155 0.014 305);
}
:root[data-palette="orchid"][data-theme="light"],
@@ -1064,7 +1093,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 +1104,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,33 +1143,42 @@ 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, the keyboard cursor and
the preview all paint, and easing it puts a visible delay between the input
landing on a row and the row admitting it. `.is-active`'s ring still eases
— being open in a pane is a state, not a response to 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. */
.tree-leaf[data-status="running"] {
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
once, so every qualifier below sits in `:where()`: each rule is worth the
same specificity and the ladder is decided purely by the order they appear
in, latest wins. Add a tier where it belongs in that order, not by piling on
another class to out-weigh the rule above it. Each tier paints a background
and nothing else — the list says all of this with fill, never an edge. */
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
.tree-leaf:where([data-status="running"]) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf:hover {
background: var(--surface-2);
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
which belongs to the row actually open. */
.tree-leaf:where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf[data-status="running"]:hover {
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-leaf:focus-visible {
.tree-leaf:where(:focus-visible) {
outline: none;
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
/* A folder row previews nothing, so its cursor stays a ring. */
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
@@ -1150,17 +1187,26 @@ textarea {
outline: none;
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active,
.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
box-shadow: inset 0 0 0 1px var(--accent-ring);
/* 3. Open in some pane, but not the focused one (issue #33). */
.tree-leaf:where(.is-open) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* 4. The session currently mounted in the content pane. */
.tree-leaf:where(.is-active) {
background: var(--accent-fill);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
/* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional. */
.tree-leaf:where(.is-previewing) {
background: var(--preview-tint);
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
@@ -1377,6 +1423,128 @@ 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;
}
.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;
}
.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. */
@media (max-width: 600px) {
.cmdbar-overlay {
grid-template-rows: 1fr auto;
padding: 0;
}
.cmdbar-panel {
grid-row: 2;
width: 100%;
max-height: 70vh;
border-radius: var(--r-3) var(--r-3) 0 0;
border-bottom: 0;
}
}
/* 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 {
@@ -2883,6 +3051,42 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — the preview hue on
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
reverts when the highlight moves. The hue is deliberately not the accent:
the accent means "this is what you picked", which a preview is not. */
.mv-tab.is-preview {
border-style: dashed;
border-color: var(--preview-ring);
color: var(--preview);
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
content: "preview";
position: absolute;
top: var(--sp-2);
right: var(--sp-3);
z-index: var(--z-sticky);
padding: 1px var(--sp-2);
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--preview-ink);
background: var(--preview);
opacity: 0.85;
pointer-events: none;
}
.mv.mv-previewing .mv-canvas::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--preview-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
/* canvas + welcome */
.mv-canvas {
position: relative;
@@ -2996,6 +3200,15 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
only thing saying the pane is provisional — and so it is never mistaken for
the accent tint of the pane the operator actually focused. It keeps full
strength even though a preview never takes focus. */
.mv-pane.is-preview .attach-header,
.mv-pane.is-preview.is-focused .attach-header {
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
opacity: 1;
}
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
.mv-pane-empty {
flex: 1;
@@ -3085,13 +3298,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* =========================================================================
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
+6
View File
@@ -50,6 +50,12 @@ 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). */