Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0504eb736 | ||
|
|
6075a177f5 | ||
|
|
6f0ac2bf4a | ||
|
|
f443f625a8 | ||
|
|
655b7b790e |
@@ -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
|
||||
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
|
||||
|
||||
+152
-35
@@ -87,10 +87,13 @@ pub struct App {
|
||||
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
||||
/// mouse) is covered without each one remembering to report it.
|
||||
focused_session: Option<Uuid>,
|
||||
/// A sidebar highlight waiting out its debounce before it becomes a
|
||||
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
|
||||
/// not open an attach per row it passes over.
|
||||
/// A sidebar highlight waiting for the pane's minimum gap before it
|
||||
/// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
|
||||
/// down must not open an attach per row it passes over.
|
||||
pending_preview: Option<(Uuid, std::time::Instant)>,
|
||||
/// When the pane last swapped what it previews. `None` until the first
|
||||
/// one, which is why a highlight landing on a still sidebar shows at once.
|
||||
last_preview_at: Option<std::time::Instant>,
|
||||
/// Sessions attached only because they were previewed, oldest first. Kept
|
||||
/// connected past the preview so stepping back onto a row is instant, and
|
||||
/// bounded so a long walk doesn't leave a socket per session behind.
|
||||
@@ -125,10 +128,11 @@ struct FrameKey {
|
||||
/// else changed.
|
||||
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
||||
|
||||
/// How long a highlight has to stand still before the pane previews it
|
||||
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
|
||||
/// attach to every row it crosses, short enough to read as immediate.
|
||||
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
|
||||
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
|
||||
/// highlight to land shows at once; this only bounds how often the pane may
|
||||
/// follow after that, so a fast walk down the list attaches to the row it
|
||||
/// started on and the row it stopped on rather than every row between.
|
||||
const PREVIEW_GAP: Duration = Duration::from_millis(120);
|
||||
|
||||
/// How many preview-only attaches to keep connected behind the current one.
|
||||
const PREVIEW_BUDGET: usize = 3;
|
||||
@@ -213,6 +217,7 @@ impl App {
|
||||
clicks: ClickTracker::default(),
|
||||
focused_session: None,
|
||||
pending_preview: None,
|
||||
last_preview_at: None,
|
||||
preview_lru: Vec::new(),
|
||||
last_frame: None,
|
||||
last_drawn_at: std::time::Instant::now(),
|
||||
@@ -311,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)
|
||||
}
|
||||
@@ -516,9 +531,10 @@ impl App {
|
||||
}
|
||||
|
||||
/// Recompute what the pane should be previewing. A session highlighted in
|
||||
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
|
||||
/// group row, the pane holding the keyboard, or the highlight already
|
||||
/// sitting on the attached session — clears the preview at once.
|
||||
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
|
||||
/// last swap — immediately, for a highlight landing on a still list.
|
||||
/// Anything else — a group row, the pane holding the keyboard, or the
|
||||
/// highlight already sitting on the attached session — clears at once.
|
||||
fn refresh_preview(&mut self) {
|
||||
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
||||
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||
@@ -527,7 +543,12 @@ impl App {
|
||||
match wanted {
|
||||
Some(sid) if self.state.active_session() != Some(sid) => {
|
||||
if self.state.preview != Some(sid) {
|
||||
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
|
||||
let now = std::time::Instant::now();
|
||||
let due = match self.last_preview_at {
|
||||
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
|
||||
_ => now,
|
||||
};
|
||||
self.pending_preview = Some((sid, due));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -537,8 +558,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The debounce elapsed: show the highlighted session, attaching it if
|
||||
/// nothing already is.
|
||||
/// The pane is free to follow: show the highlighted session, attaching it
|
||||
/// if nothing already is.
|
||||
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||
let Some((sid, due)) = self.pending_preview else {
|
||||
return Vec::new();
|
||||
@@ -550,6 +571,7 @@ impl App {
|
||||
if self.state.preview == Some(sid) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.last_preview_at = Some(now);
|
||||
self.state.preview = Some(sid);
|
||||
// Set outside the input path, so nothing else marks the frame stale.
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
@@ -1661,24 +1683,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn after_debounce() -> std::time::Instant {
|
||||
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
|
||||
fn after_gap() -> std::time::Instant {
|
||||
std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
|
||||
}
|
||||
|
||||
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
|
||||
/// tab taking it on.
|
||||
#[test]
|
||||
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
|
||||
fn highlighting_a_session_previews_it_without_waiting() {
|
||||
let a = Uuid::from_u128(1);
|
||||
let mut app = app_with_sessions(&[a]);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
// Nothing yet: the highlight has to stand still first.
|
||||
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
|
||||
assert_eq!(app.state.preview, None);
|
||||
|
||||
// The list was still, so the pane follows on the next poll — no gap to
|
||||
// wait out first (docs/PREVIEW.md).
|
||||
assert_eq!(
|
||||
app.poll_due_preview_at(after_debounce()),
|
||||
app.poll_due_preview_at(std::time::Instant::now()),
|
||||
vec![Effect::Attach(a)]
|
||||
);
|
||||
assert_eq!(app.state.preview, Some(a));
|
||||
@@ -1687,6 +1707,28 @@ mod tests {
|
||||
assert_eq!(app.state.active_tab().session(), None);
|
||||
}
|
||||
|
||||
/// The gap only bounds the *second* swap, so a burst costs two mounts.
|
||||
#[test]
|
||||
fn a_second_move_inside_the_gap_waits_for_it() {
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let mut app = app_with_sessions(&[a, b]);
|
||||
|
||||
app.dispatch(Action::SidebarNext);
|
||||
let now = std::time::Instant::now();
|
||||
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
|
||||
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.poll_due_preview_at(now), vec![]);
|
||||
assert_eq!(app.state.preview, Some(a));
|
||||
|
||||
assert_eq!(
|
||||
app.poll_due_preview_at(after_gap()),
|
||||
vec![Effect::Attach(b)]
|
||||
);
|
||||
assert_eq!(app.state.preview, Some(b));
|
||||
}
|
||||
|
||||
/// A fast walk down the list attaches to where it stopped, not to every
|
||||
/// row it crossed.
|
||||
#[test]
|
||||
@@ -1697,7 +1739,7 @@ mod tests {
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
let effects = app.poll_due_preview_at(after_debounce());
|
||||
let effects = app.poll_due_preview_at(after_gap());
|
||||
assert_eq!(effects, vec![Effect::Attach(b)]);
|
||||
assert_eq!(app.state.preview, Some(b));
|
||||
}
|
||||
@@ -1713,7 +1755,7 @@ mod tests {
|
||||
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||
app.refresh_preview();
|
||||
app.poll_due_preview_at(after_debounce());
|
||||
app.poll_due_preview_at(after_gap());
|
||||
assert_eq!(app.state.preview, Some(b));
|
||||
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
|
||||
@@ -1733,7 +1775,7 @@ mod tests {
|
||||
app.state.focus = Focus::Sidebar;
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||
app.refresh_preview();
|
||||
app.poll_due_preview_at(after_debounce());
|
||||
app.poll_due_preview_at(after_gap());
|
||||
|
||||
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
|
||||
assert_eq!(app.state.preview, None);
|
||||
@@ -1746,7 +1788,7 @@ mod tests {
|
||||
let a = Uuid::from_u128(1);
|
||||
let mut app = app_with_sessions(&[a]);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app.poll_due_preview_at(after_debounce());
|
||||
app.poll_due_preview_at(after_gap());
|
||||
// Stand in for the attach the effect would have opened.
|
||||
app.emulators.insert(a, fake_handle());
|
||||
|
||||
@@ -1765,10 +1807,13 @@ mod tests {
|
||||
.map(Uuid::from_u128)
|
||||
.collect();
|
||||
let mut app = app_with_sessions(&ids);
|
||||
// Each row is previewed in its own gap window, so all of them land.
|
||||
let mut clock = std::time::Instant::now();
|
||||
for id in &ids {
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
||||
app.refresh_preview();
|
||||
app.poll_due_preview_at(after_debounce());
|
||||
clock += PREVIEW_GAP + Duration::from_millis(1);
|
||||
app.poll_due_preview_at(clock);
|
||||
app.emulators.insert(*id, fake_handle());
|
||||
}
|
||||
|
||||
@@ -2522,6 +2567,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(
|
||||
§ion,
|
||||
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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -50,15 +50,19 @@ Nothing moves a second time on commit.
|
||||
|
||||
## Timing
|
||||
|
||||
A preview follows the highlight closely enough to be usable as a way to look
|
||||
around, and lags a pointer enough not to flash rows crossed on the way
|
||||
somewhere else.
|
||||
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
|
||||
shows it on the next frame, and so does leaving every row. There is no
|
||||
settling period in front of the first preview, because that period would sit
|
||||
in front of the one preview the operator actually meant.
|
||||
|
||||
| Trigger | Delay |
|
||||
|---|---|
|
||||
| Keyboard step (`j`/`k`, arrows) | 60 ms |
|
||||
| Pointer hover (web, mouse only — never touch) | 150 ms |
|
||||
| Clearing (highlight left every session) | same delay as the trigger that set it |
|
||||
What is bounded is how *often* the pane may swap: at most once per 120 ms.
|
||||
A burst inside one window — `j` held down, or the pointer sweeping the length
|
||||
of the sidebar — shows the row it started on, then the row it ended on, and
|
||||
none of the rows in between. So a walk costs a couple of pane mounts rather
|
||||
than one per row, without the operator ever waiting on one.
|
||||
|
||||
The highlight itself is not bounded by any of this: the row lights up on the
|
||||
same frame as the input, whatever the pane behind it is doing.
|
||||
|
||||
Touch has no hover, and a tap already opens a session, so touch never previews.
|
||||
|
||||
|
||||
+74
-11
@@ -164,6 +164,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 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(s, name);
|
||||
void requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||
]);
|
||||
});
|
||||
leafPatches.set(row, (next) => {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||
|
||||
/**
|
||||
* Everything about the tree that a patch cannot fix up.
|
||||
*
|
||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||
* signature is unchanged are patched into the standing rows instead; anything
|
||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||
*/
|
||||
function treeSignature(node: TreeNode): string {
|
||||
if (node.kind === "folder") {
|
||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||
if (node.name && collapsed.has(node.path)) return head;
|
||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||
}
|
||||
const s = node.session;
|
||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
@@ -368,7 +413,7 @@ export interface SidebarOptions {
|
||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||
* `source` is there so a mouse sweep can be debounced differently from a
|
||||
* keyboard step. */
|
||||
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
|
||||
onPreview?: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
@@ -547,7 +592,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey), "key");
|
||||
opts.onPreview?.(sessionOf(navKey));
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
@@ -578,7 +623,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
hovered = id;
|
||||
opts.onPreview?.(id, "hover");
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
@@ -595,7 +640,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
treeHost.tabIndex = -1;
|
||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||
|
||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||
@@ -635,12 +680,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
/** 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.
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
if (s) leafPatches.get(row)?.(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
@@ -664,7 +721,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
|
||||
|
||||
@@ -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
@@ -29,6 +29,7 @@ import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
@@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog";
|
||||
// point `mv` is assigned.
|
||||
let mv: MultiView;
|
||||
|
||||
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
|
||||
// mouse sweep waits long enough that rows crossed on the way somewhere else
|
||||
// never flash up. The same delay applies to clearing, so the gap between two
|
||||
// rows doesn't blink the previous view back.
|
||||
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
|
||||
let previewTimer: number | undefined;
|
||||
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
|
||||
clearTimeout(previewTimer);
|
||||
previewTimer = window.setTimeout(() => {
|
||||
ensureMultiView();
|
||||
mv?.previewSession(sessionId);
|
||||
updateShellRoute();
|
||||
}, PREVIEW_DELAY[source]);
|
||||
}
|
||||
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
||||
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
||||
// costs a couple of pane mounts instead of one per row walked.
|
||||
const schedulePreview = createPreviewSchedule((sessionId) => {
|
||||
ensureMultiView();
|
||||
mv?.previewSession(sessionId);
|
||||
updateShellRoute();
|
||||
});
|
||||
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
@@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that session's row silently ignored. */
|
||||
function focusSession(sessionId: string): void {
|
||||
clearTimeout(previewTimer);
|
||||
schedulePreview.cancel();
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
|
||||
|
||||
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
||||
const seen: [string | null, string][] = [];
|
||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "j");
|
||||
expect(seen).toEqual([
|
||||
[A, "key"],
|
||||
[B, "key"],
|
||||
]);
|
||||
expect(seen).toEqual([A, B]);
|
||||
|
||||
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||
expect(seen.at(-1)).toEqual([null, "key"]);
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||
const seen: [string | null, string][] = [];
|
||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
expect(seen).toEqual([[A, "hover"]]);
|
||||
expect(seen).toEqual([A]);
|
||||
// Moving within the same card is not a new preview.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||
expect(seen.at(-1)).toEqual([null, "hover"]);
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("ignores touch, where a tap already opens the session", () => {
|
||||
|
||||
+8
-7
@@ -1064,7 +1064,6 @@ textarea {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header:hover {
|
||||
@@ -1076,9 +1075,9 @@ textarea {
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
transition:
|
||||
transform var(--dur) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
/* The rotation is the collapse animation and stays; the hover colour is
|
||||
immediate, like the row's own hover. */
|
||||
transition: transform var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header[aria-expanded="false"] .chevron {
|
||||
@@ -1115,9 +1114,11 @@ textarea {
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||
a visible delay between the pointer landing on a row and the row admitting
|
||||
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||
follows a keyboard walk, not the pointer. */
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||
|
||||
Reference in New Issue
Block a user