command-code 1.82.5 → 1.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,8 +12,15 @@
12
12
  | `cmd.cwd` | `string` | workspace root |
13
13
  | `cmd.session` | `ModSessionApi \| undefined` | persistence seam; undefined until bound |
14
14
  | `cmd.events` | `ModEventBus` | `emit(channel, data?)` / `on(channel, handler)` cross-mod bus |
15
- | `cmd.ui` | `ModUi` | `notify` / `confirm` / `select` / `input` / `setStatus` / `widget` / `refreshWidgets` / `capabilities` |
15
+ | `cmd.ui` | `ModUi` | `notify` / `confirm` / `select` / `input` / `setStatus` / `widget` / `refreshWidgets` / `pane` / `openPane` / `closePane` / `toast` / `copy` / `override` / `setTitle` / `capabilities` |
16
16
  | `cmd.sessions` | `ModSessionControls` | `compact` / `tree` / `leafId` / `navigateTree` / `setLabel` |
17
+ | `cmd.state` | `ModStateApi` | `get` / `set` / `subscribe` - session values that survive a hot reload |
18
+ | `cmd.store` | `ModStoreApi` | `get` / `set` / `delete` - values kept across sessions (`~/.commandcode/mods/.store/<mod>.json`) |
19
+ | `cmd.clock` | `ModClockApi` | `every` / `after` - timers cancelled with the mod |
20
+ | `cmd.model` | `ModModelApi` | `complete` - one prompt on the session's model client |
21
+ | `cmd.prompt` | `ModPromptApi` | `submit` - start a turn of its own once the session is idle |
22
+ | `cmd.process` | `ModProcessApi` | `spawn` - run a process and stream its output as it arrives |
23
+ | `cmd.audio` | `ModAudioApi` | `play` / `bell` - sound files shipped with the mod, and the terminal bell |
17
24
 
18
25
  ## Registration (factory-time; each returns `Disposable`)
19
26
 
@@ -28,6 +35,8 @@
28
35
  | `cmd.addShortcut` | `(keys: string, {description?; handler}) => Disposable` |
29
36
  | `cmd.addSkillsPath` | `(path: string) => Disposable` |
30
37
  | `cmd.on` | `(event: AgentEventType \| 'session_start' \| 'session_shutdown', handler) => Disposable` |
38
+ | `cmd.addAgent` | `(definition: {name; description; systemPrompt; tools?; disallowedTools?; model?; maxTurns?}) => Disposable` |
39
+ | `cmd.expose` | `(name, fn) => Disposable` |
31
40
 
32
41
  What each does:
33
42
 
@@ -38,6 +47,8 @@ What each does:
38
47
  - `cmd.on(event, handler)` - observe any `AgentEvent` plus the host lifecycle events `session_start` / `session_shutdown` (fired when a harness binds the host and when a session switch or dispose replaces it). Handlers are isolated: a throw becomes a `mod_error {hook: 'on:<event>'}` event, never a crash.
39
48
  - `cmd.addProvider(providerModule)` - register a model provider through the same `ProviderModule` seam the built-ins use (id, transport, auth hooks, model list). The host appends mod providers to its set - mods extend the provider set, never replace it.
40
49
  - `cmd.addRenderer(customType, data => lines)` - a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors, `@commandcode/tui` helpers, or raw codes). First registration per type wins across mods.
