Keybindings — remap fleet + chat actions (~/.cyberine/keybindings.json, T535)
Both Ink TUIs — the fleet hub (cyberine agents / npm run tui, namespace fleet.) and the chat REPL (cyberine chat, namespace chat.) — read remaps from ONE file: $CYBERINEKEYBINDINGS or...
Both Ink TUIs — the fleet hub (cyberine agents / npm run tui, namespace fleet.*) and the chat
REPL (cyberine chat, namespace chat.*) — read remaps from ONE file: $CYBERINE_KEYBINDINGS or
$CYBERINE_HOME/keybindings.json, default ~/.cyberine (same resolution order for both). The file is
optional: when it does not exist the defaults load and cyberine doctor reports PASS; only a file that
exists but cannot be read or parsed warns (k9xcd). A JSON key is either namespaced
("fleet.<id>" / "chat.<id>") or a bare id with no dot, which is back-compat shorthand for
"fleet.<id>" (the 7 fleet ids predate the chat namespace and existing files with e.g. {"quit": ...}
keep working unchanged). If a file sets both a bare id and its namespaced form, the namespaced one wins
and a conflict warning is emitted.
{
"fleet.quit": { "input": "Q" },
"chat.history-search": { "key": { "ctrl": true, "shift": true }, "input": "r" },
"chat.copy-last": { "key": { "ctrl": true }, "input": "y" }
}A binding may be written two ways, and both resolve identically.
Chord string (preferred, k2kc4). Lowercase modifier tokens joined by +, then one key token:
{
"fleet.quit": "ctrl+q",
"chat.history-search": "ctrl+shift+r",
"chat.copy-last": "ctrl+y",
"fleet.focusNext": "shift+tab"
}Modifiers: ctrl, shift, meta (alt / option / opt / cmd / command / super are all
accepted spellings of meta, which is the single flag Ink reports). Order does not matter --
shift+ctrl+r and ctrl+shift+r are the same binding. Named keys: up, down, left, right,
enter (or return), escape (or esc), tab, space, backspace, delete (or del),
pageup, pagedown. Anything else of length one is taken as a literal character, case-sensitive.
Modifiers are parsed from the front, so the key token can itself be a + or a -: ctrl++ and
ctrl+shift+- both parse, and neither can be written as a descriptor object at all.
A chord that does not parse WARNS and leaves that action on its default -- it never throws, and a
typo in the file never takes the TUI down. cyberine doctor and the fleet ? overlay show what is
actually bound.
One asymmetry is deliberate and worth knowing. A chord that omits ctrl/meta asserts they are
NOT held, so "x" does not also fire on Ctrl+X. A chord that omits shift leaves shift UNTESTED,
because for a printable character the terminal folds shift into the character itself and Ink's
shift flag is not reliable across terminals -- this is also why fleet.focusNext (default Tab)
matches Shift+Tab today.
Context-keyed keymap (KEYMAP-V2, kf4zv). A second, additive shape in the SAME file, keyed by
input surface rather than by app. Thirteen contexts exist -- Global, Fleet, List, Picker,
Detail, Pager, Workflow, Diff, Chat, Composer, Editor, Confirm, Help:
{
"bindings": [
{ "context": "List", "bindings": { "k": "select:previous", "up": "select:previous", "ctrl+p": "select:previous" } },
{ "context": "Pager", "bindings": { "q": "pager:close" } }
]
}The map runs chord -> action, which is what lets three different keys name one action without
inventing three ids. Resolution is innermost context wins, with Global always consulted last
as a floor, so a q bound in Pager takes effect only while a pager is focused and falls back to
the global q everywhere else. A context binding that shadows a Global one is legitimate and
merely WARNS -- the same leniency the descriptor loader applies to same-key collisions.
The two loaders read disjoint parts of the file: this one reads only the bindings array, the
legacy loader reads only the top-level action keys, so one file can carry both during the
migration and neither complains about the other's entries. Defaults are DERIVED from the tables
below rather than retyped, so no key changed: fleet.* seeds Fleet, chat.* seeds Chat.
Six component contexts are wired (kesvk, khh09), covering the nine component-level handlers:
| Context | Read by | Notable ids |
|---|---|---|
Pager | RunTranscriptView, TrajectoryReplay, WorkflowMonitor | pager:back / previous / next / first / last |
Workflow | WorkflowMonitor (alongside Pager) | workflow:pause / save / stop / restart-worker / focus-left / focus-right |
List | FleetList, ConversationList | list:previous / next / half-page-down / half-page-up / first / last / enter / expand / toggle / collapse |
Picker | FuzzyPicker | picker:cancel / accept / alt-accept / previous / next / erase |
Detail | RunDetail, WorkerPeek | detail:back / previous / next / peek / stop / stop-worker / toggle-prompt / toggle-tooling / view-diff / toggle-replay / window-earlier / window-later / buffer-top / buffer-tail |
Diff | DiffViewer | diff:back / previous / next / half-page-down / half-page-up / first / last |
Sharing a context is what makes aliases AGREE rather than merely coincide: FleetList and
ConversationList had byte-identical navigation written out twice, and now read one map, so j
cannot come to mean different things in the two of them.
Three spellings differ between contexts ON PURPOSE, and are not bugs to unify:
Pickertakesctrl+p/ctrl+nwherePagertakes barep/n— the picker is a typing surface, and a bare letter there is a query character.Detail'sg/Gmean buffer-top / live-tail, the OPPOSITE of the pager's first / last, becauseWorkerPeek's scroll offset is inverted; itsctrl+u/ctrl+dlikewise move the window earlier / later rather than paging.Diffspells its half-page keys as raw control bytes (\x04/\x15) where the list views usectrl+d/ctrl+u. Which form Ink delivers depends on the terminal, so this was transcribed unchanged rather than "corrected" — renaming it blind could make Ctrl+D stop working.
The registry stays the source of truth after the migration, not just for one commit:
npm run selftest:keymap-tui-guard (in the CI security gate) enforces three invariants ported
from the web dashboard's own guard — no hint literal outside the format-policy owner, every
declared action id actually read by a handler, and no new inline key comparison in a migrated
component. The few sites that genuinely cannot join a shared context yet are named individually in
that smoke's allowlist with the reason, and the smoke also fails on a STALE allowlist entry, so the
exemption cannot quietly widen.
Known Limitations and Deferred Work. A context axis makes a key ADDRESSABLE; it does not give
Ink the exclusivity it lacks. Every mounted active handler still sees every keypress, so each
context still gates its own useInput with the isActive / inputActive ownership the components
already carry — npm run selftest:fleet-key-ownership remains the guard for that, and nothing here
replaces it. The Global, Composer, Editor, Confirm and Help contexts exist but no handler
reads them yet: Fleet and Chat are still served by the legacy descriptor path, and the chat
readline keys in src/tui/hooks/useTextInput.ts still match inline. No component keys remain
inline any more: FleetList's / (open the fuzzy filter) and H (hide a folder header) were the
last two, and km09f moved them onto fleet.filter / fleet.hideFolder -- new fleet.* descriptor
bindings, resolved the same way fleet.inspect already was (a descriptor prop matched with
matchBinding, not a keymap-context lookup), because neither key collides with anything the
List context FleetList also reads. WorkflowMonitor's p/s/x/r/h/l (khh09) resolve through the dedicated Workflow
context above instead of the Pager one it also reads, because its p pauses the run where Pager
binds bare p to previous, and its h/l move pane focus rather than paging or backing out --
one chord cannot carry two actions inside one context, and Detail's existing p/s/x/r
bindings belong to RunDetail/WorkerPeek, which share that context and would have had their own
meanings changed by reusing it. Chord PREFIXES (ctrl+b ctrl+b style multi-key sequences) are
deliberately unsupported.
Descriptor object (original form, still supported). { "input": "<char>" },
{ "key": { "ctrl": true, ... } } (any of ctrl, escape,
tab, upArrow, downArrow, leftArrow, rightArrow, return, backspace, delete, meta,
shift, pageUp, pageDown), or both (both must match). Unlike a chord, an omitted flag here is
simply not tested, so { "input": "x" } DOES also fire on Ctrl+X. An unknown action id, an unknown namespace, a malformed
descriptor, or two different actions in the same namespace bound to the identical key all produce a
warning (never a crash) — run cyberine doctor to see them, or check the ? help overlay in the
fleet hub, which reflects whatever is actually bound.
Fleet actions (fleet.*) — defaults byte-identical to pre-T535:
| Action id | Default | Effect |
|---|---|---|
quit | q | exit the fleet hub |
help | ? | open the keybinding help overlay |
toggleAll | a | toggle live-only vs all (history) runs |
focusNext | Tab | cycle focus between the list and preview panes (list screen) |
inspect | Enter | inspect the selected run — display-only in the help overlay; not yet wired through the registry for real (many context-specific Enter handlers in app.tsx — remapping this one safely is a follow-up, see the code comment above the fleetBindings load in app.tsx) |
stop | x | stop the selected run (or remove a finished one, two-press) |
newSession | n | open the folder picker for a new interactive session |
filter | / | open FleetList's persistent fuzzy filter (km09f; was an inline literal) |
hideFolder | H | on a folder header: hide the folder, persisted (km09f; was an inline literal) |
Chat actions (chat.*, new in T535) — the first 7 below match a PRE-EXISTING hardcoded key
exactly, so registering them changes no default behavior; the last 4 were command-only before T535
(no key at all) and default to unbound ({}) — set a key in keybindings.json to opt in:
| Action id | Default | Effect |
|---|---|---|
interrupt | Esc | while streaming: drop the last queued prompt, or stop the in-flight response |
compact | k | (in the idle-return dialog) summarize + reset history — same as /compact |
clear | x | (in the idle-return dialog) clear the conversation — same as /clear |
mode-cycle | Shift+Tab | cycle permission mode (default → acceptEdits → plan → ...) |
history-search | Ctrl+R | enter/cycle reverse-i-search over prompt history |
help | ? | open the chat shortcuts overlay (only when the input is empty) |
paste-asset | Ctrl+V | paste from the OS clipboard (image → asset chip, text → insert) |
todo | (unbound) | show the persisted todo list — same as /todo |
thinking-toggle | (unbound) | toggle the planner's reasoning-block visibility |
xmode-cycle | (unbound) | cycle execution mode (compete → swarm → research → ...) — same as bare /xmode |
copy-last | (unbound) | copy the last assistant message to the clipboard — same as /copy |
exit | Ctrl+C | exit — once while streaming, twice when idle (kakvx) |
dispatch | Ctrl+B | dispatch the ready TASK when idle, or move the in-flight response to a background run while streaming (kakvx) |
send | Enter | send the composed message (kakvx) |
newline | Shift+Enter | insert a newline instead of sending (kakvx) |
complete | Tab | completion / @-picker (kakvx) |
select-all | Ctrl+Q | select the whole input (kakvx; consumed by the Editor handler, see below) |
delete-word-back | Ctrl+W | delete the word before the cursor (kakvx; Editor handler) |
delete-to-line-start | Ctrl+U | delete to line start, Cmd+Del on macOS (kakvx; Editor handler) |
undo | Ctrl+Z | undo the last input edit (kakvx; Editor handler) |
Pick a key that is not already a printable character you type into the chat input for the unbound actions above — a bare-letter binding fires instead of inserting that letter while composing a message.
The nine ids marked (kakvx) were reference-panel literals before: real keys that a real handler
consumed, but with no action id, so they could not be rebound and their hint text was a second
source of truth nothing kept honest. Their defaults above are byte-identical to the keys those
handlers already used, so declaring them changed no behaviour -- npm run selftest:keymap-chat-registry asserts the rendered panel row-for-row against the pre-change list.
Two rows in the chat shortcuts panel are deliberately NOT bindings and cannot be remapped: the
↑↓ history cluster (two keys, not one descriptor) and Esc Esc clear input (a double-press
SEQUENCE -- chord prefixes are out of scope for KEYMAP-V2). They are declared as fixed labels, the
same literalKey split fleetActions.ts uses on the fleet side.
Known Limitations and Deferred Work. exit and dispatch are read by their handlers today, so
remapping them moves the key and the reference row together. The four Editor-context ids
(select-all, delete-word-back, delete-to-line-start, undo) are declared and RENDERED from the
registry, but the readline handler in src/tui/hooks/useTextInput.ts still matches those keys
inline -- remapping one moves the reference row without moving the handler until kesvk migrates
that hook. send, newline and complete are in the same position.
Wide terminals — the hub measures rows, not the screen (k09p3)
Tabular content tolerates far more width than prose, but not unbounded width. At 200 columns one hub run summary occupied about 146 cells on a single line, the run preview truncated its wrapped...
Fleet list — TASK-first columns, filter, scrolling
The first/primary column is TASK: a one-line summary of what each run is actually doing (extracted from the first step's prompt — the TASK: block for implement-task, or the first prompt line for...