cyberine
UsageRun recipes

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:

ContextRead byNotable ids
PagerRunTranscriptView, TrajectoryReplay, WorkflowMonitorpager:back / previous / next / first / last
WorkflowWorkflowMonitor (alongside Pager)workflow:pause / save / stop / restart-worker / focus-left / focus-right
ListFleetList, ConversationListlist:previous / next / half-page-down / half-page-up / first / last / enter / expand / toggle / collapse
PickerFuzzyPickerpicker:cancel / accept / alt-accept / previous / next / erase
DetailRunDetail, WorkerPeekdetail:back / previous / next / peek / stop / stop-worker / toggle-prompt / toggle-tooling / view-diff / toggle-replay / window-earlier / window-later / buffer-top / buffer-tail
DiffDiffViewerdiff: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:

  • Picker takes ctrl+p / ctrl+n where Pager takes bare p / n — the picker is a typing surface, and a bare letter there is a query character.
  • Detail's g / G mean buffer-top / live-tail, the OPPOSITE of the pager's first / last, because WorkerPeek's scroll offset is inverted; its ctrl+u / ctrl+d likewise move the window earlier / later rather than paging.
  • Diff spells its half-page keys as raw control bytes (\x04 / \x15) where the list views use ctrl+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 idDefaultEffect
quitqexit the fleet hub
help?open the keybinding help overlay
toggleAllatoggle live-only vs all (history) runs
focusNextTabcycle focus between the list and preview panes (list screen)
inspectEnterinspect 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)
stopxstop the selected run (or remove a finished one, two-press)
newSessionnopen the folder picker for a new interactive session
filter/open FleetList's persistent fuzzy filter (km09f; was an inline literal)
hideFolderHon 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 idDefaultEffect
interruptEscwhile streaming: drop the last queued prompt, or stop the in-flight response
compactk(in the idle-return dialog) summarize + reset history — same as /compact
clearx(in the idle-return dialog) clear the conversation — same as /clear
mode-cycleShift+Tabcycle permission mode (default → acceptEdits → plan → ...)
history-searchCtrl+Renter/cycle reverse-i-search over prompt history
help?open the chat shortcuts overlay (only when the input is empty)
paste-assetCtrl+Vpaste 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
exitCtrl+Cexit — once while streaming, twice when idle (kakvx)
dispatchCtrl+Bdispatch the ready TASK when idle, or move the in-flight response to a background run while streaming (kakvx)
sendEntersend the composed message (kakvx)
newlineShift+Enterinsert a newline instead of sending (kakvx)
completeTabcompletion / @-picker (kakvx)
select-allCtrl+Qselect the whole input (kakvx; consumed by the Editor handler, see below)
delete-word-backCtrl+Wdelete the word before the cursor (kakvx; Editor handler)
delete-to-line-startCtrl+Udelete to line start, Cmd+Del on macOS (kakvx; Editor handler)
undoCtrl+Zundo 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.