50
+ - `cmd.addAgent(definition)` - a sub-agent type the model can delegate to through the `task` tool, next to the bundled and on-disk agents. `tools` defaults to `'*'`. An agent name that already exists (bundled, project, user, or another mod's) is skipped with a warning. Read live, so a reload's agents show up on the next turn.
51
+ - `cmd.expose(name, fn)` / `cmd.use(mod, name)` - share a function with other mods and call one another mod shared. `use` answers `undefined` until that mod has loaded and exposed the name, so call it from a hook or command, not only at factory time.
41
52
  - `cmd.addShortcut(keys, {description, handler})` - a keyboard shortcut in the interactive TUI. `keys` is a `modifier+key` chord that **must include Ctrl or Alt** (`alt+r`, `ctrl+r`); a modifier-less or Shift-only chord (`p`, `enter`, `shift+p`) is rejected with a warning, because it would also type into the prompt, and so is a `Ctrl+Shift` chord whose key reaches the terminal as a bare control byte — letters, digits, Enter, Tab, Backspace (`ctrl+shift+p`, `ctrl+shift+enter`) — where the Shift never arrives (arrow / navigation keys keep their modifiers, so `ctrl+shift+up` is fine). `ctrl+m`, `ctrl+i`, `ctrl+h` and `ctrl+[` are rejected too: terminals send them as Enter, Tab, Backspace and Escape. A chord a built-in already owns (`ctrl+c`, `ctrl+o`, an editor key like `ctrl+k`) is rejected the same way — the host's own bindings run first, so it would never fire — and so are the keys the prompt handles itself (`alt+w`, `ctrl+z`, `ctrl+g`, `ctrl+s`, `ctrl+v`, `alt+v`), which stay taken even when rebound. The handler gets the same `{args, ui, cwd, exec}` context a command handler does and may return the same `{message}` / `{prompt}` result. The host dispatches mod shortcuts AFTER its own bindings, so a mod can add keys but never shadow interrupt, exit, mode cycling, or the transcript toggles; `Esc` stays host-owned. First registration per chord wins across mods; `/hotkeys` lists them under **Mods**. Headless hosts never fire shortcuts.
42
53
  - `cmd.addSkillsPath(path)` - an extra skills location (one skill directory, or a directory of skills) for this session's skill discovery. Relative paths resolve against the mod's own file, so a package can point at the `./skills` it ships. Collected at factory time. A package can declare the same thing without code through its manifest - see [What a package ships](./packaging.md#what-a-package-ships).
43
54
 
@@ -50,6 +61,7 @@ What each does:
50
61
  | `cmd.queueMessage` | `({content, deliverAs?: 'steer' \| 'follow-up'}) => void` |
51
62
  | `cmd.sendUserMessage` | `(content, {deliverAs?}) => void` |
52
63
  | `cmd.exec` | `({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}>` |
64
+ | `cmd.process.spawn` | `({command, args?, cwd?, signal?, onOutput: (chunk, stream) => void}) => Promise<{code}>` |
53
65
  | `cmd.setSessionName` / `setModel` / `setEffort` | `(value) => void` (buffered pre-bind) |
54
66
  | `cmd.getSessionName` / `getModel` / `getEffort` | `() => value \| undefined` (undefined pre-bind) |
55
67
  | `cmd.getContextUsage` | `() => {tokens, contextWindow, percent} \| undefined` |
@@ -59,10 +71,19 @@ What each does:
59
71
  | `cmd.getAllTools` / `getActiveTools` | `() => readonly string[]` |
60
72
  | `cmd.getToolInfo` | `() => readonly {name, description, readOnly, active}[]` |
61
73
  | `cmd.setActiveTools` | `(names: readonly string[]) => void` |
74
+ | `cmd.use` | `(mod, name) => fn \| undefined` |
75
+ | `cmd.state.get` / `set` / `subscribe` | `(key) => T \| undefined` / `(key, value) => void` / `(key, listener) => Disposable` |
76
+ | `cmd.store.get` / `set` / `delete` | `(key) => T \| undefined` / `(key, value) => Promise<void>` / `(key) => Promise<void>` |
77
+ | `cmd.clock.every` / `after` | `(ms, callback) => Disposable` |
78
+ | `cmd.model.complete` | `({prompt, system?, model?, maxOutputTokens?, signal?}) => Promise<ModModelCompleteResult>` |
79
+ | `cmd.prompt.submit` | `(text) => void` |
80
+ | `cmd.ui.copy` | `(text) => Promise<ModCopyResult>` |
81
+ | `cmd.audio.play` | `({file, timeoutMs?}) => Promise<ModAudioResult>` |
82
+ | `cmd.audio.bell` | `() => ModAudioResult` |
62
83
 
63
84
  Details:
64
85
 
65
- - `cmd.ui` - `notify` (a `notice` feed row), `confirm` / `select` / `input` (the Interaction question modal in the TUI; deterministic defaults headless: false/undefined), `setStatus`, `widget`, `refreshWidgets`, `setTitle`. The full dialog/status/widget contract is in [UI surface](./ui.md#ui-surface).
86
+ - `cmd.ui` - `notify` (a `notice` feed row), `confirm` / `select` / `input` (the Interaction question modal in the TUI; deterministic defaults headless: false/undefined), `setStatus`, `widget`, `refreshWidgets`, `pane` + `openPane(id, {focus?})` / `closePane`, `toast`, `override`, `setTitle`. The full dialog/status/widget/override contract is in [UI surface](./ui.md#ui-surface).
66
87
  - `cmd.queueMessage({content, deliverAs})` - `steer` lands after the current tool batch (behind any queued user messages in the same poll), `follow-up` only when the run would stop (ahead of continuation nudges, behind queued user input). Queued messages steer a run that is already going; they never START one.
67
88
  - `cmd.sendUserMessage(content, {deliverAs})` - a REAL user message: echoed in the feed like typed input and, when the agent is idle, starting a turn right away. While a run is going, `steer` (default) queues it behind the active turn exactly as typing would, and `follow-up` holds it until the run would stop. Headless there is no submit path, so it falls back to `queueMessage` with the same `deliverAs`.
68
89
  - `cmd.getSessionName()` / `getModel()` / `getEffort()` - the live values behind the setters; `undefined` before a session binds.
@@ -75,14 +96,27 @@ Details:
75
96
  - `cmd.showEntry(customType, data)` - render a custom entry into the live feed through the renderer registered for that type (unrendered types pretty-print as JSON). The TUI wires the sink; headless runs drop entries. Pair with `cmd.session.appendCustomEntry` when the data should also persist.
76
97
  - `cmd.sessions` - live session controls: `compact()`, `tree()` (the session branch tree as stable `{id, label, children}` nodes), `leafId()`, `navigateTree({targetId, summarize?, customInstructions?})`, and `setLabel({targetId, label})`. These read live harness state, so unlike the buffered setters they throw with a clear message when no session is bound yet.
77
98
  - `cmd.exec({command, args})` - run a process through the harness Runtime (args are shell-quoted).
99
+ - `cmd.process.spawn({command, args, onOutput})` - the same command line as `cmd.exec`, but every stdout/stderr chunk reaches `onOutput(chunk, stream)` as the process produces it (`stream` is `'stdout'` or `'stderr'`), so a mod can show progress, tail a log, or react to a line without waiting for the exit. Resolves `{code}` when the process exits; aborting `signal` kills the process tree and rejects. A throwing `onOutput` becomes `mod_error {hook: 'on:process'}` and the process keeps running. Output is not buffered for you: keep what you need from the chunks.
78
100
  - `cmd.getAllTools()` / `cmd.getActiveTools()` / `cmd.setActiveTools(names)` - a mod-managed tool filter: disabled tools vanish from the model's schemas and refuse execution.
79
101
  - `cmd.events` - a tiny cross-mod pub/sub bus (`emit` / `on`).
102
+ - `cmd.state` - values the host holds for the session, namespaced per mod. They survive a hot reload (module variables start over), and `set` repaints every widget and pane, so a drawing that reads state is always current. `subscribe(key, listener)` hears this mod's writes to that key.
103
+ - `cmd.store` - values kept across sessions, one JSON file per mod. `get` is synchronous (the file is read before the factory runs); `set` and `delete` write through.
104
+ - `cmd.clock.every(ms, fn)` / `after(ms, fn)` - timers owned by the mod: cancelled by their `Disposable`, and all of them when the mod reloads. They never hold the process open, and a throwing callback becomes `mod_error` while the timer keeps its schedule.
105
+ - `cmd.model.complete({prompt})` - one text completion on the session's model client, with no transcript and no tools, billed and attributed like the session. It always resolves: `{isAnswered: true, text, model}` or `{isAnswered: false, reason: 'unavailable' | 'error' | 'aborted'}` (unavailable before a session binds).
106
+ - `cmd.prompt.submit(text)` - submit a prompt as if typed: in the TUI it starts a turn once the session is idle and queues behind a running one. Headless, it rides the follow-up queue.
107
+ - `cmd.ui.copy(text)` - write text to the system clipboard with the same helper `/copy` uses. See [Clipboard and sound](./ui.md#clipboard-and-sound).
108
+ - `cmd.audio.play({file})` / `cmd.audio.bell()` - play a sound file shipped with the mod, or ring the terminal bell. See [Clipboard and sound](./ui.md#clipboard-and-sound).
80
109
 
81
110
  ## `ModHooks` (the mutating lifecycle - all optional, all composable)
82
111
 
83
- `transformContext`, `appendSystemPrompt`, `beforeToolCall`, `afterToolCall`, `onTurnStart`, `onTurnEnd`, `shouldStopAfterTurn`, `prepareNextTurn`, `onRunEnd`, `onStop`, plus the host-level `transformInput`, `onSessionStart`, and `onSessionEnd`.
112
+ `transformContext`, `appendSystemPrompt`, `transformSystemPrompt`, `transformMessage`, `transformTextDelta`, `beforeModelRequest`, `beforeToolCall`, `afterToolCall`, `onTurnStart`, `onTurnEnd`, `shouldStopAfterTurn`, `prepareNextTurn`, `onRunEnd`, `onStop`, plus the host-level `transformInput`, `onPermissionRequest`, `onSessionStart`, and `onSessionEnd`.
84
113
 
85
- - `beforeToolCall` returns `{block?, input?, additionalContext?, terminate?}`.
114
+ - `beforeToolCall` returns `{block?, input?, additionalContext?, terminate?, result?}`. `result` answers the call without running the tool.
115
+ - `beforeModelRequest` returns `{model?}` for one request.
116
+ - `transformSystemPrompt` returns the full system prompt to send.
117
+ - `transformMessage` returns the message the transcript keeps.
118
+ - `transformTextDelta` returns the answer chunk to show (synchronously), or `''` to drop or hold it back.
119
+ - `onPermissionRequest` returns `'allow' | 'deny' | undefined`.
86
120
  - `afterToolCall` receives an `isError` param (PostToolUse vs PostToolUseFailure) and returns `{content?, isError?, additionalContext?, terminate?, modState?}`.
87
121
  - `onStop` receives `{state, stopReason, turnNumber, lastAssistantText}` and returns `{continue?, reason?}`.
88
122
  - `transformInput` returns `{action:'continue'} \| {action:'transform', text} \| {action:'handled', message?}`.
@@ -92,7 +126,8 @@ The per-hook contracts, ordering guarantees, and error policy are in [the hook c
92
126
 
93
127
  - `transformInput({text})` intercepts typed user prompts before they reach the model (the mods' **UserPromptSubmit** hook): return `{action: 'transform', text}` to rewrite (handlers chain - the next sees the rewrite), `{action: 'handled', message?}` to consume the prompt entirely (an optional info row renders in its place), or `undefined`/`{action: 'continue'}` to pass through. Only real typed input is intercepted - automated turns, meta messages, image-carrying prompts, and slash commands never route through it; a throwing handler is skipped (`mod_error {hook: 'transformInput'}`), never a swallowed prompt. It lives in `hooks` because it MUTATES what the agent sees.
94
128
  - `onStop({state, stopReason, turnNumber, lastAssistantText})` is the **Stop** hook - return `{continue: true, reason?}` to force a run that would otherwise finish to keep going (the reason rides an automated turn). Fires only on natural completion, any-mod wins, and the loop caps consecutive continuations.
95
- - `onSessionStart({source})` / `onSessionEnd({reason})` are the once-per-session **SessionStart** / **SessionEnd** hooks - fired when the host binds to / tears down a session (`source: 'startup' | 'resume'`, `reason: 'shutdown' | 'replaced'`). Act through the mod's captured `cmd`; they run fire-and-forget (a throw becomes `mod_error`), so a session hook never blocks bind/dispose. Pure observation is also available via `cmd.on('session_start' | 'session_shutdown')` - these named hooks add the typed metadata.
129
+ - `onPermissionRequest({toolName, input, description, isSafetyPrompt})` answers a permission prompt before the person sees it. It fires only where the engine would ask a human, so deny rules, plan mode, and dont-ask have already decided everything they cover. Every hook is asked, and **deny wins**: any `'deny'` answers the prompt, and `'allow'` holds only when no hook denies, so a mod that blocks something cannot be overridden by another that allows it. A throwing hook is skipped (`mod_error`). On a safety-forced prompt (`isSafetyPrompt`: a sensitive write, a risky command) `'deny'` works but `'allow'` is ignored and the person is asked.
130
+ - `onSessionStart({source})` / `onSessionEnd({reason})` are the once-per-session **SessionStart** / **SessionEnd** hooks - fired when the host binds to / tears down a session (`source: 'startup' | 'resume' | 'reload'`, `reason: 'shutdown' | 'replaced' | 'reload'`). `reload` is this mod's own hot reload, in the same session: the old code ends with it and the new code starts with it. Act through the mod's captured `cmd`; they run fire-and-forget (a throw becomes `mod_error`), so a session hook never blocks bind/dispose. Pure observation is also available via `cmd.on('session_start' | 'session_shutdown')` - these named hooks add the typed metadata.
96
131
 
97
132
  ## `on` event types
98
133
 
@@ -22,9 +22,12 @@ run({state, userInput?, config})
22
22
  ◆ onTurnStart (each mod, in order; returns new state)
23
23
  → turn_start {turnNumber}
24
24
  read live permission mode ONCE - pinned for this round
25
+ ◆ beforeModelRequest (each mod; later wins) → the model for THIS request; the
26
+ deferred-tools prompt and tool list below are built for it
25
27
  resolve systemPrompt (string | builder({sessionId, state, permissionMode}))
26
28
  ◆ appendSystemPrompt (each mod; non-empty returns joined with '\n\n', in
27
29
  registration order, and appended AFTER the base prompt)
30
+ ◆ transformSystemPrompt (each mod; the FULL prompt threaded mod→mod)
28
31
  ◆ transformContext (each mod; messages threaded mod→mod; result is EPHEMERAL -
29
32
  used for this call only, never written back to state.messages)
30
33
  prepareForSend (core wire-validity pass: orphan heal, merge, strip meta)
@@ -33,8 +36,14 @@ run({state, userInput?, config})
33
36
  modelClient.complete(...) (streams → text_delta / thinking_* /
34
37
  message_update / continuation_recovery /
35
38
  tool_input_coerced; retry → api_retry)
39
+ ◆ transformTextDelta on every streamed text chunk (each mod, threaded;
40
+ sync), BEFORE its → text_delta; a retry starts the chunk log over
36
41
  → model_request_end {model, usage, stopReason}
37
- → message_end {content}
42
+ ◆ transformTextDelta {delta: '', done: true} (flush; output → text_delta)
43
+ assistant text := the rewritten chunks, joined, per text block
44
+ → message_end {content} (the rewritten content)
45
+ ◆ transformMessage on every message before it is committed
46
+ (user input, assistant reply, tool results, steering, follow-ups)
38
47
  working += assistant message (provider-executed blocks filtered out)
39
48
  if client tool calls:
40
49
  runTools(...) (see tool-dispatch order below)
@@ -73,6 +82,7 @@ phase 2 - execution (sequential, or parallel when config.toolExecution='parallel
73
82
  execution uses the LAST mod's input; a mod may also set `terminate`)
74
83
  block ⇒ → tool_hook_blocked {hookOutput}; the block reason becomes the
75
84
  tool_result text; NO tool_running / tool_completed / tool_errored fires
85
+ result ⇒ the tool does NOT run; the answer is its (successful) output
76
86
  → tool_running {toolCallId, toolName, description}
77
87
  toolRunner.execute({toolName, input: <possibly rewritten>, signal, onUpdate})
78
88
  ◆ afterToolCall (each mod, in order; sees current content, may replace it,
@@ -124,6 +134,32 @@ Fires per tool call, after the permission check passed, before execution.
124
134
  - `terminate: true` - stops the run after this tool batch finishes. ANY-semantics (one hook, on one call, in one batch, is enough). Combines with `block` on the same return value.
125
135
  - `undefined` - no opinion.
126
136
 
137
+ - `result` - answer the call WITHOUT running the tool: this content is the tool's successful output (unlike `block`, which is an error). Later mods' `beforeToolCall` for this call do not run; `afterToolCall` still does, so a post-hook sees the answer like any real result. `block` wins when both are set.
138
+
139
+ ### `beforeModelRequest({state, turnNumber, model}, ctx?) → {model?} | undefined`
140
+
141
+ Fires once per model request, at the start of the round, before the round's prompt, context, and tools are built. The tool list and the deferred-tools prompt are then built for the model the request goes to, so a model that cannot load deferred tools gets every tool inline, the same as picking it with `/model`. A returned `model` sends **this request only** to that model; the next request goes back to the run's model (use `prepareNextTurn` to switch for the rest of the run). Later mods win. A swapped request drops the run's explicit BYOK route, which belongs to the run's model. `model_request_start` / `model_request_end` report the model the request actually went to.
142
+
143
+ The other hooks of the round have not run yet, so a value one of them stores is from the previous round. To route on context size, measure `state.messages` inside `beforeModelRequest` itself.
144
+
145
+ ### `transformSystemPrompt({system, state}, ctx?) → string`
146
+
147
+ Fires once per round, after `appendSystemPrompt`: receives the round's **full** system prompt (base + every append) and returns the prompt to send, so a mod can replace, edit, or drop parts of it. Threaded mod to mod. Returning the input unchanged keeps the structured, cache-friendly sections; any change sends one flat string, so keep the result byte-stable across rounds or the prompt-prefix cache misses every turn.
148
+
149
+ ### `transformMessage({message, state}, ctx?) → AgentMessage`
150
+
151
+ Fires for every message the loop commits: the person's input, each model reply, each tool-result batch, steering and follow-up messages. The returned message is what the transcript stores **and** what every later request sends, which makes it the place for secret redaction and PII masking. Threaded mod to mod. The role and every `tool_use` / `tool_result` id must survive: a rewrite that changes them is rejected with `mod_error {hook: 'transformMessage'}` and the previous message is kept. Live streaming events (`text_delta`, `message_end`) still show the model's raw output; the transcript holds the rewrite. To change what is shown as well, use `transformTextDelta`.
152
+
153
+ ### `transformTextDelta({delta, done, turnNumber, attempt, state}, ctx?) → string`
154
+
155
+ Fires for every chunk of the model's answer text as it streams, **before** the `text_delta` event for it goes out. Return the chunk to show (rewritten or as is), or `''` to drop it. Threaded mod to mod; a dropped chunk stops there, so later mods never see it. What the feed shows and what the transcript keeps are the same text: the committed assistant message (and `message_end`, and the live `message_update`) carries exactly the rewritten chunks, joined, each in the text block it streamed into. Thinking and tool calls are untouched.
156
+
157
+ - **Synchronous and fast.** It runs inside the model stream. A hook that throws, or returns a promise or a non-string, is skipped for that chunk with `mod_error {hook: 'transformTextDelta'}`, and the chunk passes on as the previous mod handed it.
158
+ - **The final flush.** When the response is complete, every mod is called once more with `{delta: '', done: true}`. Return anything you held back (the start of a key split across two chunks) and it streams as one last `text_delta` and lands at the end of the last text block.
159
+ - **Retries.** `attempt` counts this request's attempts from 0. A mid-stream retry throws the abandoned attempt's chunks away (the feed marks the seam with `stream_restart`), so a mod that keeps state across chunks should start over when `turnNumber` or `attempt` changes.
160
+ - **Non-streaming clients.** A response that arrived with no text deltas still goes through the hook, one chunk per text block, with no `text_delta` events invented for it.
161
+ - Runs **before** `transformMessage`, which still sees the (rewritten) assistant message on commit.
162
+
127
163
  ### `afterToolCall({toolCallId, toolName, input, result, isError, state}, ctx?) → {content?, terminate?, additionalContext?, isError?, modState?} | undefined`
128
164
 
129
165
  Fires per tool call after execution (also after an execution error - `result` is then the error text content). `input` is the FINAL input the tool actually ran with (after any `beforeToolCall` rewrites).
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **Experimental.** The `ModApi` is new and still taking shape. Surfaces and signatures can change as we learn from how mods get built - so pin what you ship, and tell us what's missing. Your feedback is what stabilizes it.
6
6
 
7
- Mods are TypeScript packages that let you modify almost any behavior of Command Code. They're far more powerful than the plugin or extension APIs you'll find in other agents - though you can absolutely use one to build a plugin or an extension. Each mod is written against the `ModApi`: a plain TypeScript file that Command Code discovers on disk, loads at startup, and compiles onto its agent loop. One file can add tools the model calls, slash commands, mutating lifecycle hooks, event observers, typed-input interception, custom feed rendering, configurable flags, and model providers. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.
7
+ Mods are TypeScript packages that let you modify almost any behavior of Command Code. They're far more powerful than the plugin or extension APIs you'll find in other agents - though you can absolutely use one to build a plugin or an extension. Each mod is written against the `ModApi`: a plain TypeScript file that Command Code discovers on disk, loads at startup, and compiles onto its agent loop. One file can add tools the model calls, slash commands, mutating lifecycle hooks, event observers, typed-input interception, permission answers, transcript redaction, per-request model routing, panes and widgets with buttons, toasts, sub-agent types, configurable flags, and model providers. Mods hot-reload when you save them. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.
8
8
 
9
9
  A loadable mod IS an `AgentMod` once loaded; the mod host just builds it from a factory file instead of a code import:
10
10
 
@@ -27,6 +27,10 @@ hooks.transformInput ──► typed-prompt
27
27
  addProvider ──► ProviderModule
28
28
  addRenderer ──► custom feed entries
29
29
  queueMessage ──► steering drains
30
+ ui.widget / ui.pane ──► bands, panes, buttons
31
+ (element tree, any host)
32
+ addAgent ──► task tool agent types
33
+ prompt.submit ──► a turn of its own
30
34
  ```
31
35
 
32
36
  The factory receives the API bound as `cmd`. Registration verbs are all `add*` and each returns a `Disposable` - call `.dispose()` to undo exactly that one registration.
@@ -169,11 +173,13 @@ Dot-entries and `node_modules` are never scanned (the package registry lives und
169
173
  {"mods": {"disabled": ["review-guard"]}}
170
174
  ```
171
175
 
176
+ Turn off every installed mod at once with `{"mods": {"disableAll": true}}` (any settings file; a disable wins), or for one session with `cmd --safe-mode`. Both still load the built-in mods, which are Command Code itself; safe mode also skips `--mod` paths.
177
+
172
178
  ---
173
179
 
174
180
  ## The one rule: hooks mutate, `on` observes
175
181
 
176
- - **`cmd.hooks({...})`** is the only place that can change behavior - block a tool (`beforeToolCall`), rewrite a result (`afterToolCall`), add to the prompt (`appendSystemPrompt`), rewrite typed input (`transformInput`), force a finished run to keep going (`onStop`), react to session start/end (`onSessionStart`/`onSessionEnd`), or run post-turn work (`onRunEnd`). Multiple `hooks()` calls compose in registration order.
182
+ - **`cmd.hooks({...})`** is the only place that can change behavior - block, rewrite, or answer a tool call (`beforeToolCall`), rewrite a result (`afterToolCall`), add to or edit the system prompt (`appendSystemPrompt`, `transformSystemPrompt`), redact what the transcript keeps (`transformMessage`), rewrite the answer as it streams (`transformTextDelta`), send one request to another model (`beforeModelRequest`), answer a permission prompt (`onPermissionRequest`), rewrite typed input (`transformInput`), force a finished run to keep going (`onStop`), react to session start/end (`onSessionStart`/`onSessionEnd`), or run post-turn work (`onRunEnd`). Multiple `hooks()` calls compose in registration order.
177
183
  - **`cmd.on(event, ...)`** only observes - it cannot block or rewrite. Handlers are isolated (a throw becomes a `mod_error` event, never a crash).
178
184
 
179
185
  If you find yourself wanting an `on` handler to stop a tool, you want a hook instead. The full mutating surface is in [Hooks and events](./hooks-and-events.md#hooks-and-events); the full registration/live surface is the [ModApi reference](./api.md#mod-api-reference).
@@ -222,6 +228,13 @@ Ask for any of these by name or by what you want:
222
228
  | `custom-entry-renderer.ts` | `addRenderer` + `showEntry` - styled feed rows |
223
229
  | `flags-and-options.ts` | `addFlag` / `getFlag` + `--mod-option` |
224
230
  | `lifecycle-hooks.ts` | `hooks.onStop` (Stop) + `onSessionStart`/`onSessionEnd` + `afterToolCall` `isError` + `on('subagent_start'/'subagent_stop')` |
231
+ | `pane-and-buttons.ts` | `ui.pane` + `ui.widget` + buttons + `cmd.state` + `ui.toast` |
232
+ | `pane-form.ts` | a focused form pane: `input` + `select` + `markdown` elements, `openPane(id, {focus: true})` |
233
+ | `spinner-count.ts` | `ui.override` - a tool-call count beside the spinner, compact shell rows |
234
+ | `redact-secrets.ts` | `hooks.transformMessage` - mask keys before the transcript keeps them |
235
+ | `permission-policy.ts` | `hooks.onPermissionRequest` - answer prompts with a team policy |
236
+ | `model-router.ts` | `hooks.beforeModelRequest` + `cmd.model.complete` |
237
+ | `stream-filter.ts` | `hooks.transformTextDelta` - redact keys as the answer streams + `cmd.process.spawn` |
225
238
  | `kitchen-sink.ts` | every capability in one annotated file |
226
239
 
227
240
  ---
@@ -232,8 +245,10 @@ Ask for any of these by name or by what you want:
232
245
  - **Project mods are trust-gated** like project skills: they load only after the workspace trust prompt, because a mod is arbitrary code. User-scope and `--mod` mods always load. There is no sandbox - install packages you trust. Package installs run npm with `--ignore-scripts` (mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface).
233
246
  - **Print mode loads user-scope and `--mod` mods only**, with the ui bridge degraded to headless defaults (confirm → false, select/input → undefined - never auto-approved; `setStatus`/`widget` render nowhere, and `cmd.ui.capabilities.status` is false). Project mods stay out of headless runs because print never shows a trust prompt; pass `--dangerously-skip-permissions` to opt a repo's own mods into a headless run (CI).
234
247
  - **Mod-queued messages don't echo in the feed** the way typed input does - they land in the transcript and steer the model, but the visible record is the model's response.
235
- - **Rendering is line-based, not component-based.** `cmd.addRenderer` returns styled text lines the host prints as feed rows; mods do not mount React components into the TUI. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.
236
- - **Reload is the `/reload` path.** Mods load once per process; `/reload` restarts the process, which re-discovers and re-imports every mod (jiti caches nothing between loads). There is no in-place hot swap.
248
+ - **Rendering is data, not components.** Widgets, panes, and feed renderers return strings or a small element tree (text, row, box, button, progress, divider, input, select, markdown) that each host draws; mods never mount React components. That keeps a mod host-agnostic, and a crashing render degrades to a one-line warning, never a broken screen.
249
+ - **Mods hot-reload on save.** In the interactive TUI every file-backed mod is watched: saving it re-imports the file and swaps the mod in place. The old code gets `onSessionEnd` with `reason: 'reload'` and every registration it made is disposed, except tools added with `addTool`, which keep the old code until the next session. `cmd.state` is kept, and the new code's `onSessionStart` fires with `source: 'reload'`. A save that throws while loading keeps the old code running, and the feed says why. `/mods reload [name]` does the same on demand; `COMMANDCODE_MODS_HOT_RELOAD=0` turns the watcher off. Tools a mod adds take effect on the next session, because the tool set is fixed per session. Built-in mods never reload.
250
+ - **Mods restyle rows, never the permission prompt.** `cmd.ui.override` covers the spinner, tool rows, user messages, and command output. What a person is asked to approve is always drawn by Command Code, so the permission prompt (and every other approval dialog) is not on the list and never will be.
251
+ - **Permission answers stop at the prompt.** `onPermissionRequest` only sees prompts the engine was about to show you: deny rules, plan mode, and dont-ask always win, and a safety-forced prompt can be denied by a mod but never allowed. In a headless run, where the prompt would otherwise be auto-answered, the mod's answer is used too.
237
252
  - **Session controls stop at the harness surface.** `cmd.sessions` covers what the live harness owns (compact, tree, navigate, labels); creating/switching/forking sessions is host lifecycle, not harness state, and stays with the host UI.
238
253
 
239
254
  Embedders wire the same machinery directly: `createModHost` + `loadMods` + `createHarness({modHost})` (or `createNodeHarnessSession({modHost})`), all exported from `@commandcode/harness`.
@@ -19,6 +19,7 @@ cmd mods remove fun-stuff # by the name `mods list` prints
19
19
  cmd mods enable <name> # enable a mod; -g for user level
20
20
  cmd mods disable <name> # disable a mod; -g for user level
21
21
  cmd mods open # open the mods dir (project); --user for ~
22
+ cmd mods inspect ./some-mod # what it hooks, registers, and reaches (never runs it)
22
23
  ```
23
24
 
24
25
  `cmd mods list` prints one line per mod - name, scope, origin - project mods first, then user, then package-provided:
@@ -146,7 +147,19 @@ A disable in `settings.local.json` has to be removed by hand; no command writes
146
147
 
147
148
  ## Trust and safety
148
149
 
149
- - **There is no sandbox** - a mod is arbitrary code; install packages you trust.
150
+ - **There is no sandbox** - a mod is arbitrary code that runs with your permissions; install packages you trust.
151
+ - **Inspect before you trust** - `cmd mods inspect <path>` reads a mod's source (it never imports it) and lists the hooks it registers, what it adds, and what it can reach: processes, the network, the filesystem, the environment, permission answers, transcript rewrites. It is a lexical scan, so code a mod imports or names it builds at runtime can reach further, and the report says so.
152
+
153
+ ```bash
154
+ $ cmd mods inspect ./permission-policy.ts
155
+ files: 1
156
+ hooks: onPermissionRequest
157
+ registers: none
158
+ reaches: answers permission prompts (onPermissionRequest)
159
+ imports: none
160
+ ```
161
+
162
+ - **Kill switches** - `cmd --safe-mode` loads built-in mods only for one session (no installed mods, no `--mod`); `{"mods": {"disableAll": true}}` in any settings file does the same for every session until removed.
150
163
  - **Project mods are trust-gated** like project skills: they load only after the workspace trust prompt. User-scope and `--mod` mods always load.
151
164
  - **Package installs run npm with `--ignore-scripts`** - mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface.
152
165
  - **Print mode loads user-scope and `--mod` mods only.** Project mods stay out of headless runs because print never shows a trust prompt; pass `--dangerously-skip-permissions` to opt a repo's own mods into a headless run (CI).
@@ -2,17 +2,43 @@
2
2
 
3
3
  # UI surface
4
4
 
5
- Everything a mod can put on screen rides `cmd.ui`, `cmd.addRenderer`, and `cmd.showEntry`. All of it is line-based and host-agnostic: the TUI wires real rendering, headless runs degrade to deterministic defaults, and a crashing renderer becomes a warning - never a broken screen.
5
+ Everything a mod can put on screen rides `cmd.ui`, `cmd.addRenderer`, and `cmd.showEntry`. All of it is data, never components: a mod returns strings or a small element tree, the TUI and the desktop app draw it, headless runs degrade to deterministic defaults, and a crashing render becomes a one-line warning - never a broken screen.
6
6
 
7
7
  ## Notifications and dialogs
8
8
 
9
9
  - `cmd.ui.notify(message, level?)` - a `notice` feed row, styled by `level`: `'info'` (default) is a dim bullet, `'warning'` an amber ⚠, `'error'` a red ⚠. Rendered during a turn (the per-turn feed projector); a notice raised at factory time flushes once the session binds.
10
+ - `cmd.ui.toast(text, {level?, durationMs?})` - a short-lived line above the prompt that clears itself (default 4s). `level` is `info`, `success`, `warning`, or `error`. Where nothing draws toasts (headless), it falls back to a notice row so the message is never lost.
10
11
  - `cmd.ui.confirm({title, message?})` / `cmd.ui.select({title, options})` / `cmd.ui.input({title, placeholder?})` - the Interaction question modal in the TUI. Headless, each resolves its deterministic default: confirm → `false`, select/input → `undefined` - never auto-approved.
11
12
  - `cmd.ui.setTitle(text | null)` - the terminal tab title. The text replaces the session title on the tab (and survives the host's own title re-assertions) until `null` hands the tab back. Headless: no-op.
12
13
 
13
- ## Footer status segments and editor widgets
14
+ ## Clipboard and sound
14
15
 
15
- > **Widgets are not wired into the TUI yet.** `cmd.ui.widget` and `cmd.ui.refreshWidgets` exist on the surface and are safe to call - they never throw and return the documented `Disposable` - but they currently render **nowhere**. The contract below is the intended design; wire-up is pending. `cmd.ui.setStatus` **is** wired and renders today.
16
+ - `cmd.ui.copy(text)` - write `text` to the system clipboard through the same helper `/copy` uses. It never throws: `{isCopied: true}`, or `{isCopied: false, reason}` where `reason` is `headless` (no interactive host), `no-clipboard` (the machine's clipboard is unreachable, for example over SSH without X11), or `error`.
17
+ - `cmd.audio.play({file, timeoutMs?})` - play a sound file with the platform player: `afplay` on macOS, `paplay` then `aplay` on Linux, PowerShell `Media.SoundPlayer` on Windows. A relative `file` resolves against the directory of the mod's own file, so ship the sound next to it; an absolute path works too. It runs through the runtime shell and is killed after `timeoutMs` (default 10s). It never throws: `{isPlayed: true}`, or `{isPlayed: false, reason}` where `reason` is `headless`, `not-found`, `no-player`, `timeout`, or `error`.
18
+ - `cmd.audio.bell()` - ring the terminal bell. Returns `{isPlayed: true}`, or `{isPlayed: false, reason: 'headless'}`.
19
+ - Where they work today. In the desktop app, copy, bell, and play all answer `headless` for now. Over SSH without a display, copy answers `no-clipboard`. On Windows, and on Linux with only ALSA (`aplay`, no PulseAudio or PipeWire), play handles WAV files only. Ship a `.wav` if the mod should sound everywhere.
20
+ - Bad arguments never throw either. A `file` that is not a string answers `error`. A `timeoutMs` that is not a positive whole number falls back to the 10s default. A hot reload stops any sound the old code started.
21
+
22
+ Sound is a side effect, so do not `await` it in a hook unless the turn should wait for it:
23
+
24
+ ```ts
25
+ cmd.hooks({
26
+ onStop: () => {
27
+ void cmd.audio.play({file: './done.wav'});
28
+ return undefined;
29
+ },
30
+ });
31
+ cmd.addCommand({
32
+ name: 'copy-branch',
33
+ handler: async ({exec, ui}) => {
34
+ const {stdout} = await exec({command: 'git', args: ['branch', '--show-current']});
35
+ const copied = await ui.copy(stdout.trim());
36
+ return {message: copied.isCopied ? 'Copied.' : `Not copied: ${copied.reason}`};
37
+ },
38
+ });
39
+ ```
40
+
41
+ ## Footer status segments
16
42
 
17
43
  `cmd.ui.setStatus(text | null)` - a persistent per-mod segment in the TUI footer (under the input panel). One segment per mod: a new call replaces the text, `null` (or whitespace-only text) clears it, and the returned `Disposable` clears it too - but only while it still owns the segment, so a stale handle disposing late can't wipe a newer status. Segments from multiple mods render on one row in load order, and a call from async code repaints on its own. Text is printed verbatim (style it with ansi), collapsed to a single line: newlines and tabs become spaces. Headless: stored, rendered nowhere.
18
44
 
@@ -23,20 +49,46 @@ cmd.hooks({
23
49
  });
24
50
  ```
25
51
 
26
- `cmd.ui.capabilities.status` - whether this host renders footer segments (true in the interactive TUI, false headless). Use it to pick a surface instead of shipping a permanent fallback:
52
+ `cmd.ui.capabilities.status` / `cmd.ui.capabilities.surface` - whether this host renders footer segments, and widgets / panes / toasts (true in the interactive TUI and the desktop app, false headless). Use them to pick a surface instead of shipping a permanent fallback:
27
53
 
28
54
  ```ts
29
55
  if (cmd.ui.capabilities.status) cmd.ui.setStatus(text);
30
56
  else cmd.ui.notify(text);
31
57
  ```
32
58
 
33
- `cmd.ui.widget({placement: 'above-editor' | 'below-editor', render: () => lines})` - a line-based widget the TUI renders around the input panel. Same verbatim-lines contract as `addRenderer` (style with ansi); `render` re-runs on every repaint, and `cmd.ui.refreshWidgets()` requests a repaint after your data changes. A throwing render is skipped (reported as a `mod_error`) so siblings keep rendering. Headless: no-op.
59
+ ## Elements
60
+
61
+ A widget's or pane's `render({width})` returns a list of nodes. A node is a plain string (one line) or one of:
62
+
63
+ | Element | Shape | Draws |
64
+ |---|---|---|
65
+ | text | `{type: 'text', text, color?, bold?, dim?}` | styled text; `color` is `red` `green` `yellow` `blue` `magenta` `cyan` `white` `gray` |
66
+ | row | `{type: 'row', children}` | children on one line, space-separated |
67
+ | box | `{type: 'box', title?, children}` | an optional bold title, then each child on its own line |
68
+ | button | `{type: 'button', label, hotkey?, key?, onPress}` | `[ r: Reset ]`; `hotkey` is one letter or digit, `key` is its identity (defaults to the label) |
69
+ | progress | `{type: 'progress', value, width?, label?}` | `label ██████░░░░ 60%`, `value` 0..1 |
70
+ | divider | `{type: 'divider'}` | a rule across `width` |
71
+ | input | `{type: 'input', key, placeholder?, value?, onSubmit(text)}` | a one-line text field `[ value ]`; editable only while its pane has focus, Enter submits |
72
+ | select | `{type: 'select', key, options: [{label, value}], value?, onSelect(value)}` | `○ Small ◉ Medium ○ Large` with `value` marked; while focused, ←/→ move the highlight and Enter picks |
73
+ | markdown | `{type: 'markdown', text}` | markdown drawn the way the feed draws replies (headings, lists, code, tables); a host without a markdown renderer falls back to plain lines |
74
+
75
+ `width` is the columns the surface draws into, so a mod can size its own layout. Mods still own their styling: a plain string is printed as written, ansi colors and styles included. Other escape sequences (cursor moves, screen clears) and control bytes other than newline and tab are dropped from all mod text, so a mod can never take over the terminal.
76
+
77
+ `input` and `select` need a `key` (their identity for focus and handlers); a button's `key` defaults to its label. Their handlers run in the mod like a hook: a throw becomes `mod_error {hook: 'ui.submit'}` or `{hook: 'ui.select'}`, and the surface repaints after every submit or pick. Keep what they set in `cmd.state` and read it back as `value`, so the field shows it after a repaint or a hot reload. Images and raster graphics are not elements: terminal graphics protocols (kitty, iTerm2, sixel) differ per terminal and do not survive the TUI's line-based redraw.
78
+
79
+ ## Widgets: the band above the prompt
80
+
81
+ `cmd.ui.widget({placement: 'above-editor' | 'below-editor', render})` - lines drawn around the input panel; `above-editor` is the band right above the prompt, `below-editor` sits under it with the status row. `render` re-runs on every repaint; call `cmd.ui.refreshWidgets()` after the data it reads changed (or keep that data in `cmd.state`, whose `set` repaints for you). A throwing render shows a one-line warning and reports a `mod_error` so siblings keep drawing. The returned `Disposable` removes the widget. Headless: stored, drawn nowhere.
34
82
 
35
83
  ```ts
36
84
  let failing = 0;
37
85
  cmd.ui.widget({
38
86
  placement: 'above-editor',
39
- render: () => [failing > 0 ? `✗ ${failing} tests failing` : '✓ tests green'],
87
+ render: () => [
88
+ failing > 0
89
+ ? {type: 'text', text: `✗ ${failing} tests failing`, color: 'red'}
90
+ : {type: 'text', text: '✓ tests green', color: 'green'},
91
+ ],
40
92
  });
41
93
  cmd.on('tool_completed', () => {
42
94
  failing = readFailingCount();
@@ -44,24 +96,144 @@ cmd.on('tool_completed', () => {
44
96
  });
45
97
  ```
46
98
 
99
+ ## Panes
100
+
101
+ `cmd.ui.pane({id, title, render})` - a titled, bordered panel above the prompt that the person opens and closes. Registering does not open it: `cmd.ui.openPane(id)` does (usually from a command), `cmd.ui.closePane(id)` closes it, and so do `/mods open <pane>` / `/mods close <pane>`. While a pane is open, **Alt+&lt;hotkey&gt;** presses its buttons (the pane's last line lists them) and **Alt+0** closes the newest one; typing still goes to the prompt. Hotkeys the prompt already owns (`p`, `v`, `w`, `u`, `m`) are drawn but never pressed by the chord. A pane's id is `<mod>/<id>`, so two mods can both have a `stats` pane; whether it is open survives a hot reload.
102
+
103
+ **Docked on wide terminals.** From 140 columns up, open panes draw in a column to the right of the prompt (about 40% of the width, 40 to 72 columns) instead of stacking above it, and the prompt narrows to fit beside them. The feed above is scrollback, so panes dock beside the prompt, not beside the feed. Below 140 columns they stack above the prompt.
104
+
105
+ ### Focus
106
+
107
+ The prompt keeps the keyboard until a pane takes it, so typing never reaches a pane by accident. **Alt+↑** moves focus into the newest open pane (its border lights up and the prompt steps aside, keeping what you typed), and `cmd.ui.openPane(id, {focus: true})` opens a pane with focus, like a dialog. While a pane has focus:
108
+
109
+ | Key | Does |
110
+ |---|---|
111
+ | Tab / ↓ | next button, input, or select (wraps) |
112
+ | Shift+Tab / ↑ | previous one |
113
+ | Enter | press the button, submit the input, or pick the select's highlighted option |
114
+ | ←/→ | move a select's highlight |
115
+ | typing, Backspace | edit the focused input (it starts from its `value`) |
116
+ | Esc | give the keyboard back to the prompt. The pane stays open, and what you typed in it is still there when it takes focus again. |
117
+ | Alt+0 | close the newest pane (focus returns to the prompt) |
118
+
119
+ Ctrl+C still reaches the prompt's interrupt and exit keys while a pane has focus. Closing the focused pane, or a dialog taking the screen, hands the keyboard back too.
120
+
121
+ ```ts
122
+ cmd.ui.pane({
123
+ id: 'standup',
124
+ title: 'Standup',
125
+ render: () => [
126
+ {type: 'markdown', text: '**Today**: one line, then pick how it is going.'},
127
+ {
128
+ type: 'input',
129
+ key: 'note',
130
+ placeholder: 'what are you working on?',
131
+ value: cmd.state.get<string>('note') ?? '',
132
+ onSubmit: text => cmd.state.set('note', text),
133
+ },
134
+ {
135
+ type: 'select',
136
+ key: 'mood',
137
+ options: [
138
+ {label: 'Great', value: 'great'},
139
+ {label: 'Blocked', value: 'blocked'},
140
+ ],
141
+ value: cmd.state.get<string>('mood'),
142
+ onSelect: value => cmd.state.set('mood', value),
143
+ },
144
+ ],
145
+ });
146
+ cmd.addCommand({
147
+ name: 'standup',
148
+ handler: () => cmd.ui.openPane('standup', {focus: true}),
149
+ });
150
+ ```
151
+
152
+ ```ts
153
+ cmd.ui.pane({
154
+ id: 'calls',
155
+ title: 'Tool calls',
156
+ render: () => [
157
+ {type: 'progress', label: 'budget', value: calls() / 50},
158
+ {
159
+ type: 'button',
160
+ label: 'Reset',
161
+ hotkey: 'r',
162
+ onPress: () => cmd.state.set('calls', 0),
163
+ },
164
+ ],
165
+ });
166
+ cmd.addCommand({name: 'calls', handler: () => cmd.ui.openPane('calls')});
167
+ ```
168
+
169
+ A button's `onPress` runs in the mod like a hook: a throw becomes `mod_error {hook: 'ui.press'}`, and the surface repaints after every press.
170
+
47
171
  ## Custom feed rendering
48
172
 
49
173
  - `cmd.addRenderer(customType, data => lines)` - a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors, `@commandcode/tui` helpers, or raw codes). First registration per type wins across mods.
50
174
  - `cmd.showEntry(customType, data)` - render a custom entry into the live feed through the renderer registered for that type (unrendered types pretty-print as JSON). The TUI wires the sink; headless runs drop entries. Pair with `cmd.session.appendCustomEntry` when the data should also persist.
51
175
 
52
- Rendering is deliberately **line-based, not component-based**: mods return styled text lines, never React components. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.
176
+ ## Restyle built-in rows
53
177
 
54
- ## Headless behavior at a glance
178
+ `cmd.ui.override(component, render)` - redraw a row Command Code draws itself, with the same [elements](#elements) widgets use. `render(props, next)` gets the row's data and returns nodes, or `undefined` to keep the built-in row. The returned `Disposable` removes the override, and a hot reload drops it with every other registration.
55
179
 
56
- | Surface | Interactive TUI | Headless (`-p`) |
180
+ | Component | `props` | What the nodes do |
57
181
  |---|---|---|
58
- | `notify` | notice feed row | printed notice |
59
- | `confirm` | modal | resolves `false` |
60
- | `select` / `input` | modal | resolves `undefined` |
61
- | `setStatus` | footer segment under the input | stored, rendered nowhere |
62
- | `widget` | no-op (wire-up pending) | no-op |
63
- | `setTitle` | terminal tab title | no-op |
64
- | `showEntry` | rendered feed row | dropped |
65
- | `addShortcut` | dispatched after host bindings | never fires |
66
- | `sendUserMessage` | real user turn (queued while busy) | falls back to `queueMessage` |
67
- | `shutdown` | exits after the turn settles | no-op |
182
+ | `spinner` | `{status, elapsedMs, tokens}` | appended after the working status line, on the same line |
183
+ | `tool_row` | `{toolName, displayName, input, status, result?}` | drawn instead of the tool's row; `status` is `pending`, `success`, or `error`, `result` is set once it finished |
184
+ | `user_message` | `{text}` | drawn instead of the person's message |
185
+ | `command_output` | `{message, details?, isError}` | drawn instead of a slash command's result row |
186
+
187
+ ```ts
188
+ // "tool calls: 3" beside the spinner.
189
+ cmd.ui.override('spinner', (_props, next) => [
190
+ {type: 'text', text: `tool calls: ${calls()}`, color: 'cyan'},
191
+ ...(next() ?? []),
192
+ ]);
193
+
194
+ // Shell rows as one line; every other tool keeps its built-in row.
195
+ cmd.ui.override('tool_row', ({toolName, input}) =>
196
+ toolName === 'shell_command' ? [`$ ${input}`] : undefined,
197
+ );
198
+ ```
199
+
200
+ How overrides compose and fail:
201
+
202
+ - **Load order, first answer wins.** Several mods can override the same component. The first mod to return nodes wins; `undefined` passes to the next mod, and past the last one to the built-in row.
203
+ - **`next()` is the rest of the chain.** It returns what the mods loaded after this one drew, or `undefined` when the built-in row would draw. Wrap it to add to another mod's drawing instead of replacing it (`[...mine, ...(next() ?? [])]`). It runs the later mods once, however often you call it.
204
+ - **A broken override falls back.** An override that throws, returns something other than an array or `undefined` (a string, or the Promise of an `async` render), or draws a malformed tree is skipped as if it returned `undefined`. It reports one `mod_error {hook: 'ui.override:<component>'}` until the mod registers it again, so a broken spinner override does not report on every frame.
205
+ - **Rows redraw with the row.** The spinner redraws about 12 times a second; a tool row redraws while it is live and is final once it scrolls into the transcript. An override applies to rows drawn after it registered.
206
+ - **Tool and command rows are a trust surface.** A `tool_row` or `command_output` override changes what the transcript shows for a tool call or a command. Install only mods you trust to show those rows honestly.
207
+ - **Never the permission prompt.** Only the four components above are overridable; any other name throws at registration.
208
+
209
+ ## In the desktop app
210
+
211
+ The desktop app draws the same surface around the chat composer. Mod code still runs only in the app's main process: it renders each widget and pane there at a fixed 80 columns and sends the window plain lines and button ids, never code.
212
+
213
+ - **Widgets**: `above-editor` sits right above the composer, `below-editor` right under it, with the status row below that.
214
+ - **Panes**: an open pane is a card above the composer, titled, with a close button. Buttons are clickable where the mod drew them (`[ r: Reset ]`); Alt+hotkey chords are a TUI thing.
215
+ - **Toasts**: the app's own toasts, titled with the mod's name.
216
+ - **Styling**: ansi bold, dim, and the eight element colors map to the app theme; other escape codes (cursor moves, links, 256-color) are dropped.
217
+
218
+ ## Headless behavior at a glance
219
+
220
+ | Surface | Interactive TUI | Desktop app | Headless (`-p`) |
221
+ |---|---|---|---|
222
+ | `notify` | notice feed row | notice feed row | printed notice |
223
+ | `toast` | line above the prompt, clears itself | app toast, clears itself | notice row |
224
+ | `confirm` | modal | question in the composer | resolves `false` |
225
+ | `select` / `input` | modal | question in the composer | resolves `undefined` |
226
+ | `setStatus` | footer segment under the input | status row under the composer | stored, rendered nowhere |
227
+ | `widget` | band above / below the prompt | band above / below the composer | stored, drawn nowhere |
228
+ | `pane` | bordered panel above the prompt (docked right from 140 columns), Alt+key buttons, Alt+↑ focus | card above the composer, clickable buttons | stored, drawn nowhere |
229
+ | `override` | redraws the spinner / tool / message / command rows | not drawn | stored, drawn nowhere |
230
+ | `setTitle` | terminal tab title | no-op | no-op |
231
+ | `showEntry` | rendered feed row | dropped | dropped |
232
+ | `addShortcut` | dispatched after host bindings | never fires | never fires |
233
+ | `sendUserMessage` | real user turn (queued while busy) | falls back to `queueMessage` | falls back to `queueMessage` |
234
+ | `prompt.submit` | starts a turn when idle, queues when busy | follow-up queue | follow-up queue |
235
+ | `shutdown` | exits after the turn settles | no-op | no-op |
236
+ | `ui.copy` | system clipboard | `{isCopied: false, reason: 'headless'}` | `{isCopied: false, reason: 'headless'}` |
237
+ | `audio.play` / `audio.bell` | platform player / terminal bell | no sound, `reason: 'headless'` | no sound, `reason: 'headless'` |
238
+
239
+ **TUI only for now.** `cmd.ui.override`, `cmd.ui.setTitle`, `cmd.ui.copy`, `cmd.audio`, `cmd.addShortcut`, `cmd.shutdown`, pane focus, and typing into `input` or picking in `select` elements are handled by the interactive TUI only. The desktop app draws an `input` or `select` element as plain text it cannot focus, and a `markdown` element as styled lines.
@@ -27,7 +27,11 @@ Your mod must appear, with no load warnings. If it does not appear, the warning
27
27
  - **Hook** - trigger the behavior it guards (for a `beforeToolCall` blocker, ask for the blocked action and watch the block reason land as the tool result).
28
28
  - **Input interception** - type the pattern `transformInput` matches and confirm the rewrite/consume happened.
29
29
  - **Status** - `cmd.ui.setStatus('text')` must show up as a segment on the row under the input, replace itself on the next call, and disappear on `setStatus(null)`.
30
- - **Widget** - `cmd.ui.widget` / `cmd.ui.refreshWidgets` must return without throwing and hand back a `Disposable`. They render **nothing** today (TUI wire-up pending - see the [UI surface](./ui.md#ui-surface) note), so don't expect an editor widget to appear yet; only verify the calls are safe.
30
+ - **Widget** - an `above-editor` widget draws right above the prompt, a `below-editor` one under it; `cmd.ui.refreshWidgets()` (or a `cmd.state.set`) repaints it.
31
+ - **Pane** - open it (`/mods open <pane>`, or the mod's own command): it draws above the prompt with its buttons' Alt+keys on its last line; `Alt+<key>` presses one, `Alt+0` closes it. For inputs and selects, `Alt+↑` focuses it: Tab through the fields, type, press Enter, and check the pane redraws with the new `value`.
32
+ - **Toast** - `cmd.ui.toast('text')` shows above the prompt and clears itself.
33
+ - **Override** - send a prompt that runs a tool: a `spinner` override shows beside the working line, a `tool_row` override replaces that tool's row. If a row still looks built-in, the override returned `undefined` or threw (look for `mod_error` with `ui.override:` in its hook).
34
+ - **Permission answers** - with an `onPermissionRequest` hook, trigger a call that would prompt: an answered prompt never appears. `cmd mods inspect` lists the hook so reviewers see it too.
31
35
  - **Renderer** - `cmd.showEntry` rows render styled; an unregistered type pretty-prints as JSON.
32
36
 
33
37
  ### Test: block-dangerous-commands guards rm -rf
@@ -42,9 +46,9 @@ Expected result:
42
46
  - Declining blocks the tool - the model sees the block reason and adapts
43
47
  - No `tool_running` fires for the blocked call
44
48
 
45
- ## 4. Iterate with /reload
49
+ ## 4. Iterate: save, and it reloads
46
50
 
47
- Mods load once per process. After editing the file, run `/reload` - it restarts Command Code, resumes the session, and re-discovers and re-imports every mod (jiti caches nothing between loads).
51
+ In the interactive TUI, saving a mod's file reloads it in place, with no restart: the feed says `[mods] ✓ reloaded <name>` (or why it did not). The old code gets `onSessionEnd` with `reason: 'reload'`, then every registration it made is disposed, except tools added with `addTool`. Those keep the old code until the next session. `cmd.state` survives, and `onSessionStart` fires again with `source: 'reload'`. A save that throws while loading leaves the old code running. `/mods reload [name]` reloads on demand. Two things need more than a reload: tools (the tool set is fixed per session, so start a new session or `/reload`) and new mod files (`/reload` re-discovers). `COMMANDCODE_MODS_HOT_RELOAD=0` turns the watcher off.
48
52
 
49
53
  ## 5. Headless check (CI)
50
54