Merge remote-tracking branch 'origin/main' into dominant

This commit is contained in:
Miguel Palhas
2026-09-03 14:06:58 +01:00
11 changed files with 941 additions and 78 deletions
+5
View File
@@ -87,6 +87,11 @@ 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 indicator's states, the blocking overlay, grace and poll timings. Read it
before changing reconnect behaviour on either client. 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 `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 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 daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+61 -26
View File
@@ -87,10 +87,13 @@ pub struct App {
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle, /// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it. /// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>, focused_session: Option<Uuid>,
/// A sidebar highlight waiting out its debounce before it becomes a /// A sidebar highlight waiting for the pane's minimum gap before it
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must /// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
/// not open an attach per row it passes over. /// down must not open an attach per row it passes over.
pending_preview: Option<(Uuid, std::time::Instant)>, 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 /// Sessions attached only because they were previewed, oldest first. Kept
/// connected past the preview so stepping back onto a row is instant, and /// 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. /// bounded so a long walk doesn't leave a socket per session behind.
@@ -125,10 +128,11 @@ struct FrameKey {
/// else changed. /// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1); const CLOCK_REFRESH: Duration = Duration::from_secs(1);
/// How long a highlight has to stand still before the pane previews it /// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't /// highlight to land shows at once; this only bounds how often the pane may
/// attach to every row it crosses, short enough to read as immediate. /// follow after that, so a fast walk down the list attaches to the row it
const PREVIEW_DELAY: Duration = Duration::from_millis(120); /// 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. /// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3; const PREVIEW_BUDGET: usize = 3;
@@ -213,6 +217,7 @@ impl App {
clicks: ClickTracker::default(), clicks: ClickTracker::default(),
focused_session: None, focused_session: None,
pending_preview: None, pending_preview: None,
last_preview_at: None,
preview_lru: Vec::new(), preview_lru: Vec::new(),
last_frame: None, last_frame: None,
last_drawn_at: std::time::Instant::now(), last_drawn_at: std::time::Instant::now(),
@@ -526,9 +531,10 @@ impl App {
} }
/// Recompute what the pane should be previewing. A session highlighted in /// Recompute what the pane should be previewing. A session highlighted in
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a /// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
/// group row, the pane holding the keyboard, or the highlight already /// last swap — immediately, for a highlight landing on a still list.
/// sitting on the attached session — clears the preview at once. /// 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) { fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) { let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
@@ -537,7 +543,12 @@ impl App {
match wanted { match wanted {
Some(sid) if self.state.active_session() != Some(sid) => { Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) { if self.state.preview != Some(sid) {
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY)); let now = std::time::Instant::now();
let due = match self.last_preview_at {
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
_ => now,
};
self.pending_preview = Some((sid, due));
} }
} }
_ => { _ => {
@@ -547,8 +558,8 @@ impl App {
} }
} }
/// The debounce elapsed: show the highlighted session, attaching it if /// The pane is free to follow: show the highlighted session, attaching it
/// nothing already is. /// if nothing already is.
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> { fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let Some((sid, due)) = self.pending_preview else { let Some((sid, due)) = self.pending_preview else {
return Vec::new(); return Vec::new();
@@ -560,6 +571,7 @@ impl App {
if self.state.preview == Some(sid) { if self.state.preview == Some(sid) {
return Vec::new(); return Vec::new();
} }
self.last_preview_at = Some(now);
self.state.preview = Some(sid); self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale. // Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1); self.epoch = self.epoch.wrapping_add(1);
@@ -1671,24 +1683,22 @@ mod tests {
} }
} }
fn after_debounce() -> std::time::Instant { fn after_gap() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1) std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
} }
/// docs/PREVIEW.md: the highlight shows a session in the pane without the /// docs/PREVIEW.md: the highlight shows a session in the pane without the
/// tab taking it on. /// tab taking it on.
#[test] #[test]
fn highlighting_a_session_previews_it_once_the_debounce_elapses() { fn highlighting_a_session_previews_it_without_waiting() {
let a = Uuid::from_u128(1); let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]); let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
// Nothing yet: the highlight has to stand still first. // The list was still, so the pane follows on the next poll — no gap to
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]); // wait out first (docs/PREVIEW.md).
assert_eq!(app.state.preview, None);
assert_eq!( assert_eq!(
app.poll_due_preview_at(after_debounce()), app.poll_due_preview_at(std::time::Instant::now()),
vec![Effect::Attach(a)] vec![Effect::Attach(a)]
); );
assert_eq!(app.state.preview, Some(a)); assert_eq!(app.state.preview, Some(a));
@@ -1697,6 +1707,28 @@ mod tests {
assert_eq!(app.state.active_tab().session(), None); 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 /// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed. /// row it crossed.
#[test] #[test]
@@ -1707,7 +1739,7 @@ mod tests {
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce()); let effects = app.poll_due_preview_at(after_gap());
assert_eq!(effects, vec![Effect::Attach(b)]); assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b)); assert_eq!(app.state.preview, Some(b));
} }
@@ -1723,7 +1755,7 @@ mod tests {
app.state.sidebar_highlight = Some(SidebarItem::Session(b)); app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview(); app.refresh_preview();
app.poll_due_preview_at(after_debounce()); app.poll_due_preview_at(after_gap());
assert_eq!(app.state.preview, Some(b)); assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a)); app.state.sidebar_highlight = Some(SidebarItem::Session(a));
@@ -1743,7 +1775,7 @@ mod tests {
app.state.focus = Focus::Sidebar; app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b)); app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview(); app.refresh_preview();
app.poll_due_preview_at(after_debounce()); app.poll_due_preview_at(after_gap());
app.dispatch(Action::FocusPane(crate::action::Direction::Right)); app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None); assert_eq!(app.state.preview, None);
@@ -1756,7 +1788,7 @@ mod tests {
let a = Uuid::from_u128(1); let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]); let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce()); app.poll_due_preview_at(after_gap());
// Stand in for the attach the effect would have opened. // Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle()); app.emulators.insert(a, fake_handle());
@@ -1775,10 +1807,13 @@ mod tests {
.map(Uuid::from_u128) .map(Uuid::from_u128)
.collect(); .collect();
let mut app = app_with_sessions(&ids); 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 { for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id)); app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview(); app.refresh_preview();
app.poll_due_preview_at(after_debounce()); clock += PREVIEW_GAP + Duration::from_millis(1);
app.poll_due_preview_at(clock);
app.emulators.insert(*id, fake_handle()); app.emulators.insert(*id, fake_handle());
} }
+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.
+12 -8
View File
@@ -77,15 +77,19 @@ Nothing moves a second time on commit.
## Timing ## Timing
A preview follows the highlight closely enough to be usable as a way to look **A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
around, and lags a pointer enough not to flash rows crossed on the way shows it on the next frame, and so does leaving every row. There is no
somewhere else. settling period in front of the first preview, because that period would sit
in front of the one preview the operator actually meant.
| Trigger | Delay | 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
| Keyboard step (`j`/`k`, arrows) | 60 ms | of the sidebar — shows the row it started on, then the row it ended on, and
| Pointer hover (web, mouse only — never touch) | 150 ms | none of the rows in between. So a walk costs a couple of pane mounts rather
| Clearing (highlight left every session) | same delay as the trigger that set it | 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. Touch has no hover, and a tap already opens a session, so touch never previews.
+74 -11
View File
@@ -164,6 +164,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return wrap; 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 s = node.session;
const now = Date.now(); const now = Date.now();
const row = document.createElement("a"); const row = document.createElement("a");
@@ -197,33 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => { similarBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); 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; const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void requestKill(s, name); void requestKill(cur, sessionName(cur));
}); });
row.addEventListener("click", () => { 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) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [ openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) }, { label: "Rename…", onSelect: () => beginRename(row, cur) },
{ {
label: "Spawn similar", label: "Spawn similar",
onSelect: () => 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; 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 * 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 * session may go, and it refuses over a worktree holding unsaved work. Only
@@ -371,7 +416,7 @@ export interface SidebarOptions {
* shell shows it in the content pane without opening it (docs/PREVIEW.md). * 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 * `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */ * keyboard step. */
onPreview?: (sessionId: string | null, source: "key" | "hover") => void; onPreview?: (sessionId: string | null) => void;
} }
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle { export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -555,7 +600,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null; navKey = next.dataset.navKey ?? null;
applyNav(); applyNav();
next.scrollIntoView({ block: "nearest" }); next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key"); opts.onPreview?.(sessionOf(navKey));
}; };
/** Enter: open the highlighted session, or expand the highlighted folder. */ /** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -586,7 +631,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const setHovered = (id: string | null) => { const setHovered = (id: string | null) => {
if (hovered === id) return; if (hovered === id) return;
hovered = id; hovered = id;
opts.onPreview?.(id, "hover"); opts.onPreview?.(id);
}; };
treeHost.addEventListener("pointerover", (e) => { treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return; if (e.pointerType === "touch") return;
@@ -603,7 +648,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.tabIndex = -1; treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once // 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. // it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key")); treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */ /** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => { const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
@@ -643,12 +688,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
e.preventDefault(); e.preventDefault();
}); });
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
const refresh = () => { const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into. // Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return; if (renamingId !== null) return;
const state = store.snapshot(); const state = store.snapshot();
const sessions = [...state.sessions.values()]; const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter); 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(); treeHost.replaceChildren();
if (sessions.length === 0) { if (sessions.length === 0) {
treeHost.appendChild( treeHost.appendChild(
@@ -672,7 +729,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
/* absolute paths are a fine fallback */ /* 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); document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
+105
View File
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
+10 -15
View File
@@ -29,6 +29,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js"; import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js"; import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT, FOCUS_SIDEBAR_EVENT,
@@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog";
// point `mv` is assigned. // point `mv` is assigned.
let mv: MultiView; let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a // Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
// mouse sweep waits long enough that rows crossed on the way somewhere else // — j held down, or the pointer sweeping the whole list — is coalesced so it
// never flash up. The same delay applies to clearing, so the gap between two // costs a couple of pane mounts instead of one per row walked.
// rows doesn't blink the previous view back. const schedulePreview = createPreviewSchedule((sessionId) => {
const PREVIEW_DELAY = { key: 60, hover: 150 } as const; ensureMultiView();
let previewTimer: number | undefined; mv?.previewSession(sessionId);
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void { updateShellRoute();
clearTimeout(previewTimer); });
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
const sidebar = renderSidebar(store, { const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e), onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
@@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash * 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. */ * changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void { function focusSession(sessionId: string): void {
clearTimeout(previewTimer); schedulePreview.cancel();
showMultiView(); showMultiView();
mv.openSession(sessionId); mv.openSession(sessionId);
updateShellRoute(); updateShellRoute();
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
function harness() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let nextHandle = 1;
const emitted: (string | null)[] = [];
const schedule = createPreviewSchedule((id) => emitted.push(id), {
now: () => now,
setTimer: (fn, ms) => {
const h = nextHandle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h) => {
timers.delete(h);
},
});
/** Advance the clock, firing any timer that comes due. */
const advance = (ms: number) => {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
t.fn();
}
}
};
return { schedule, emitted, advance };
}
describe("preview schedule", () => {
it("shows the first preview immediately", () => {
const { schedule, emitted } = harness();
schedule("s1");
expect(emitted).toEqual(["s1"]);
});
it("coalesces a burst into one trailing preview", () => {
const { schedule, emitted, advance } = harness();
// A pointer sweep across the list, or j held down: every row in between
// is walked well inside one gap.
schedule("s1");
schedule("s2");
schedule("s3");
expect(emitted).toEqual(["s1"]);
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s3"]);
});
it("shows the next preview immediately once the gap has passed", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
advance(PREVIEW_MIN_GAP_MS);
schedule("s2");
expect(emitted).toEqual(["s1", "s2"]);
});
it("drops a superseded trailing preview rather than replaying it", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule("s1"); // back where we started inside the same gap
advance(PREVIEW_MIN_GAP_MS);
// s1 is already on screen, so the trailing edge has nothing to do.
expect(emitted).toEqual(["s1"]);
});
it("clears through the same gap, so leaving and re-entering does not blink", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule(null); // pointer crosses the gap between two rows
schedule("s2");
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s2"]);
});
it("cancel drops a pending trailing preview", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule.cancel();
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
*
* The highlight itself is never scheduled — that is a class on a row and lands
* on the same frame as the input. This governs only the pane swap behind it,
* which mounts a terminal and, for a session the connection pool isn't already
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
*
* A plain debounce would put that cost in front of every preview, including the
* one preview the operator actually meant. Instead the first move shows at once
* and the rest of a burst is coalesced: walking a list with `j` held down, or
* sweeping the pointer from the top of the sidebar to the bottom, costs one
* preview at the start and one at the row you stopped on, not one per row.
*/
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
* couple of terminals rather than one per row; short enough that a deliberate
* second move never waits on it. */
export const PREVIEW_MIN_GAP_MS = 120;
export interface PreviewScheduleDeps {
now?: () => number;
setTimer?: (fn: () => void, ms: number) => number;
clearTimer?: (handle: number) => void;
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
minGapMs?: number;
}
export interface PreviewSchedule {
(sessionId: string | null): void;
/** Drop a pending trailing preview without showing it. */
cancel(): void;
}
/**
* Wrap `show` so it fires on the leading edge and at most once per gap.
*
* `show` is called with the session to preview, or `null` to put back whatever
* the operator actually opened.
*/
export function createPreviewSchedule(
show: (sessionId: string | null) => void,
deps: PreviewScheduleDeps = {},
): PreviewSchedule {
const now = deps.now ?? (() => Date.now());
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
let lastShownAt = Number.NEGATIVE_INFINITY;
let shown: string | null | undefined;
let pendingTimer: number | undefined;
let pendingId: string | null = null;
const emit = (id: string | null) => {
// Nothing to do when the burst ended on the view already up: re-showing it
// would tear down and re-mount the pane for no visible change.
if (shown === id) return;
shown = id;
lastShownAt = now();
show(id);
};
const cancelPending = () => {
if (pendingTimer === undefined) return;
clearTimer(pendingTimer);
pendingTimer = undefined;
};
const schedule = ((sessionId: string | null) => {
pendingId = sessionId;
const waited = now() - lastShownAt;
if (waited >= minGap) {
cancelPending();
emit(sessionId);
return;
}
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
pendingTimer = setTimer(() => {
pendingTimer = undefined;
emit(pendingId);
}, minGap - waited);
}) as PreviewSchedule;
schedule.cancel = cancelPending;
return schedule;
}
+8 -11
View File
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
describe("sidebar preview requests (docs/PREVIEW.md)", () => { describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => { it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j"); press(tree(el), "j");
press(tree(el), "j"); press(tree(el), "j");
expect(seen).toEqual([ expect(seen).toEqual([A, B]);
[A, "key"],
[B, "key"],
]);
tree(el).dispatchEvent(new FocusEvent("blur")); tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]); expect(seen.at(-1)).toBe(null);
}); });
it("asks for a preview of the hovered card, and clears it on the way out", () => { it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0]; const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]); expect(seen).toEqual([A]);
// Moving within the same card is not a new preview. // Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1); expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]); expect(seen.at(-1)).toBe(null);
}); });
it("ignores touch, where a tap already opens the session", () => { it("ignores touch, where a tap already opens the session", () => {
+8 -7
View File
@@ -1091,7 +1091,6 @@ textarea {
cursor: pointer; cursor: pointer;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--fs-12); font-size: var(--fs-12);
transition: color var(--dur-fast) var(--ease-out);
} }
.tree-folder-header:hover { .tree-folder-header:hover {
@@ -1103,9 +1102,9 @@ textarea {
text-align: center; text-align: center;
color: var(--faint); color: var(--faint);
font-size: var(--fs-11); font-size: var(--fs-11);
transition: /* The rotation is the collapse animation and stays; the hover colour is
transform var(--dur) var(--ease-out), immediate, like the row's own hover. */
color var(--dur-fast) var(--ease-out); transition: transform var(--dur) var(--ease-out);
} }
.tree-folder-header[aria-expanded="false"] .chevron { .tree-folder-header[aria-expanded="false"] .chevron {
@@ -1142,9 +1141,11 @@ textarea {
border-radius: var(--r-1); border-radius: var(--r-1);
color: var(--ink); color: var(--ink);
position: relative; position: relative;
transition: /* No transition on `background`: it is what hover paints, and easing it puts
background var(--dur-fast) var(--ease-out), a visible delay between the pointer landing on a row and the row admitting
box-shadow var(--dur-fast) var(--ease-out); it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
follows a keyboard walk, not the pointer. */
transition: box-shadow var(--dur-fast) var(--ease-out);
} }
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */ /* "Alive" reads as a faint wash on the row itself — no per-row status icon. */