The TUI has always raised `KillConfirm` before `x` kills anything; the web sent `DELETE /api/sessions/:id` on the first press and only prompted when the daemon refused. The command bar puts both behind the same three keystrokes, so the difference now reads as a bug (#160). The web follows the TUI: a kill is destructive and unattended agents live in these sessions. `killDialog.ts` grows a confirmation that shares the force prompt's shell, names the blockers the card already knows about, and keeps the focus on Cancel. The guarded refusal still opens its own dialog behind it.
493 lines
21 KiB
Markdown
493 lines
21 KiB
Markdown
# 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.
|
|
|
|
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
|
|
`GET /api/info`, and the web binds that instead of its own constant (#159). A
|
|
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
|
|
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
|
|
every client, not the TUI's alone.
|
|
|
|
The leader itself needs the same kind of way back: holding it consumes every
|
|
press, on both clients, even before anything is bound behind it. Pressing it
|
|
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
|
focused pane and clears the pending chord, the way tmux resolves a doubled
|
|
prefix key. This chord is also built from the configured leader, so a
|
|
rebound leader carries its own escape hatch with it.
|
|
|
|
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
|
character is intercepted, so none of them needs one.
|
|
|
|
### 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 through the confirmation
|
|
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
|
(`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.
|