docs(maestro): add the command bar contract
Specifies the ctrl-k command bar for both clients: opening and the leader escape hatch for a literal Ctrl-K, sigils, empty-query recency, the key model including the session action page, and touch entry. The match-and-rank algorithm is pinned exactly — normalization, word boundaries, the per-character score, embedding choice, tie-break — and closed with twelve fixtures the TypeScript (#146) and Rust (#147) matchers are both tested against, so their orders cannot drift.
This commit is contained in:
@@ -87,6 +87,11 @@ or is upgraded under them — the build stamp on `/api/info`, the status
|
|||||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||||
before changing reconnect behaviour on either client.
|
before changing reconnect behaviour on either client.
|
||||||
|
|
||||||
|
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
|
||||||
|
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
|
||||||
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user