This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/docs/COMMAND-BAR.md
T
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
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.
2026-09-03 22:08:06 +01:00

21 KiB

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 preventDefaults 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.