zidane 7.0.15 → 7.0.16

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.
package/docs/TUI.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Zidane TUI
2
2
 
3
- Terminal shell for the chat engine. React + OpenTUI on top of `zidane/chat`. Ships as `zidane/tui` — **Bun-only** because of `bun:sqlite` (via the session store) and OpenTUI's native bindings. A non-terminal host (Electron / Tauri / web) imports `zidane/chat` and provides its own renderer; see `docs/CHAT.md`.
3
+ Terminal shell for `zidane/chat`. React + OpenTUI, **Bun-only** (`bun:sqlite` session store + OpenTUI's native bindings). A non-terminal host (Electron/Tauri/web) imports `zidane/chat` directly and provides its own renderer — see [CHAT.md § GUI checklist](./CHAT.md#gui-checklist). This doc owns keybindings, modals, and screens; settings/todos/safe-mode/commands semantics live in [CHAT.md](./CHAT.md), artifacts semantics in [ARTIFACTS.md](./ARTIFACTS.md), daemon/multi-window in [IPC.md](./IPC.md).
4
4
 
5
5
  ```bash
6
6
  bun run build
@@ -13,76 +13,59 @@ import { runTui } from 'zidane/tui'
13
13
  await runTui() // ~/.zidane/sessions.db + state.json
14
14
  await runTui({ prefix: '.myapp' }) // ~/.myapp/...
15
15
  await runTui({ providers: { ...BUILTIN_PROVIDERS } }) // custom registry
16
- await runTui({ agents: { ...BUILTIN_AGENTS, debug: myProfile } })
17
- await runTui({ projectDb: true }) // per-repo `<git-root>/.zidane/sessions.db`
16
+ await runTui({ projectDb: true }) // per-repo <git-root>/.zidane/sessions.db
18
17
  ```
19
18
 
20
- `runTui` is a one-shot launcher: it boots OpenTUI, registers Tree-sitter grammars, resolves config, mounts `<App>`, then `process.exit(0)`s on dismiss (Ctrl+C / Esc on auth). Calling it twice throws — global terminal state can only be hijacked once.
19
+ Contents: [Quick start](#quick-start) · [Composition](#composition) · [Public exports](#public-exports) · [Screens](#screens) · [Keyboard](#keyboard) · [Modals](#modals) · [Theme + code highlighting](#theme--code-highlighting) · [Transcript rendering](#transcript-rendering) · [Settings modal](#settings-modal) · [Completion + slash-commands](#completion--slash-commands) · [MCP servers + OAuth](#mcp-servers--oauth) · [Auth wizard](#auth-wizard-ai-providers) · [Execution runtimes](#execution-runtimes-in-process-vs-daemon) · [Embedding](#embedding) · [Auto-update](#auto-update) · [File layout](#file-layout)
21
20
 
22
- For embedding, mount `<App config={resolveConfig(...)} />` against your own `createCliRenderer()`. For a non-OpenTUI shell, drop `<App>` and compose the `zidane/chat` providers directly (see `docs/CHAT.md` → **GUI checklist**).
21
+ ## Quick start
23
22
 
24
- Env-var overrides: `ZIDANE_STORAGE_DIR`, `ZIDANE_PREFIX`, `ZIDANE_PROJECT_DB`, `ZIDANE_NO_UPDATE` (silences the auto-update check), `ZIDANE_DEBUG` (enables renderer `gatherStats`; dumps fps / frametime to stderr on exit).
23
+ `runTui` is a one-shot launcher: boots OpenTUI, registers Tree-sitter grammars, resolves config, mounts `<App>`, then `process.exit(0)`s on dismiss (Ctrl+C, or Esc on the auth screen). Calling it twice throws.
25
24
 
26
- Renderer fps is a regular user setting (`Settings → Renderer FPS`, `30 / 60 / 120`). The on-disk value seeds the renderer at boot; subsequent flips apply live via `renderer.targetFps` / `renderer.maxFps`. `clampFps` (in `zidane/chat`) guards bounds.
25
+ Env overrides: `ZIDANE_STORAGE_DIR`, `ZIDANE_PREFIX`, `ZIDANE_PROJECT_DB`, `ZIDANE_NO_UPDATE`, `ZIDANE_NO_DAEMON` / `--no-daemon`, `ZIDANE_DEBUG` (renderer `gatherStats`, fps/frametime to stderr on exit).
27
26
 
28
- ## Composition
27
+ Renderer fps is `Settings.targetFps` (30/60/120): seeds the renderer at boot, live flips apply via `renderer.targetFps`/`maxFps`.
29
28
 
30
- `<App>` is one component; everything else slots underneath it. Provider order is load-bearing.
29
+ ## Composition
31
30
 
32
31
  ```
33
32
  ConfigProvider
34
33
  └── SettingsProvider ← persists settings.json on change
35
- └── ThemedShell ← reads settings.theme → ThemeProvider
36
- └── MdStyleProvider ← SyntaxStyle.fromStyles(theme) once per theme
37
- └── ChipStyleProvider ← per-provider chip bg/fg SyntaxStyle for the live textarea
38
- └── SafeModeProvider ← FIFO approval queue
34
+ └── ThemedShell ← settings.theme → ThemeProvider
35
+ └── MdStyleProvider ← SyntaxStyle.fromStyles(theme), once per theme
36
+ └── ChipStyleProvider ← per-provider chip bg/fg for the live textarea
37
+ └── SafeModeProvider ← FIFO approval queue
39
38
  └── InteractionsProvider ← FIFO plan / ask_user queue
40
39
  └── McpAuthProvider ← per-server OAuth status map
41
- └── DiscoveryShell ← live skills / mcps / files catalogs (DiscoveryProvider)
40
+ └── DiscoveryShell ← live skills / mcps / files catalogs
42
41
  └── ModalRoot ← single overlay slot
43
42
  └── AppShell ← screens + footer
44
43
  ```
45
44
 
46
- `McpAuthProvider` + `DiscoveryShell` mount **above** `<ModalRoot>` so live updates reach open modals — settings opened during a refresh re-renders, an MCP login mid-modal flips its badge. The TUI reads theme via `useColors()`, settings via `useSettings()`, modal via `useModal()` — never reach around the stack.
45
+ `McpAuthProvider` + `DiscoveryShell` mount **above** `<ModalRoot>` so live updates reach open modals (an MCP login mid-modal flips its badge). Read theme via `useColors()`, settings via `useSettings()`, modal via `useModal()` — never reach around the stack.
47
46
 
48
47
  ## Public exports
49
48
 
50
49
  ```ts
51
50
  import {
52
- // Launcher + root
53
51
  runTui, App,
54
- // Screens
55
- AuthScreen, SessionsScreen, ChatScreen,
56
- type QueuedPreview, type QueueShortcuts,
57
- // Modal layer
52
+ AuthScreen, SessionsScreen, ChatScreen, type QueuedPreview, type QueueShortcuts,
58
53
  Modal, type ModalProps, ModalRoot, useModal, useModalAwareFocus,
59
- // Modals
60
- ModelPickerModal, EffortPickerModal, AgentPickerModal,
61
- SettingsModal, SkillsSettingsModal, McpsSettingsModal,
62
- SessionDetailsModal, TurnDetailsModal,
54
+ ModelPickerModal, type PickedModel, ModelOptionsPickerModal, EffortPickerModal, AgentPickerModal,
55
+ SettingsModal, type SettingsActions, SkillsSettingsModal, McpsSettingsModal, ExtensionsSettingsModal,
56
+ SessionDetailsModal, type SessionDetailsModalActions, type SessionExportResult, type SessionCompactResult,
57
+ TurnDetailsModal, type TurnDetailsModalActions,
63
58
  ToggleListModal,
64
- // Inline blocks (embed-friendly, also used by ChatScreen internally)
65
- InteractionBlock,
66
- // Picker / action types
67
- type PickedModel, type SettingsActions,
68
- type SessionDetailsModalActions, type SessionExportResult, type SessionCompactResult,
69
- type TurnDetailsModalActions,
70
- // Transcript + chrome
71
- Transcript, Footer, Spinner, StatusSpinner, TitleOverlay,
59
+ InteractionBlock, PlanReviewPanel,
60
+ Transcript, Footer, type FooterUsage, Spinner, StatusSpinner, TitleOverlay, type MetaSegment,
72
61
  CompletionPopup,
73
- // OpenTUI-specific display helpers
74
- buildMdStyle, useMdStyle, onInputSubmit,
75
- renderHintSpans,
76
- // Shared types
77
- type MetaSegment, type ContextUsage,
78
- // Deprecated back-compat re-exports from `zidane/chat`.
79
- // New code should import these directly from `zidane/chat`.
62
+ buildMdStyle, useMdStyle, onInputSubmit, renderHintSpans,
63
+ type ContextUsage,
64
+ type ExtensionRenderers, ExtensionRenderersProvider, useExtensionRenderers, useRowVisible, useSessionState, type RowViewportWindow,
65
+ // Deprecated back-compat re-exports — import from `zidane/chat` instead.
80
66
  type Hint, hintsLength, clipHintsToWidth, truncateTrailing,
81
- isVisible, marginTopFor, isEditErrorResult, isTurnHighlighted,
82
- selectableTurnIds, turnSelectionOwnership,
83
- accentColor,
84
- TOOL_DISPLAY, displayNameFor, formatToolCall,
85
- type ToolDisplayMeta, type ToolFormatLine,
67
+ isVisible, marginTopFor, isEditErrorResult, isTurnHighlighted, selectableTurnIds, turnSelectionOwnership,
68
+ accentColor, TOOL_DISPLAY, displayNameFor, formatToolCall, type ToolDisplayMeta, type ToolFormatLine,
86
69
  type PromptSegment, type PromptSegmentRef, splitPromptSegments,
87
70
  type MarkdownSegment, splitMarkdownCodeBlocks,
88
71
  computeTurnAnchors, type TranscriptItem,
@@ -91,550 +74,216 @@ import {
91
74
 
92
75
  | Export | Purpose |
93
76
  |---|---|
94
- | `runTui(options?)` | One-shot launcher. Boots OpenTUI + tree-sitter + renders `<App>`. Exits the process on dismiss. |
95
- | `App` | Top-level component. Accepts a fully-resolved `ResolvedConfig`; embedders mount this against their own renderer. |
96
- | `ChatScreen` | Transcript + auto-grow textarea + approval picker + interaction wizard + completion popup + queued-messages block. See [ChatScreen props](#chatscreen) for the full prop list. |
97
- | `AuthScreen` | First-run + re-config provider picker. Drives the OAuth / API-key wizard. |
98
- | `SessionsScreen` | Session list. Sorted by recency via the store. |
99
- | `Modal`, `ModalRoot`, `useModal`, `useModalAwareFocus` | Overlay layer. `Modal` owns its own esc-to-close handler. `useModalAwareFocus(preferred)` returns `false` while a modal is open so background inputs blur. **Single-slot:** `modal.open(node)` replaces the active node; modals do not stack. |
100
- | `ModelPickerModal` | Cross-provider searchable model picker. Two commit modes: **single pick** (`onPick`) — ↵ returns `{ providerKey, modelId }`, used by the compaction-model picker; **ordered multi-select** (`onApplyChain`) — ↵ toggles the row in a numbered fallback chain (the number is the row's 1-based position; pick order = fallback order), esc applies. Entry 1 is the primary (provider swap, effort, options); entries 2+ become run-level fallback targets (`agent.run({ model: […] })`). Esc with an unchanged or emptied chain is a plain cancel. Cross-provider chains trigger a provider swap + session re-activation in the host; the footer model chip shows `+N` while fallbacks are configured. When a run actually falls back (`model:fallback`), the chip switches to the model NOW serving the run, warn-colored as the "temporary" cue, and a transcript info line records the switch; both snap back at the next run (fallback is run-sticky per run). **Off-catalog models:** the active pick and every seeded chain entry are passed to `buildModelCatalog` as `include`, so a model the provider has retired (or one typed by hand) still gets a row, marked `not in catalog` in warn color instead of a capability blurb. Without it such an entry is invisible, un-deselectable, and re-persists on every close. When a query matches nothing, the empty state is a **selectable** row offering the typed id (`parseCustomModelQuery`: a leading provider key/label routes it, otherwise the current provider owns it) and the `↵` hint reads `use anyway`; committing one in chain mode keeps it listed for the rest of the session so its position marker stays visible. |
101
- | `EffortPickerModal` | Reasoning-effort picker. Lists `'off' / 'minimal' / 'low' / 'medium' / 'high'` plus `'adaptive'` when `supportsAdaptive` is true (Anthropic only). Surfaced via `ctrl+l` when `modelSupportsReasoning(descriptor, model)`. |
102
- | `SettingsModal` | Tabbed: **Agent** (behaviour, persistence), **UI** (display, theming, animations), **Keybindings** (read-only catalog · `↵` opens `keybindings.json`), **Authentication** (current provider + detected providers · `↵` pops the AuthScreen), **Instructions** (live default-context segments · `tab` reads inline, `↵` opens file-backed sources), **Skills**, **MCP servers**. Category tab order is data-driven by `SETTINGS_CATEGORIES` from `zidane/chat`. Keybindings + Authentication tabs only appear when the host wires the `keybindings` / `authentication` data props; otherwise the legacy "open keybindings file" / "re-authenticate" rows render in the UI / Agent tabs as a fallback. One shared search input filters the active tab; `←/→` cycle tabs (preventDefault keeps the input cursor put), `↑/↓` navigate, `↵` toggle/cycle/pick/edit. MCP tab adds `ctrl+L` login, `ctrl+O` logout, and `esc` cancel-authorizing (Modal's esc-close still fires alongside). Sized at `maxWidth: 160, maxHeight: 50` — wider than the legacy modal so the Keybindings catalog + Authentication status block sit comfortably. |
103
- | `AgentPickerModal` | Multi-agent profile picker. Hidden when only one profile is registered. |
104
- | `SkillsSettingsModal`, `McpsSettingsModal` | Standalone surfaces re-exported for embedders. The main `SettingsModal` integrates the same lists as tabs — prefer that for the in-app flow. `McpsSettingsModal` uses single-letter shortcuts (`l` / `o`); the integrated tab uses `ctrl+L` / `ctrl+O`. |
105
- | `ToggleListModal` | Generic checkbox-list modal for `settings.enabledX` allowlists. Powers both the Skills + MCP server pickers; reuse with `keyOf` + `settingKey` + `renderDetail` to add a third toggle surface. |
106
- | `SessionDetailsModal` | `ctrl+shift+x` modal. Actions (host wires which are available): `g` generate title, `e` export markdown, `j` export JSON, `k` compact, `d` delete (two-press), `c` copy id. Renders stats + total token usage rolled up from `session.runs`. |
107
- | `TurnDetailsModal` | Opened from select-turn mode (`ctrl+s` → ↵). `f` fork session at this turn (two-press), `d` delete turn (two-press), `c` copy turn text. Preview is scrollable + capped at 8 000 chars. |
108
- | `CompletionPopup` | Provider-agnostic popover. Consumes any `CompletionState<TItem>` — reads `label` + `description` only, so providers stay typed without leaking into the renderer. |
109
- | `Transcript`, `Footer`, `Spinner`, `StatusSpinner` | Event renderer, status bar, animated braille spinner, and a 1-cell title-row activity glyph. |
110
- | `TitleOverlay` + `MetaSegment` | Colored title (left, defaults to `COLOR.brand`) + optional right-aligned meta on a bordered-box's top border. Plain string → dim label; `MetaSegment[]` → per-segment colors. Render as a SIBLING of the bordered box (not a child) so the border row isn't clipped. Every span carries an explicit background (`bg`, default `SURFACE.background`): OpenTUI keeps a cell's existing char when a span paints a space with NO background, so a transparent overlay lets the border `─` bleed through the title's gaps (reads as a strikethrough) and lets a native `title=` on the same row interleave characters with it (`" plan "` under `"New session"` → `Nplane`). Opaque cuts both ways — `bg` must MATCH the surface under the decorated box, so pass `SURFACE.modal` for modal content (`McpAuthorizingPanel` does) or the overlay stamps a mismatched run along the border row. One title per row, therefore: the chat screen drops this overlay while the plan / file-edit panel owns the transcript slot and paints its own. Pinned in `test/tui/title-overlay-collision.test.tsx`. |
111
- | `renderHintSpans`, `hintsLength`, `Hint` | Footer + prompt-hint primitives. A `Hint` is `{ key, label, keyColor?, labelColor? }`; renderers compose them via `renderHintSpans(hints, COLOR)`. |
112
- | `ContextUsage` | `{ used, max }` shape passed to `<Footer context>`. Drives the right-aligned context indicator. |
113
- | `FooterUsage` | `{ label, usedPercent }` shape passed to `<Footer usage>`. Drives the subscription-usage segment left of the cost (`▰ 5h 43%`): the active provider's tightest rate-limit window, resolved by the host via `mostConstrainedWindow` (`zidane/chat`). Rendered as a floored whole percent, so `99.9` never reads as a ceiling the user hasn't hit. `null` hides it. Pair with `<Footer usageKey>` (a keybinding spec, default `ctrl+p`) to make it a click target. |
114
- | `buildMdStyle(theme)`, `useMdStyle()` | Convert a `Theme` to an OpenTUI `SyntaxStyle`. Hook returns the shared instance for the active theme. |
115
- | `onInputSubmit(handler)` | Cast helper — bypasses an OpenTUI / DOM type collision on `<input onSubmit>`. |
116
- | `isVisible`, `marginTopFor`, `isEditErrorResult` | Re-exported from `zidane/chat` (`store` module). Pure transcript-view rules — a GUI shell renders against the same matrix. |
117
- | `accentColor` | Re-exported from `zidane/chat` (`agents` module). Resolves an `AgentAccent` token against a 4-role color subset. |
118
- | `TOOL_DISPLAY`, `displayNameFor`, `formatToolCall` | Re-exported from `zidane/chat` (`tool-formatters` module). Per-tool curated one-liners for `'formatted'` `toolCallDisplay`. Host apps extending the registry should import from `zidane/chat`. |
119
-
120
- Note: `zidane/tui` does **not** re-export the renderer-agnostic engine (settings, theme, safe-mode, store, providers, completion framework, interactions, keybindings, MCP auth state, …). Import those from `zidane/chat` directly — keeps a future GUI off OpenTUI. The deprecated back-compat re-exports above will be dropped in the next minor.
77
+ | `runTui(options?)` | One-shot launcher. Exits the process on dismiss. |
78
+ | `App` | Top-level component; takes a resolved `ResolvedConfig`. Embedders mount this against their own renderer. |
79
+ | `ChatScreen` | Transcript + textarea + approval/interaction slots + queue block. See [ChatScreen](#chatscreen). |
80
+ | `AuthScreen` / `SessionsScreen` | Provider-picker wizard / session list (sorted by recency). |
81
+ | `Modal`, `ModalRoot`, `useModal`, `useModalAwareFocus` | Single-slot overlay layer — `modal.open(node)` replaces the active node, modals don't stack. `useModalAwareFocus(preferred)` returns `false` while a modal is open. |
82
+ | `ModelPickerModal` / `PickedModel` | Cross-provider model picker. `onPick` = single pick (↵ returns `{providerKey, modelId}`); `onApplyChain` = ordered multi-select building a run-level fallback chain (entry 1 = primary, entries 2+ = fallback targets), esc applies. See [ModelPickerModal](#modelpickermodal). |
83
+ | `ModelOptionsPickerModal` | Reasoning-effort + feature-toggle picker (`ctrl+l`). Supersedes `EffortPickerModal` for in-app use. |
84
+ | `EffortPickerModal` | Legacy effort-only picker. No longer wired to a keybinding; kept exported for embedders. |
85
+ | `AgentPickerModal` | Multi-agent profile picker. Hidden when one profile is registered. |
86
+ | `SettingsModal` | Tabbed settings surface. See [Settings modal](#settings-modal). |
87
+ | `SkillsSettingsModal`, `McpsSettingsModal`, `ExtensionsSettingsModal` | Standalone list+toggle surfaces for embedders; `SettingsModal` integrates the same catalogs as tabs. |
88
+ | `ToggleListModal` | Generic checkbox-list modal (powers the skills/MCP pickers). |
89
+ | `SessionDetailsModal` (`ctrl+shift+x`) | Stats + `g` title · `e`/`j` export md/json · `k` compact · `d` delete (two-press) · `c` copy id. |
90
+ | `TurnDetailsModal` (from select-turn mode, ↵) | `f` fork (two-press) · `d` delete (two-press) · `c` copy · `e` edit turn text. |
91
+ | `CompletionPopup` | Provider-agnostic popover; reads `label`+`description` off any `CompletionState<TItem>`. |
92
+ | `Transcript`, `Footer`, `FooterUsage`, `Spinner`, `StatusSpinner`, `TitleOverlay`, `MetaSegment` | Renderer chrome. `TitleOverlay` renders a colored title (+ optional right-aligned `MetaSegment[]` meta) on a bordered box's top border — always as a SIBLING of the box, never a child, and with an explicit `bg` matching the surface underneath, or OpenTUI's scissor rect lets the border bleed through. |
93
+ | `buildMdStyle(theme)`, `useMdStyle()` | `Theme` → OpenTUI `SyntaxStyle`; the hook returns the shared per-theme instance. |
94
+ | `onInputSubmit`, `renderHintSpans` | Cast helper for `<input onSubmit>`; footer/prompt hint-row renderer. |
95
+
96
+ Not re-exported: the renderer-agnostic engine (settings, theme, safe-mode, store, providers, completion, interactions, keybindings, MCP auth state). Import those from `zidane/chat` — keeps a future GUI off OpenTUI.
121
97
 
122
98
  ## Screens
123
99
 
124
100
  | Screen | When | Primary interaction |
125
101
  |---|---|---|
126
- | `auth` | No usable provider credentials, or user picked "Re-configure providers" | Provider picker → wizard (OAuth / API key) → re-detect |
127
- | `sessions` | User opens the session list | Pick existing or `+ new` |
102
+ | `auth` | No usable provider credentials, or "Re-configure providers" | Provider picker → wizard (OAuth / API key) → re-detect |
103
+ | `sessions` | User opens the session list | Pick existing, `+ new`, `tab` toggles project ↔ all-projects scope |
128
104
  | `chat` | Default once a provider + session are resolved | Type → submit → stream |
129
105
 
130
106
  ### ChatScreen
131
107
 
132
- `<ChatScreen>` accepts (full list — most props have sensible defaults):
133
-
134
- ```ts
135
- events, settings, busy, compacting,
136
- session, selectedTurnId,
137
- onSubmit, completionProviders, onPopupOpenChange,
138
- pending, onApproval, // safe-mode head
139
- pendingInteraction, onInteraction, // plan / ask_user head
140
- queuedMessages, queueSelectionIndex, queueShortcuts,
141
- onEnterQueueFromEmptyPrompt,
142
- promptTriggerHints,
143
- ```
144
-
145
- The slot priority has two layers — a top layer that may take over the **transcript** slot, and a bottom layer below the transcript:
146
-
147
- **Transcript-slot takeover**:
148
-
149
- - **`pendingApproval != null && isFileEditTool(pending.tool)`** → `FileEditApprovalModal` renders **inline** in the transcript slot. The `<Transcript>` stays mounted with `visible: !fileEditPending` so its memoized turn anchors, scrollbox position, and lazy markdown chrome survive the modal open → close round-trip. The prompt + footer stay anchored at the bottom (queue-only while `busy`). `ChatScreen` pushes an empty `<></>` placeholder onto the global modal context so `useModalAwareFocus()` keeps releasing background focus and the app-level `esc` / `ctrl+s` shortcuts stay gated. See [File-edit approval modal](#file-edit-approval-modal).
108
+ Layered slots, in priority order:
150
109
 
151
- **Bottom slot** (priority order):
110
+ 1. **Transcript-slot takeover** — a file-edit approval (`edit`/`multi_edit`/`write_file`) renders `FileEditApprovalModal` inline in the transcript slot (`<Transcript visible={!fileEditPending}>` so scroll position and memoized turn anchors survive open→close). See [File-edit approval modal](#file-edit-approval-modal).
111
+ 2. **Bottom slot**, one of: non-file-edit `pendingApproval` → `ApprovalBlock` (accept once / for session / + remember / deny, esc aborts the run) · `pendingInteraction` → `InteractionBlock` (plan picker or Q&A wizard; esc aborts if live, exits the form if resumed) · else `PromptBlock` (textarea + history), kept mounted (`Display.None` when hidden) so draft/cursor/attachments survive an approval round-trip.
152
112
 
153
- 1. **Non-file-edit `pendingApproval`** → `ApprovalBlock` (4 options: accept once · accept for session · accept + remember · deny). Esc aborts the run via the parent handler.
154
- 2. **`pendingInteraction != null`** → `InteractionBlock` (plan picker or question wizard, depending on `request.kind`).
155
- - **Live** (`busy === true`) — esc aborts the run.
156
- - **Resumed** (`busy === false`, persisted `tool_call` without matching `tool_result`) — esc leaves the form, the session re-enqueues on next activation.
157
- 3. Otherwise → `PromptBlock` (textarea + history) — kept mounted even while `busy` so the user can type-ahead. It also stays mounted (and `suspended`, so the hidden textarea releases focus to the slot owner) under cases 1 and 2 rather than being swapped out: `visible: approvalPending == null && pendingInteraction == null` maps to `Display.None`, so the draft, cursor, history position, and staged attachments survive an approval / interaction round-trip instead of being destroyed by an unmount. A small `StatusSpinner` sits next to the session title via `TitleOverlay`'s `statusIcon` slot (`warn` while streaming, `accent` while auto-compacting); `↵` enqueues the typed message onto the drain loop and the next `agent.run()` picks it up when the current stream completes.
113
+ When the queue has entries, a `QueuedMessagesBlock` renders between transcript and prompt:
158
114
 
159
- File-edit pending falls through to the bottom slot's prompt branch — the inline modal owns the conversation area and the keystroke dispatch, so the prompt stays interactive (queue-only).
160
-
161
- When the queue has entries, a `QueuedMessagesBlock` renders between the transcript and the prompt:
162
-
163
- | Key (default) | Action |
164
- |---|---|
165
- | `↑` on an empty buffer | enter queue-selection mode (focus the queue box, blur the textarea) — natural-feel path; no global shortcut needed |
166
- | `enterQueueSelection` (unbound by default) | same as above, useful when the user wants to enter without emptying their draft first |
167
- | `↑` / `↓` | navigate within the queue |
168
- | `ctrl+↵` (`pushQueuedMessage`) | push the selected message via `agent.steer()` — delivered to the live run between tool calls |
169
- | `backspace` (`dropQueuedMessage`; Mac: the laptop key labeled `delete`) | drop the selected message; auto-exit back to the prompt when the queue empties |
170
- | `esc` | exit queue-selection mode |
171
-
172
- All four shortcuts route through `keybindings.json` (actions `enterQueueSelection`, `pushQueuedMessage`, `dropQueuedMessage`). The queue box surfaces the `enter` shortcut on its top-right border and the `push` / `drop` shortcuts on the focused row.
173
-
174
- `ApprovalBlock` (non-file-edit gates only) height is pinned: `2 (border) + 1 (summary) + N options`. Summary row uses `wrapMode="none"` so a 500-char tool preview can't push the select off-screen. Four decision options: `accept once`, `accept for session`, `accept + remember` (writes the safelist), `deny`. File-edit tools (`edit` / `multi_edit` / `write_file`) bypass this block — they're routed to the inline `FileEditApprovalModal` instead, which can express the wider `{ kind: 'partial', mask }` decision shape on top of the same four bulk options.
115
+ | Key (default) | Action id | Effect |
116
+ |---|---|---|
117
+ | `↑` on empty buffer | — | enter queue-selection mode (no shortcut needed) |
118
+ | — (unbound) | `enterQueueSelection` | same, without emptying the draft first |
119
+ | `↑`/`↓` | — | navigate the queue |
120
+ | `ctrl+↵` | `pushQueuedMessage` | steer selected message into the live run (`agent.steer()`) |
121
+ | `backspace` | `dropQueuedMessage` | drop selected message; auto-exits when empty |
122
+ | `esc` | — | exit queue-selection mode |
175
123
 
176
124
  ## Keyboard
177
125
 
178
- Every customizable shortcut routes through `<userDir>/keybindings.json` and is parsed via `matchesBinding(event, spec)` from `zidane/chat`. The full action catalog lives in `KEYBINDING_DEFS`; defaults are merged with user overrides on launch.
126
+ Every action routes through `<userDir>/keybindings.json`, matched via `matchesBinding(event, spec)` from `zidane/chat`. Catalog + defaults: `KEYBINDING_DEFS` in `src/chat/keybindings.ts` — this table mirrors it exactly. Engine-side parsing contract (tolerant aliases, strict modifier matching): [CHAT.md § Keybindings](./CHAT.md#keybindings).
179
127
 
180
- ### Global shortcuts (chat screen unless noted)
128
+ ### Global (group: Global)
181
129
 
182
130
  | Key (default) | Action id | Where | Notes |
183
131
  |---|---|---|---|
184
- | `enter` | — | textarea (popup closed) | submit |
185
- | `enter` / `tab` | — | textarea (popup open) | commit highlighted completion |
186
- | `shift+enter` | — | textarea | newline |
187
- | `↑` / `↓` | — | textarea (popup open) | move popup highlight |
188
- | `↑` / `↓` at buffer edges | — | textarea (popup closed) | cycle prompt history (snapshots draft on entry) |
189
- | `↑` on empty buffer | — | textarea | enter queue-selection mode (when queue is non-empty) |
190
- | `esc` | — | popup open | dismiss popup |
191
- | `esc` | — | run in flight / approval pending | abort run + drain approval + interaction queues |
192
- | `esc` | — | modal open | close modal |
193
132
  | `ctrl+o` | `openSettings` | non-auth | open Settings modal |
194
- | `ctrl+shift+x` | `openSessionDetails` | chat (idle) or sessions list (focused row) | open session-details modal. Kept off `ctrl+x` (conventional "cut"); needs the kitty keyboard protocol to stay distinct from it — rebind on legacy terminals like Terminal.app |
195
- | `ctrl+m` | `openModelPicker` | chat (idle) | open cross-provider model picker (↵ numbers models into a fallback chain · esc applies) |
196
- | `ctrl+l` | `openEffortPicker` | chat (idle, model has reasoning) | open reasoning-effort picker |
197
- | `ctrl+s` | `enterSelectTurnMode` | chat (idle) | enter select-turn mode (transcript navigation) |
198
- | `ctrl+t` | `openTodos` | chat (session attached) | open the active run's `todowrite` checkpoints (read-only) |
199
- | `ctrl+shift+a` | `openArtifacts` | chat (session attached) | open the session's artifacts — pull requests, branches, commits, created files, extension kinds (same modal as `/artifacts`; `/artifacts project` opens project scope). Kitty-protocol chord like `ctrl+shift+g`; rebind on Terminal.app |
200
- | `ctrl+shift+g` | `openContextPanel` | chat | open the context-usage breakdown panel (read-only). Needs a terminal with the kitty keyboard protocol to stay distinct from `ctrl+g`; rebind on legacy terminals like Terminal.app |
201
- | `ctrl+p` | `openUsagePanel` | chat | open the subscription usage panel (read-only): per-provider rate-limit windows and credit balances. `ctrl+r` refetches inside it. Also reachable by clicking the footer's `▰ 5h 43%` segment. Plain `ctrl+p` rather than a `ctrl+shift+…` chord, so it behaves the same on kitty and legacy terminals, and it is free in OpenTUI's textarea keymap |
202
- | `ctrl+y` | `openKeybindings` | non-auth | open the keybindings panel — read-only catalog grouped by surface, with `↵` to edit `~/.zidane/keybindings.json` |
203
- | `ctrl+k` | `cancelToolCall` | chat (busy, ≥1 in-flight call) | open the cancel-tool picker — pick one call to flip via `agent.cancelTool(callId)`, or press `a` to cancel every in-flight call. Distinct from `esc abort` (whole-run abort); siblings keep running |
204
- | `ctrl+v` | `attachClipboardImage` | textarea | paste **text or an image** from the OS clipboard, read by the process itself rather than through the terminal's paste path. Images become attachments; text goes through the same policy as a normal paste (large / multiline folds into `paste.txt`). Because only the keystroke crosses the PTY, this is the way out of integrated terminals whose PTY layer freezes on large `cmd+V` pastes (e.g. Cursor). Reads via a platform helper (`pngpaste` or an `osascript` fallback on macOS; `wl-paste`/`xclip` on Linux; PowerShell on Windows). An empty bracketed-paste event also probes the clipboard for an image. Soft-fails with an inline notice (nothing on the clipboard / helper missing / over SSH / too large). Image attaching is gated on `attachmentsEnabled`; the action id stays `attachClipboardImage` so existing keybinding overrides keep working |
205
- | `ctrl+g` | `openExternalEditor` | textarea | open the current prompt draft in `$EDITOR` / `$VISUAL` (git-style: temp file, reloads on save & quit). Suspends the renderer for the editor; a non-zero editor exit aborts (prompt unchanged). Gated on `externalEditor` |
206
- | `shift+tab` | `cycleAgent` | chat (idle, ≥2 profiles) | cycle to next agent profile |
207
- | `tab` | — | sessions list | toggle list scope: current project ↔ all projects (ephemeral — doesn't touch the `showAllProjects` setting). Opening a session from another project `chdir`s to that session's `projectRoot` first |
208
- | `ctrl+↵` | `pushQueuedMessage` | queue-selection mode | push selected queued message into the live run |
209
- | `backspace` | `dropQueuedMessage` | queue-selection mode | drop selected queued message |
210
- | `pageup` / `pagedown` | (transcript scroll) | chat (no modal) | scroll the transcript one full page up / down |
211
- | `shift+pageup` / `shift+pagedown` | (transcript scroll) | chat (no modal) | jump the transcript to the very top / bottom |
212
- | `ctrl+a` | (textarea built-in) | textarea | select-all (use `home` / `ctrl+e` for emacs-style line ends) |
213
-
214
- ### Modal-internal shortcuts
215
-
216
- These only fire while the modal in question is mounted, so single-letter defaults don't clash with each other.
217
-
218
- | Modal | Key | Action id | Notes |
219
- |---|---|---|---|
220
- | `TurnDetailsModal` | `f` | `turnFork` | two-press confirm |
221
- | | `d` | `turnDelete` | two-press confirm |
222
- | | `c` | `turnCopy` | copy turn content via OSC 52 |
223
- | `SessionDetailsModal` | `g` | `sessionGenerateTitle` | generate title via active provider/model |
224
- | | `e` | `sessionExportMarkdown` | export under `.{prefix}/sessions/` |
225
- | | `j` | `sessionExportJson` | export under `.{prefix}/sessions/` |
226
- | | `k` | `sessionCompact` | LLM-compact older turns (synthetic `compact-summary` marker turn) |
227
- | | `d` | `sessionDelete` | two-press confirm |
228
- | | `c` | `sessionCopyId` | copy full session id via OSC 52 |
229
-
230
- `SettingsModal` MCP tab adds `ctrl+L` (login) / `ctrl+O` (logout) on the focused row. The standalone `McpsSettingsModal` (for embedders) uses single-letter `l` / `o`.
133
+ | `ctrl+shift+x` | `openSessionDetails` | chat (idle) / sessions (focused row) | collapses to `ctrl+x` without the kitty keyboard protocol (e.g. Terminal.app) — rebind there |
134
+ | `ctrl+n` | `newSession` | chat (idle) | start a fresh session with the active provider/model |
135
+ | `ctrl+m` | `openModelPicker` | chat (idle) | cross-provider model picker |
136
+ | `ctrl+l` | `openEffortPicker` | chat (idle, model has reasoning or options) | open `ModelOptionsPickerModal` (reasoning effort + feature toggles, e.g. fast mode) |
137
+ | `ctrl+t` | `openTodos` | chat (session attached) | open `TodosModal` (read-only) |
138
+ | `ctrl+shift+a` | `openArtifacts` | chat (session attached) | open `ArtifactsModal` — same modal as `/artifacts`. Collapses to `ctrl+a` without kitty; rebind there |
139
+ | `ctrl+shift+g` | `openContextPanel` | chat | context-usage breakdown (read-only). Collapses to `ctrl+g`; rebind there |
140
+ | `ctrl+shift+m` | `openMainSession` | any, when `Settings.orchestration` is on | jump to (or lazily create) the pinned Main orchestration session for the cwd; requires the daemon runtime. Silent no-op when the setting/runtime is off |
141
+ | `ctrl+p` | `openUsagePanel` | chat | subscription usage panel; also opens by clicking the footer's `▰ 5h 43%` segment |
142
+ | `ctrl+y` | `openKeybindings` | non-auth | keybindings panel, `↵` opens `keybindings.json` |
143
+ | `ctrl+k` | `cancelToolCall` | chat (busy, ≥1 in-flight call) | cancel-tool picker (`agent.cancelTool(callId)`); distinct from `esc` (whole-run abort) |
144
+ | `ctrl+h` | `changeCwd` | non-auth | open the directory picker to change cwd |
145
+ | `ctrl+v` | `attachClipboardImage` | textarea | paste text or an image from the OS clipboard, read by the process itself (bypasses a frozen terminal paste path) |
146
+ | `ctrl+g` | `openExternalEditor` | textarea | edit the prompt draft in `$EDITOR`/`$VISUAL`; gated on `externalEditor` setting |
147
+ | `shift+tab` | `cycleAgent` | chat (idle, ≥2 profiles) | cycle agent profile |
148
+ | `ctrl+s` | `enterSelectTurnMode` | chat (idle) | enter select-turn mode |
149
+ | `ctrl+↵` | `pushQueuedMessage` | queue-selection | push selected queued message |
150
+ | `backspace` | `dropQueuedMessage` | queue-selection | drop selected queued message |
151
+ | — (unbound) | `enterQueueSelection` | textarea | move focus into the queue box |
152
+ | `enter` / `shift+enter` | — | textarea | submit / newline |
153
+ | `↑`/`↓` at buffer edges | — | textarea (popup closed) | cycle prompt history |
154
+ | `esc` | — | popup / run in flight / modal open | dismiss popup, or abort run + drain approval+interaction queues, or close modal |
155
+ | `pageup`/`pagedown`, `shift+pageup`/`shift+pagedown` | — | chat, no modal | scroll transcript one page / jump to top-bottom |
156
+ | `ctrl+a` | (textarea built-in) | textarea | select-all |
157
+
158
+ ### Modal-internal (single-letter defaults; only fire while that modal is mounted)
159
+
160
+ | Modal (group) | Key | Action id |
161
+ |---|---|---|
162
+ | Turn details | `f` | `turnFork` (two-press) |
163
+ | | `d` | `turnDelete` (two-press) |
164
+ | | `c` | `turnCopy` (OSC 52) |
165
+ | | `e` | `turnEdit` |
166
+ | Session details | `g` | `sessionGenerateTitle` |
167
+ | | `e` | `sessionExportMarkdown` |
168
+ | | `j` | `sessionExportJson` |
169
+ | | `k` | `sessionCompact` |
170
+ | | `d` | `sessionDelete` (two-press) |
171
+ | | `c` | `sessionCopyId` (OSC 52) |
172
+
173
+ `SettingsModal`'s MCP tab adds `ctrl+L` login / `ctrl+O` logout on the focused row; the standalone `McpsSettingsModal` uses single-letter `l`/`o`.
231
174
 
232
175
  ### Keybindings file
233
176
 
234
- `<userDir>/keybindings.json` is JSONC (`//` and `/* … */` comments allowed). Tolerant parser:
235
-
236
- - Case-insensitive (`Ctrl+O` ≡ `ctrl+o`).
237
- - Order-insensitive (`shift+ctrl+o` works).
238
- - `+` and `-` separators both accepted (`ctrl-o`).
239
- - Modifier aliases: `cmd` / `command` / `super` / `win` → `meta`; `option` / `opt` → `alt`; `ctrl` / `control`.
240
- - Key aliases: `esc` ↔ `escape`, `enter` / `ret` ↔ `return`, `del` ↔ `delete`, `pgup` / `pgdn` ↔ `pageup` / `pagedown`.
241
-
242
- Invalid / unknown entries silently fall back to defaults (with a `ZIDANE_DEBUG` log). User customizations are sacred — `ensureKeybindingsFile` never overwrites an existing file.
243
-
244
- Modifier semantics in `matchesBinding` are strict: all declared modifiers must be pressed AND no extras. `'o'` does NOT match `ctrl+o`, and `'ctrl+o'` does NOT match `ctrl+shift+o`. The Settings → General row "Keybindings" opens the file in `$EDITOR` / `$VISUAL` (or platform default opener); changes take effect on restart.
245
-
246
- Esc-while-busy aborts the active `agent.run()`, resolves every pending approval with `deny`, and cancels every pending interaction. The teardown order (`denyAll → interactions.cancelAll → abort → stream.reset → agent.destroy`) is shared with session-switching and re-auth, so late hooks from a doomed run never leak into the next session.
247
-
248
- **Single-deny semantics**. `applyGate` no longer cascades a `deny` through the rest of the queue. When the user denies a single approval (via `ApprovalBlock`, `FileEditApprovalModal`, or the active modal's esc), `gateDecision` sets `ctx.block = true` + `ctx.reason = 'User denied this tool call'` for that one call and returns — the model receives `Blocked: …` as that tool's result and the turn continues. Other parallel approvals stay queued and prompt independently. The user's explicit "stop everything" gesture is the global `esc abort run` shortcut → `onAbort()` → `cancelRunOnDenial()`, which is the only path that calls `denyAll()` + aborts the in-flight `agent.run()`.
249
-
250
- Esc gates on the completion popup state — when the popup is visible, esc is consumed by `PromptBlock`'s `onKeyDown` (dismiss) and the outer abort handler short-circuits.
251
-
252
- ## Subagent rendering
253
-
254
- A subagent run emits `spawn-start` / `spawn-end` markers and a stream of tagged child events (`depth > 0`, `childId: child-N`).
255
-
256
- `Settings.hideSubagentOutput` (default **on**) collapses everything between the markers — the user sees `🌱 child-1 · task` … `child-1 · done 213 in / 4 075 out` (the end marker's tag is `done` / `aborted` / `error` followed by token usage) and the parent's next response. The `child-N` label renders in `COLOR.brand` with a `·` separator so it reads as part of the line, not a bracketed prefix.
257
-
258
- When off, consecutive child events are wrapped in a single bordered `SubagentBlock` rendered with `depthOffset: 1` (events sit flush with the box's inner padding instead of being indented twice) and a `COLOR.brand` border (the box title shares the border color — brand-tinted frame doubles as a brand-tinted label, no separate `titleColor` API needed). The box title carries the `child-N` label, so single-subagent runs drop the per-row label to avoid duplication. Multi-subagent runs (rare — happens when several `spawn` tool calls render back-to-back inside the same partitioned run) keep the per-row label for disambiguation. `SubagentBlock` threads this via `hideChildLabel = childIds.length === 1` to `EventLine`.
259
-
260
- The reload path (`eventsFromTurns(turns, runs)`) reproduces the same tagged events from persisted `SessionRun.parentRunId` chains. Live and reloaded transcripts render identically — depth ≥ 2 nesting included.
261
-
262
- Every `'tool'` / `'tool-result'` event carries `StreamEvent.callId` (verbatim `tool_use.id`). The TUI itself uses tool-name filtering, but downstream consumers reading the same `StreamEvent[]` (host SDKs, GUI shells, replayers) pair calls ↔ results by `callId` — positional pairing breaks the moment two parallel calls share a tool name. See **`StreamEvent.callId` pairing contract** in `ARCHITECTURE.md`.
263
-
264
- ## Theme + markdown
265
-
266
- Theme switching is real-time. Cycle via Settings → Theme; every `useColors()` / `useSelectStyle()` / `useSurfaces()` / `useMdStyle()` consumer re-renders.
267
-
268
- `useMdStyle()` returns a shared `SyntaxStyle` for the active theme — `MdStyleProvider` builds it once via `buildMdStyle(theme)` and broadcasts. Without this, a transcript with 50 markdown blocks would re-allocate 50 `SyntaxStyle.fromStyles(...)` objects per theme switch.
177
+ `<userDir>/keybindings.json` is JSONC. `ensureKeybindingsFile` never overwrites an existing file. Invalid/unknown entries silently fall back to defaults (`ZIDANE_DEBUG` logs why). Settings → General → "Keybindings" opens the file in `$EDITOR`/`$VISUAL`; changes apply on restart. Full alias/parsing rules: [CHAT.md § Keybindings](./CHAT.md#keybindings).
269
178
 
270
- `ChipStyleProvider` performs the analogous job for the live textarea's per-provider chip pills (`useChipStyle()` returns one `SyntaxStyle` keyed by `theme.surfaces.chips[providerId]`).
179
+ **Esc semantics.** While busy, `esc` runs `denyAll → interactions.cancelAll → abort → stream.reset → agent.destroy` (shared with session-switching/re-auth teardown). `applyGate` does **not** cascade a single deny through the rest of the queue: denying one approval blocks only that call (`Blocked: …`), other pending approvals stay queued. The global `esc abort` is the only path that denies everything + aborts the run. Esc is consumed by the completion popup first when one is open.
271
180
 
272
- Fenced code blocks get language-specific highlighting via Tree-sitter. Registered grammars: `python`, `bash` (aliases `sh`/`shell`/`zsh`), `json`, `rust` (`rs`), `go` (`golang`), `yaml` (`yml`), `html` (`htm`), `css`, `haskell` (`hs`), `c`, `cpp` (`c++` / `cxx` / `cc`), `csharp` (`cs` / `c#`), `elixir` (`ex` / `exs`), `sql` (`psql` / `mysql` / `sqlite` / `plsql` — TUI binary only), plus everything OpenTUI ships by default (TypeScript / JavaScript / JSX / TSX). Wasms download lazily on first use and cache under OpenTUI's data path; SQL is vendored into the TUI binary via `ZIDANE_LOCAL_TREE_SITTER_SQL_WASM` (set by `tui/src/tree-sitter-bundle.ts`). `registerTreeSitterParsers()` registers them synchronously at boot, `initTreeSitterWorker()` spins up the worker off the critical path — failures fall back to plain text without breaking the renderer.
181
+ ## Modals
273
182
 
274
- ### Tool call display
183
+ ### ModelPickerModal
275
184
 
276
- `Settings.toolCallDisplay` picks one of three renderings for `'tool'` events. All three live behind the `<EventLine>` dispatch in `components.tsx`:
185
+ Two commit modes: **single pick** (`onPick`, ↵ returns the model) or **ordered multi-select** (`onApplyChain`, ↵ toggles a numbered fallback-chain row, esc applies). Cross-provider chains trigger a provider swap + session re-activation; the footer model chip shows `+N` while fallbacks are configured, and switches to warn-colored "temporary" display of the model actually serving a run when it falls back (`model:fallback`), snapping back next run. Off-catalog models (retired, or hand-typed) still get a row via `buildModelCatalog`'s `include` param, marked `not in catalog`. An empty search offers a selectable "use anyway" row for the typed id.
277
186
 
278
- | Mode | Line shape | Notes |
279
- |---|---|---|
280
- | `'hidden'` | (event filtered out by `isVisible`) | Transcript reads as pure chat. |
281
- | `'formatted'` (default) | `↳ <Verb> <target> · <meta>` | Verb + target / meta come from `displayNameFor` + `formatToolCall` in `zidane/chat`. MCP tools get their `mcp_` prefix stripped before sentence-casing. |
282
- | `'full'` | `↳ <Verb> <name>` header + native `<code filetype="json">` block | Debug view; renders the raw `input` object pretty-printed. Capped at 32 KiB. |
283
-
284
- Edit-diff rendering (`showEditDiffs` on) takes precedence over `toolCallDisplay` for `edit` / `multi_edit` / `write_file` — those render via `EditDiffBlock` regardless of the mode. The diff's header still picks up the curated `displayName` so the two surfaces stay visually consistent.
285
-
286
- Hosts adding a new tool: extend `TOOL_DISPLAY` in `zidane/chat` with a `{ displayName, format }` entry. The TUI's renderer picks it up automatically — no `components.tsx` changes.
287
-
288
- Provider-executed tools (`stream:server_tool_use` / `stream:server_tool_result`, e.g. Anthropic's server-side `web_search_20250305`) are synthesized into the same `'tool'` / `'tool-result'` events in `app.tsx`, so they flow through `<EventLine>` and `TOOL_DISPLAY` like locally-dispatched tools. Add a `TOOL_DISPLAY` entry under the producer's tool name to customize rendering.
187
+ ### SettingsModal
289
188
 
290
- ### Edit-diff rendering
291
-
292
- `Settings.showEditDiffs` (default **on**) renders `edit` / `multi_edit` / `write_file` tool calls as a unified diff via the native `<diff>` renderable. Wire path:
293
-
294
- - `tool:before` hook reads pre-write content for `write_file` (`edit` / `multi_edit` carry the old text in their input), then calls `extractEditPayload(name, input, priorContent)` from `zidane/chat`. The resulting `EditPayload` rides on the `'tool'` `StreamEvent.edit` field. The default emit short-circuits when `pendingAnnotationsRef.current.has(callId)` is true — the gate handler already painted a synthetic `'tool'` event with the FULL hunks + outcomes, so emitting again with the reduced input would paint a misleading second row.
295
- - `EditDiffBlock` consumes that payload and calls `buildUnifiedDiff(payload)` + `filetypeFromPath(payload.path)` for syntax highlighting. When `payload.outcomes` is populated (live partial-approval or persisted replay), each hunk row carries an applied / denied / skipped / failed badge and the header line shows the tally via `summarizeOutcomes`.
296
- - The paired `tool-result` is suppressed (`isEditErrorResult` is the gate) only for **all-applied** outcomes — the result body is the legacy `Edited <path>: applied N edits (M replacements).` summary with no annotation. Presence of an `<edit-outcomes>…</edit-outcomes>` block (mixed / denied / failed) or a `[fully denied] …` body keeps the row visible so the user reads the per-hunk reasons next to the diff. Errors (`Edit error: …`, `Tool failed: …`, `multi_edit error: …`) always bypass suppression.
297
- - Historical replay from persisted turns has no pre-write snapshot for `write_file` — the diff renders all-add, matching git's "new file" convention. Outcomes are reconstructed from the persisted `tool_result` body via `parseEditOutcomesFromResult` and re-attached, so reloaded transcripts show the same per-hunk badges live capture displayed.
298
-
299
- Diff row colors come from `theme.surfaces.diff` (`addBg`, `removeBg`, optional `*ContentBg` for a deeper content-column hue, `addFg` / `removeFg` for the gutter glyphs). Built-in themes pre-mix translucent reds/greens against their primary surface so terminals without true alpha-blend still read cleanly.
189
+ See [Settings modal](#settings-modal) below.
300
190
 
301
191
  ### File-edit approval modal
302
192
 
303
- File-edit tools (`edit` / `multi_edit` / `write_file`) get their own approval surface — `FileEditApprovalModal` (`src/tui/file-edit-approval-modal.tsx`) — instead of the generic `ApprovalBlock`. The modal branches on hunk count:
304
-
305
- - **`SingleEditApprovalModal`** (`edit`, `write_file`, single-step `multi_edit`) — diff panel + 4-button action bar. Same `a` / `s` / `p` / `d` shortcuts as the inline `ApprovalBlock`, plus `←` / `→` / `tab` to navigate, `↵` to confirm, `esc` to deny. Split-pane on terminals ≥ 100 columns, unified view below.
306
- - **`MultiEditApprovalModal`** (multi-step `multi_edit`, ≥ 2 hunks) — per-hunk toggle list + focused-hunk diff panel + action bar. List zone: `↑` / `↓` move cursor, `space` toggles the focused hunk, `y` / `n` set all on/off. Actions zone: `←` / `→` cycle, `↵` confirm. `tab` cycles between zones. `a` / `s` / `p` / `d` bulk shortcuts work in either zone. The first action label is dynamic — `Apply all`, `Apply N/M`, `Nothing selected` — and submits one of:
307
- - All hunks on → bulk decision (`accept-once` / `accept-session` / `accept-safelist`) preserving the safelist path identical to single-edit.
308
- - All hunks off → `'deny'`.
309
- - Mixed mask → `{ kind: 'partial', mask }` — `applyGate` calls `resolveApprovalForPayload`, then **rebinds** `ctx.input` to a fresh shallow-clone whose `edits` is the approved subset (the model's original `tool_call.input` on the persisted assistant turn stays untouched) and stashes the full-length `EditOutcome[]` in `pendingAnnotationsRef.current` keyed by `ctx.callId`. The paired `tool:transform` / `child:tool:transform` hook reads the entry, MERGES with any body-emitted `<edit-outcomes>` block (`multi_edit`'s best-effort body emits one when any step failed) via `mergeApprovalAndBodyOutcomes`, strips the body's block, rewrites the body's header so `N of M` counts reflect the model's original total (`rewriteMultiEditHeader`), and appends the canonical merged annotation. The merged outcomes also flow into the in-flight `'tool'` event (`updateToolEventOutcomes` via `stream.flushAndUpdate`) so the live diff badges catch up to body-side failures the gate couldn't anticipate — no reload required. A partially-approved `multi_edit` whose body subsequently has a step-level failure surfaces as e.g. `[denied, applied, denied, failed, applied]` end-to-end — live AND on replay. See [Per-edit approval in CHAT.md](./CHAT.md#per-edit-approval) for the full contract.
193
+ `edit`/`multi_edit`/`write_file` route to `FileEditApprovalModal` (`isFileEditTool`) instead of the generic `ApprovalBlock`. Branches on hunk count:
310
194
 
311
- **Inline mount**. The modal renders inside `ChatScreen`'s transcript slot via `flexGrow: 1`. The transcript stays mounted with `visible: !fileEditPending` — OpenTUI maps that to Yoga's `Display.None`, removing the box from layout entirely so the sibling modal claims the slot via its own `flexGrow: 1`. Memoized turn anchors, scrollbox position, and lazy markdown chrome all survive the open → close round-trip. The prompt + footer stay anchored at the bottom (queue-only while `busy`). `ChatScreen` pushes an empty `<></>` placeholder onto the global modal context so `useModalAwareFocus()` keeps releasing background focus and the app-level `esc` / `ctrl+s` shortcuts stay gated; the actual UI is sibling, not stacked.
195
+ - **`SingleEditApprovalModal`** — diff panel + action bar (`a`/`s`/`p`/`d` = accept-once / accept-session / accept+remember / deny, `←`/`→`/`tab` navigate, `↵` confirm, `esc` deny). Split-pane at ≥100 columns, unified below.
196
+ - **`MultiEditApprovalModal`** (≥2 hunks) — per-hunk toggle list + focused-hunk diff + action bar. `↑`/`↓` move cursor, `space` toggles the focused hunk, `y`/`n` set all on/off, `tab` cycles list↔actions, same `a`/`s`/`p`/`d` bulk shortcuts. All-on → bulk decision; all-off → deny; mixed → `{kind: 'partial', mask}`.
312
197
 
313
- **Force-remount on queue advance**. The modal is keyed on `request.id` (`<FileEditApprovalModal key={fileEditPending.id} … />`). Without the key, `MultiEditApprovalModal`'s `mask` / `cursor` / `zone` and `SingleEditApprovalModal`'s `selected` would carry over from the previous call when the safe-mode queue pops one approval and exposes the next. React re-uses the component instance otherwise and the user would see a checkbox list whose state matches the prior modal.
314
-
315
- **Originator attribution**. The right-side title overlay appends ` · child-N` when `request.originator` is `{ kind: 'child', label: 'child-N' }`. `applyGate` builds the originator from `ctx.childId` (set by `BUBBLED_MUTABLE_EVENTS` for subagent-issued gates) and passes it as the third arg to `requestApproval`. Parent calls show no suffix.
316
-
317
- **Title layout**. Left-aligned title rides the top border (`edit approval` / `multi-edit approval`). The right-aligned filename + ` · child-N` + ` · N/M selected` suffix paints via a sibling `<text style={{ position: 'absolute', top: 0, right: 1 }}>` — OpenTUI's box scissor rect excludes the border row, so a child node can't paint over the top border (same trick `TitleOverlay` and `CompletionPopup` use). `rightTitleFilename(targetPath, termWidth, leftLen, suffixLen)` budgets the basename so the suffixes never get pushed off the right edge on a narrow terminal.
318
-
319
- **Lenient preview**. Both layouts run the focused hunk's diff through `previewEditPayload(payload, priorContent, contextLines)` from `zidane/chat` — the model-faithful resolver (curly-quote recovery, line-number-prefix stripping, model-side `<n>`→`<name>` desanitize) so what the user previews matches what the tool would actually apply. Unresolved hunks (resolver couldn't locate `old_string`, or matched ambiguously without `replace_all`) render an `UnresolvedHunkPanel` instead of a blank diff: red-bordered, headlined with the failure mode, showing the model's intended `old_string` / `new_string` so the user can deny intelligently. The multi-edit list flags the same rows with a red `⚠` glyph.
320
-
321
- `isFileEditTool(tool)` (exported from `file-edit-approval-modal.tsx`) is the gate routing predicate. The set is `{'edit', 'multi_edit', 'write_file'}`; everything else stays on the inline `ApprovalBlock` path. For a fully-denied file-edit call, `applyGate` skips the substitute path entirely — sets `ctx.block = true` + emits a synthetic `tool-result` event with body `[fully denied] <edit-outcomes>…</edit-outcomes>` for live display. The persisted result stays the terse `Blocked: User denied this tool call` the harness writes.
198
+ Inline-mounted in `ChatScreen`'s transcript slot (not stacked); keyed on `request.id` so the safe-mode queue popping the next approval force-remounts rather than reusing stale `mask`/`cursor` state. Title overlay appends ` · child-N` for subagent-issued gates. Unresolved hunks (resolver couldn't locate `old_string`) render a red-bordered `UnresolvedHunkPanel` instead of a blank diff. Full gate/annotation contract (partial-mask rebind, outcome merging, replay): [CHAT.md § Per-edit approval](./CHAT.md#per-edit-approval).
322
199
 
323
200
  ### Tool-call cancellation
324
201
 
325
- `CancelToolModal` (`src/tui/cancel-tool-modal.tsx`) surfaces the in-flight tool registry maintained by `App.tsx` and routes the user's pick to `agent.cancelTool(callId)`. The shortcut is `ctrl+k` (`cancelToolCall`), gated on `busy && !pendingApproval && inFlightTools.length > 0` so it never opens on a dead-empty state.
326
-
327
- In-flight tracking is hook-driven: `App.tsx` listens on `tool:before` / `child:tool:before` to add an entry, drains on `tool:after` / `tool:error` / `tool:cancelled` (mirrored for `child:*`). The Map is keyed by `callId` and the modal opens with a snapshot of the live set — auto-dismisses 500 ms after the list empties so the user isn't left staring at "nothing in flight".
328
-
329
- Modal row layout: `›` cursor glyph, tool name, truncated `callId`, optional `· child-N` attribution for subagent-issued calls, right-aligned elapsed time (`0.3s` / `2m12s` format). Keys: `↑` / `↓` navigate, `↵` cancels the focused row, `a` cancels every call in the snapshot, `esc` dismisses.
330
-
331
- Distinct from the run-wide `esc abort`: the cancelled call's wire result is the canonical `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`, the assistant turn closes normally on the next batch boundary, and other in-flight tools keep running. The session teardown path also clears `inFlightTools` so a switched session doesn't try to cancel calls on an agent that no longer owns them.
332
-
333
- ### Background-task completion banner
202
+ `CancelToolModal` lists the in-flight tool registry (`App.tsx` tracks it via `tool:before`/`tool:after`/`tool:error`/`tool:cancelled`, mirrored for `child:*`), opened by `ctrl+k` when `busy && !pendingApproval && inFlightTools.length > 0`. Row: `›` cursor, tool name, truncated `callId`, optional `· child-N`, elapsed time. `↑`/`↓` navigate, `↵` cancels the focused row, `a` cancels every call, `esc` dismisses. Auto-dismisses 500ms after the in-flight list empties. Cancellation resolves that call with `TOOL_USE_CANCELLED_MESSAGE` (`isError: true`); other in-flight tools keep running — distinct from the run-wide `esc abort`.
334
203
 
335
- `shell({ run_in_background: true })` parks a process under a `bash_<n>` handle (see [SKILL.md "Background tasks"](./SKILL.md#background-tasks) for the full lifecycle). When the task exits — naturally, via `shell_kill`, or via `agent.destroy()` — the agent queues a `<task-notification>` block that arrives on the model's next turn AND surfaces in the transcript as a one-line banner.
204
+ ### TodosModal
336
205
 
337
- `TaskNotificationBlock` (`src/tui/components.tsx`) is the renderer. Layout: `⌁` glyph + task id + status (`exited 0` / `killed`) + duration (`4.2s` / `42m`) + absolute path to the log file. Status-accented:
206
+ `ctrl+t`, closes on `esc`. Header tally (`3 completed · 1 in progress · 2 pending`) mirrored on a right-aligned top-border badge via `Modal.rightTitle`. Falls back to the archived "last batch" snapshot when the live list is empty, with a `· last batch ·` note. Auto-scrolls the first live `in_progress` row into view; capped between 12 and 36 rows (~2/3 of terminal height). Subscribes to `tool:after` (filtered to `todowrite`) to stay live while open. Data contract (keying, auto-clear, archive): [CHAT.md § Todos](./CHAT.md#todos).
338
207
 
339
- - `exited` with code 0 → dim (success is quiet).
340
- - `exited` with non-zero code OR `killed` → warn (model should pay attention).
208
+ The above-prompt "in progress" bar this modal replaced is gone — the same signal now renders as a footer hint chip (`N/M todos`, from `todoProgressLabel`), gated on `Settings.showTodoIndicator`, clickable to open the modal. Every `todowrite` call in the transcript also gets an inline "what's in progress" sub-list under the formatted call line (reads that call's own `input.todos`, not live session state).
341
209
 
342
- The `app.tsx` host wires `behavior.tasksDir = resolveTasksDir({ userDir, sessionId })` at session activation so the framework knows where to put log files. Without this, the shell tool surfaces a clean error to the model when it tries to spawn a background task.
210
+ ### ArtifactsModal
343
211
 
344
- On replay (session reload), `eventsFromTurns` re-synthesizes `'task-notification'` `StreamEvent`s from the persisted `<task-notification>` XML in user-turn text blocks — the raw XML block is dropped from the user-prompt stream so the banner doesn't double-paint alongside it. Field decoding is tolerant: missing tags fall back to defaults so older transcripts pre-dating a field still render cleanly.
212
+ `ctrl+shift+a` (`openArtifacts`) or `/artifacts` (`/artifacts project` for project scope) — what the session produced: pull requests, branches, commits, created files, extension-contributed kinds. `tab` toggles session ↔ project scope, `↵` opens the focused row's href (browser/editor), `c` copies its locator, `j` jumps to the producing turn (session scope only), `d` removes it. Live via a `subscribe` tick (local `artifact:recorded` hook, or daemon `artifact:recorded`/`artifact:changed`) so artifacts created while the modal is open appear without a re-open. Extension `renderDetail` panes mount under the list inside an error boundary. Semantics + extension API: [ARTIFACTS.md](./ARTIFACTS.md).
345
213
 
346
- ### Active skills — footer chip + pinned-skills persistence
214
+ ### ContextPanelModal
347
215
 
348
- When at least one skill is active, the footer hint row gets a passive `✦ N skill(s)` chip (`COLOR.brand` accent). The chip is purely informational — no shortcut wired — but it gives the user a passive surface for "tool restrictions are in effect" without having to scroll back through the transcript looking for a `skills_use` call. Driven by `activeSkillNames: ReadonlySet<string>` in `App.tsx`, fed by the agent's `skills:activate` / `skills:deactivate` hooks.
216
+ `ctrl+shift+g`, `esc` closes. Read-only, fetched on open via `agent.getContextBreakdown(...)`. One stacked bar (per-category segment + autocompact-buffer zone pinned at the tail + free space) above per-category rows (swatch, label, tokens, percent — `~` prefix when estimated). Also renders active-skills, last-turn cache/fresh/output split, and a usage-by-model rollup. Expandable rows (MCP tools, rule files) toggle with `↵`/`space`. Contract: [CHAT.md § Context-usage breakdown](./CHAT.md#context-usage-breakdown).
349
217
 
350
- Slash-command activations (`/skill-name`) call `agent.activateSkill()` programmatically, BEFORE `agent.run()`. The framework deactivates every active skill at run-end (`reason: 'run-end'`) and the session-resume rehydrator only scans for `skills_use` `tool_call` blocks — so a slash activation would silently lose its `allowed-tools` enforcement on the next prompt. The TUI fixes this with a **pinned-skills set persisted to session metadata** under `zidane.activeSkills`:
218
+ ### UsagePanelModal
351
219
 
352
- - The `skills:deactivate` listener filters by reason — `'run-end'` keeps the entry, `'model'` / `'explicit'` / `'reset'` drain it. So a user-driven `/skill-name` stays pinned across the framework's per-run cleanup; a model-driven `skills_use({ mode: 'deactivate' })` correctly releases the pin.
353
- - `onSubmitPrompt` activates the union of the current prompt's `/skill` refs AND every name in the pinned set before `agent.run()`. So a `/read-only` typed once stays in force across every subsequent message in the session.
354
- - `activateSession` seeds the React state from `session.metadata['zidane.activeSkills']` on session open — so the pin survives a TUI restart. `readPinnedSkills` is tolerant (older sessions, hand-edited metadata degrade to "no pins" rather than throwing).
355
- - `persistPinnedSkills` mirrors every state change back into metadata via `session.setMeta(...)`, sorted for stable on-disk ordering.
220
+ `ctrl+p` or click the footer's `▰ 5h 43%` segment; `esc` closes, `ctrl+r` refetches. One section per authenticated provider that reports usage (active provider first): `label · plan`, then a row per rate-limit window (bar, `used%`, reset ETA or credit balance). A failed probe still shows a `not authorized`/`unavailable` section rather than disappearing. Gated by `Settings.subscriptionUsage` (network) and `Settings.showUsageIndicator` (footer display only — the panel still opens and `ctrl+r` still works with the indicator off). `ZIDANE_NO_USAGE_CHECK`/`CI` disable outright. Contract: [CHAT.md § Subscription usage](./CHAT.md#subscription-usage).
356
221
 
357
- The teardown path also clears the in-memory pinned set on session swap (`setActiveSkillNames(new Set())`) so the next session starts from its own persisted state, not the previous one's residue.
222
+ ## Theme + code highlighting
358
223
 
359
- ### Todos surface — indicator + modal
224
+ Theme switching is real-time: every `useColors()`/`useSelectStyle()`/`useSurfaces()`/`useMdStyle()` consumer re-renders on Settings → Theme. `MdStyleProvider` builds one shared `SyntaxStyle` per theme (`buildMdStyle`) so a 50-block transcript doesn't re-allocate on every switch; `ChipStyleProvider` does the same for the live textarea's per-provider chip pills.
360
225
 
361
- Two thin chrome layers on top of the renderer-agnostic `useActiveTodos` hook (see [Todos in CHAT.md](./CHAT.md#todos) for the data contract — the keying rule, the auto-clear behavior, and the legacy-bag migration). Both surfaces share `TODO_STATUS_GLYPHS` so a row in the modal and the inline indicator read as the same visual language.
226
+ Fenced code blocks highlight via Tree-sitter (`src/tui/tree-sitter.ts`). Registered grammars: `python`, `bash`(`sh`/`shell`/`zsh`), `json`, `rust`(`rs`), `go`(`golang`), `yaml`(`yml`), `html`(`htm`), `css`, `haskell`(`hs`), `c`, `cpp`(`c++`/`cxx`/`cc`), `csharp`(`cs`/`c#`), `elixir`(`ex`/`exs`), `ruby`(`rb`), `diff`(`patch`), `scala`(`sbt`/`sc`), `julia`(`jl`), `ocaml`(`ml`), `r`, `perl`(`pl`/`pm`), `swift`, `kotlin`(`kt`/`kts`), `fsharp`(`fs`/`fsx`/`fsi`), `sql`(`psql`/`mysql`/`sqlite`/`plsql` — vendored into the TUI binary via `ZIDANE_LOCAL_TREE_SITTER_SQL_WASM`), plus whatever OpenTUI ships by default (TS/JS/JSX/TSX). `registerTreeSitterParsers()` runs synchronously at boot; `initTreeSitterWorker()` warms the worker off the critical path. Wasms download lazily and cache under OpenTUI's data path; failures fall back to plain text.
362
227
 
363
- - **`TodoIndicator`** (`src/tui/todo-indicator.tsx`) — single-line `◐ <in-progress content>` badge mounted between the queue block and the prompt in `ChatScreen`. Hides itself (returns `null`) when there's no session, no active run, no `in_progress` item, the terminal is too narrow to render a meaningful tail, or `Settings.showTodoIndicator` is off. Read-only — not focusable, not clickable. Truncates multi-line / oversized content to one visual line so the chat zone stays calm.
364
- - **`TodosModal`** (`src/tui/todos-modal.tsx`) — opened via `ctrl+t` (`openTodos`), closes on `esc`. Renders the per-status tally header (`3 completed · 1 in progress · 2 pending`) plus a scrollable list of rows. A right-aligned top-border badge mirrors the live counts (`N in progress · M completed · …`) — populated via the new `Modal.rightTitle` slot (absolutely-positioned sibling that rides the top border, same scissor-rect trick as `TitleOverlay` / file-edit modal). Falls back to the archived "last batch" snapshot when the live list is empty (e.g. after the model marked everything `completed` and the slot auto-cleared) with a subtle `· last batch ·` banner so the user can still see what was just finished. Auto-scrolls the first live `in_progress` row into view on open. Capped at ~two-thirds of the terminal height with a floor (`12`) and ceiling (`36`) so the modal never overpowers the transcript. Layout containment matches the file-edit modal — `flexGrow: 1` on the scrollbox + `overflow: 'hidden'` on the panel — so a long list scrolls cleanly without painting over the action footer. The modal subscribes to the agent's `tool:after` hook (filtered to `todowrite`) so it reflects checkpoints written while it's open — `<ModalRoot>` sits above `<AppShell>` in the React tree and doesn't re-render on the streaming-buffer cascade, so the explicit subscription is what drives reactivity.
228
+ `Settings.toolCallDisplay` picks the `'tool'` event rendering (`'hidden'` / `'formatted'` (default) / `'full'`); `Settings.showEditDiffs` renders `edit`/`multi_edit`/`write_file` as a unified diff via `EditDiffBlock` regardless of that mode, with per-hunk applied/denied/skipped/failed badges when outcomes are present. Both are engine-shaped contracts documented in [CHAT.md § Tool call display](./CHAT.md#tool-call-display) and [§ Edit-diff rendering](./CHAT.md#edit-diff-rendering) — this doc only owns the OpenTUI rendering (`<EventLine>` dispatch in `components.tsx`, diff colors from `theme.surfaces.diff`).
365
229
 
366
- Both surfaces resolve "what list to show" via `useActiveTodos(session)`, which carries both the live `todos` (model-facing) and the `archive` (sidecar — see [Todos in CHAT.md](./CHAT.md#todos) for the archive contract). The indicator only renders when there's a live `in_progress` item (an archived in-progress item is by definition stale and hidden). The modal renders `todos` when non-empty, falling back to `archive` when the live slot has been auto-cleared.
230
+ ## Transcript rendering
367
231
 
368
- ### Notice rows — harness events in the transcript
232
+ **Subagents.** A subagent run emits `spawn-start`/`spawn-end` + tagged child events (`depth > 0`, `childId`). `Settings.hideSubagentOutput` (default on) collapses the run to `🌱 child-1 · task` … `child-1 · done N in / M out`. When off, `SubagentBlock` wraps consecutive child events in one bordered, brand-tinted box; single-subagent runs drop the per-row `child-N` label (box title already carries it), multi-subagent runs keep it. Reload (`eventsFromTurns`) reproduces the same tagged events from persisted `parentRunId` chains. Every `'tool'`/`'tool-result'` event carries `StreamEvent.callId` for call↔result pairing — see `ARCHITECTURE.md` § `StreamEvent.callId` pairing contract.
369
233
 
370
- Things the *harness* did, as opposed to things the model said: a run error, a model fallback, a repeat-guard trip, history repair, a stop-reason explanation. Before these rows existed each producer hand-rolled its own emoji prefix and prose, errors dumped a raw multi-line provider message into the transcript, and none of it survived a reload.
234
+ **Notice rows.** Harness-originated events (run error, model fallback, repeat-guard trip, stop-reason explanation) render via `NoticeRow`: `<glyph> <label> <message>`, clipped to one line (`+N lines` for overflow), stacked tight. Persisted as a display-only `command` turn so a reload replays the same row. Progress banners and hot-reload chatter pass `persist: false`. Shape + persistence contract: [CHAT.md § `StreamEvent`](./CHAT.md#streamevent).
371
235
 
372
- - **Shape.** `StreamEvent.notice = { severity: 'info' | 'warn' | 'error', label }` on an ordinary `info` / `error` event. An event without it keeps the previous free-text rendering, which is also why the GUI timeline (it switches on `kind`) needs no change.
373
- - **Row.** `NoticeRow` (`src/tui/components.tsx`) draws `<glyph> <label padded to 15> <message>`: glyph and label in the severity color (`error` / `warn` / `mute`), message dim. Single-width glyphs only (`✗ ! ·`) because the emoji they replaced render two cells wide in some terminals and one in others, which an aligned gutter cannot absorb. The row is clipped, never wrapped, so a 400-character provider error stays one line; a multi-line message collapses to its first line plus `+N lines`, with the full text still persisted. Consecutive notices stack tight (`marginTopFor`), so a burst reads as one block.
374
- - **Persistence.** `emitNotice` (`app.tsx`) pre-mints a `turnId`, appends the live event, and writes a display-only `role: 'command'` turn carrying `metadata.command.notice` (`noticeTurnContent` in `chat/store.ts`). `eventsFromTurns` replays it into the same event, so the reloaded row IS the live row. Holding that invariant takes two exemptions from the surrounding command-turn handling: the marker also carries the live `childId` / `depth` (a subagent notice is persisted by the parent's depth-0 run, so the run-derived tag alone would reload it flat), and a notice-only turn skips the delimiting `separator` (live there is none, and `marginTopFor` collapses consecutive notices, so a separator would reload a burst as a double-spaced ladder). A malformed marker degrades to an ordinary `command-detail` row rather than throwing.
375
- - **Session scoping.** Callers whose work outlives a session switch (run hooks, awaited command handlers) pass their own `session`, captured when the work started, and `emitNotice` splits the two halves: the live row is skipped when that session is no longer the open one, while the turn is still persisted to the run's own session. A run keeps streaming after the user opens another conversation, so without the first half its late fallback or error row lands in the wrong transcript and appends a durable turn that replays there forever; without the second half a run that fails while you are looking elsewhere leaves no record anywhere, which is exactly when one is worth the most. Reopening the original session replays the row in place.
376
- - **What is not persisted.** Progress banners (`compact:start`), extension hot-reload chatter, and the system-prompt-override warning pass `persist: false`: after a reload they are either meaningless, duplicative of a boundary card that persists on its own, or would accumulate one copy per load.
377
- - **Repeat guard.** `repeat-guard:exceeded` emits once per `(tool, action)` per run: one row when the guard first blocks and steers, one when it aborts. Without it the intervention is invisible, because a gate-blocked call never reaches `tool:before` and a guard abort otherwise looks byte-identical to the user pressing esc. **Parent runs only** — `repeat-guard:exceeded` is not in `BUBBLED_EVENTS` (`src/tools/spawn.ts`), so a subagent's trip reaches no bus the TUI listens on and produces no row; an aborted child shows up only as the generic `subagent` error row from `spawn:error`. Closing that gap means bubbling the event as `child:repeat-guard:exceeded` and hooking it separately, where the ctx would carry the `childId` / `depth` `emitNotice` already accepts.
236
+ **Background-task banner.** `TaskNotificationBlock` renders `⌁ <task id> <exited 0 / killed> <duration> <log path>` when a `shell({run_in_background: true})` task ends — dim for a clean exit, warn otherwise. Reload re-synthesizes the banner from the persisted `<task-notification>` XML (dropped from the visible prompt text so it doesn't double-paint). Lifecycle: [RUN_IN_BACKGROUND.md § Background tasks](./RUN_IN_BACKGROUND.md#background-tasks).
378
237
 
379
- ### Context-usage panel
238
+ **Scrolling.** `<scrollbox stickyScroll stickyStart="bottom">` auto-pins to the bottom while streaming, detaches on manual scroll-up, reattaches at the bottom edge. Selecting a turn (`selectedTurnId`) additionally calls `scrollChildIntoView` via `requestAnimationFrame`. **Bounded**: `capTranscriptEvents` trims the live display to the newest ~1,200 events (cap 1,500, turn-boundary-safe) with a "N earlier items hidden" marker — disk stays complete; tune via `StreamBufferOptions.maxTranscriptEvents`. **Large markdown** (>~24 KiB) is chunk-rendered at fence/list-safe boundaries (`chunkMarkdownForRender`) because marked's lexer is quadratic over paragraph-heavy input. `Settings.smoothStreaming` drip-feeds streamed text at a smooth cadence instead of in bursts.
380
239
 
381
- - **`ContextPanelModal`** (`src/tui/context-panel.tsx`) — opened via `ctrl+shift+g` (`openContextPanel`), closes on `esc`. Read-only, on-demand: the `App.tsx` handler calls `agent.getContextBreakdown({ model, effectiveWindow, autocompactBuffer, compactThreshold })` (see [Context-usage breakdown in CHAT.md](./CHAT.md#context-usage-breakdown)) and opens the modal with the result — guarded so a stale async resolve (session switched mid-fetch) doesn't pop a panel for the wrong agent. Renders a single full-width stacked bar (one fixed-hue segment per live category, the autocompact-buffer zone pinned at the tail from the threshold to the window end, free space as the remaining track) above per-category rows (swatch · label · token count · percent). Estimated rows carry a `~` prefix; the footnote `~ estimated — exact counts unavailable for this provider` shows only when ≥1 live category is heuristic. Below the categories: an "active skills" section (informational — an activated skill's body lives in the conversation, already counted there) a "last turn" section with the exact cache-read / cache-write / fresh-input / output split from `TurnUsage`, and a "usage by model" section (session-wide per-model turn/token/cost totals across parent + subagent runs, via `usageByModel` from `zidane/chat`; `≥` marks costs floored by turns with no known rate; hidden while the session has no runs). Expandable rows (MCP tools, per-rule files) toggle with `↵`/`space`; `↑↓` move the cursor across expandable groups. The panel works before the first message via the agent's skills-only pre-run assembly. The bar palette is shared verbatim with the GUI popover so both surfaces read identically.
240
+ **Select-turn mode.** `ctrl+s` (idle, ≥1 turn, no pending approval/interaction): textarea blurs, `↑`/`↓` cycle `selectableTurnIds(events, settings)`, the row fills with `theme.surfaces.selection`, `↵` opens `TurnDetailsModal`, `esc` exits and re-engages sticky-bottom.
382
241
 
383
- ### Subscription-usage panel
242
+ ## Settings modal
384
243
 
385
- - **`UsagePanelModal`** (`src/tui/usage-panel.tsx`) opens via `ctrl+p` (`openUsagePanel`) or by clicking the footer's `▰ 5h 43%` segment, closes on `esc`; `ctrl+r` refetches. One section per authenticated provider that exposes usage (see [Subscription usage in CHAT.md](./CHAT.md#subscription-usage)), the **active** provider sorted first and marked `active`. Each section renders `label · plan` then one row per rate-limit window: 12-cell `▰▱` bar, short window label, `used%`, then either `resets in 2h14m` or, for a credits window, the remaining balance (`20.00 / 25.00`). Providers whose credits sit alongside rate-limit windows (Codex) get a separate `credits` row. A provider whose probe failed keeps its section and shows `not authorized · <reason>` or `unavailable · <reason>` rather than disappearing, so a broken token is visible instead of silent. Percent thresholds match the footer's `ctx` gauge (warn ≥60%, error ≥85%), and reset labels round down at every step (36h reads `1d`), like the footer percent: overstating the headroom left is the wrong direction to be wrong in.
386
- - **Opt-outs.** `Settings.subscriptionUsage` (Agent tab, default on) gates every automatic probe: the footer indicator stops fetching and the panel opens on `usage checks are off · ctrl+r fetches once`, so an explicit refresh still works (that line is replaced by the ordinary empty state once a probe has actually run, so the user can tell the fetch happened). `ZIDANE_NO_USAGE_CHECK` / `CI` (any truthy value, same rule as `ZIDANE_NO_UPDATE`) disable the feature outright, including that refresh; the panel then says so and drops the `ctrl+r` hint. `ctrl+r` is ignored while a probe is in flight, so key repeat can't fan out one request per provider per keypress.
387
- - **`Settings.showUsageIndicator`** (UI tab, default on) hides the footer gauge only; `ctrl+p` still opens the panel, same contract as `showTodoIndicator`. The footer hook exists solely to feed that gauge, so it stands down with it and the panel's on-open fetch becomes the only caller. This is the display switch; `Settings.subscriptionUsage` (Agent tab) is the network switch and decides whether providers are asked at all.
388
- - **The panel loads its own data.** `<ModalRoot>` sits above `<AppShell>`, so a parent-owned snapshot would freeze at open time and `ctrl+r` would have nowhere to write; the modal calls `useAllProviderUsage` itself (`src/tui/use-provider-usage.ts`). The footer indicator uses the sibling `useActiveProviderUsage` hook, which fetches on provider switch and on run completion (throttled to ≥15s apart) and **never polls on a timer**, so an idle TUI issues no requests. Both hooks read through the shared 60s cache in `chat/provider-usage`, so opening the panel right after a footer refresh costs nothing.
244
+ Tabbed: **Agent**, **UI** (always present) + **Keybindings**, **Authentication**, **Instructions** (present when the host wires the matching data props) + **Skills**, **MCP servers** (always) + **Extensions** (present when extensions are discoverable). One shared search input filters the active tab; `←`/`→` cycle tabs, `↑`/`↓` navigate, `↵` toggle/cycle/pick/edit.
389
245
 
390
- In addition, every `todowrite` tool call in the transcript renders a small "what's in progress" sub-list directly beneath the formatted call line. The sub-list reads the call's `input.todos` payload (the checkpoint the model just wrote), filters to `status === 'in_progress'`, and prints one indented `◐ <content>` row per item. Hidden when the payload has no in-progress items (e.g. all-completed close-out) so the transcript stays tight. This is purely declarative — it reads the event's own payload, not session state, so it's stable across re-renders and matches what the model intended at that checkpoint (not the latest list state, which the indicator + modal already surface).
246
+ - **Agent / UI** rows are toggle/choice/action, sourced from `SETTINGS_TOGGLES`/`SETTINGS_CHOICES`/`SettingsActions` in `src/chat/settings-context.tsx` — current keys, labels, categories: [CHAT.md § Settings](./CHAT.md#settings).
247
+ - **Keybindings** embeds the `KEYBINDING_DEFS` catalog (shared with the standalone `KeybindingsModal` via `groupBindings`); `↵` opens `keybindings.json`.
248
+ - **Authentication** shows the active + detected providers; `↵` pops the full-screen `AuthScreen`.
249
+ - **Instructions** answers "what's in my context, and where from" — one row per `ContextSegment` (base prompt, each `AGENTS.md`/`CLAUDE.md`, skills catalog, MCP instructions, subagent defs), labelled by origin (`built-in`/`file`/`generated`/`mcp server`). `tab` expands inline, `↵` opens file-backed rows in `$EDITOR`. The `SYSTEM.md` row's `↵` **creates** the override file (seeded from the doctrine-only prompt) when none exists — the one Instructions action that writes. Full contract: [CHAT.md § `SYSTEM.md`](./CHAT.md#systemmd--overriding-the-built-in-prompt) and [§ Context segments](./CHAT.md#context-segments).
250
+ - **Skills / MCP servers / Extensions** render the discovered catalogs as enable/disable checklists (`enabledSkills`/`enabledMcps` default to "everything enabled"; toggling seeds an explicit allowlist so newly-discovered entries don't silently opt in). Extension semantics: [EXTENSIONS.md](./EXTENSIONS.md).
391
251
 
392
- Because top-level runs share `session.metadata.todos.session`, a list written in one prompt **stays visible across the next prompt** — the user aborts mid-task, sends a follow-up, and the indicator + modal continue to show the same list until the model rewrites or auto-clears it. Subagent runs surface their own slot when they're the live run (`pickActiveRunId` picks the latest running run); when a subagent completes, the active resolution reflows to the parent's session slot on the next paint.
393
-
394
- `ChatScreen` forwards the live `Session` reference through a separate `liveSession` prop (distinct from the lightweight `SessionMeta` snapshot) because the hook reads `session.metadata.todos` directly; the snapshot path would miss mutations that don't change message identity. The reference stays stable across activations of the same session — re-renders are driven by the existing `events` cascade on every tool result.
395
-
396
- `Settings.showTodoIndicator` (default `true`) hides only the inline indicator. The modal is always accessible — opening it is an explicit user gesture.
397
-
398
- ## Settings rows
399
-
400
- The Settings modal is tabbed. The **Agent** and **UI** tabs each render three row kinds (toggle / choice / action) sourced from the chat-level tables, filtered by their `category` field. The **Keybindings** tab embeds the action catalog from `chat/keybindings.ts` (shared with the standalone `KeybindingsModal` via `groupBindings` + `KEYBINDING_KEY_COL_WIDTH`); `↵` opens the JSON file. The **Authentication** tab shows the active provider + a list of detected ones (from `detectAuth`); `↵` pops the full-screen `AuthScreen` (the multi-step wizard doesn't embed cleanly inside a modal). **Skills** and **MCP servers** render the discovered catalogs as enable/disable checklists.
401
-
402
- The **Instructions** tab answers "what text is in my context, and where did it come from". Each row is a `ContextSegment` (`chat/context-segments`) — the base system prompt, each discovered `AGENTS.md` / `CLAUDE.md`, the skills catalog, the MCP instructions block, the subagent definitions — labelled with its origin (`built-in` / `file` / `generated` / `mcp server`) and heuristic token size. `tab` expands a row to read its text inline (clamped to `INSTRUCTION_PREVIEW_LINE_CAP` lines); `↵` opens the source in `$EDITOR`, but only for `origin: 'file'` rows — the other origins have no file, and the tab says so rather than no-op'ing. The shared search box matches each segment's full body, so it doubles as "which instruction source told the agent this?".
403
-
404
- The data is split by who knows what: the agent supplies the segments it assembled via `agent.getContextSegments()`, while the host supplies the instruction files (path **and** text) it loaded at session activation — the agent only ever sees the rendered rules block as an opaque substring of the system prompt, so it can't attribute text back to a path. `App` holds those files in `sessionInstructionFilesRef`, captured alongside the prompt they were rendered into, so the tab reports what the session is actually running on rather than what happens to be on disk now. Because `SettingsModal` is opened with snapshot props, the fetch is an *action* (`actions.getContextSegments`) that the modal calls on every activation of the tab and whose loading / error state it owns; wiring that action is what makes the tab appear.
405
-
406
- **`SYSTEM.md` override.** When a `SYSTEM.md` is active (see `docs/CHAT.md`), the System prompt row reports `origin: 'file'` and `↵` opens it like any other file row. When none exists, `↵` **creates** `~/.{prefix}/SYSTEM.md` seeded with the doctrine currently in effect and opens that — the one Instructions action that writes, so the footer hint reads `create SYSTEM.md override` rather than `open file`. Creation is idempotent (an existing file is opened, never clobbered) and the seed makes it a behavioral no-op until edited. A `SYSTEM.md` that exists on disk but isn't loaded — beaten on precedence, or found on a profile that ignores overrides — is reported in a note above the list, so editing an inactive file doesn't look ignored for no reason.
407
-
408
- Two things the seed gets right, both of which were bugs worth naming:
409
-
410
- - **It seeds from the DOCTRINE-ONLY prompt** (`composeHostContext().doctrineOnlySystem()`), not from the composed one. The composed prompt already contains the rendered `AGENTS.md` block in its static half, and an override composes *above* a freshly-appended copy of that block — so seeding from it would duplicate every instruction on the next session.
411
- - **It refuses on profiles that can't be overridden.** `composeHostContext` returns `system: null` for any profile that isn't `build` or `plan`, which would seed a comment-only file. That file is still a *valid* override, so switching to `build` later would silently reduce the entire doctrine to an HTML comment. The create path throws instead, and the row explains that the profile supplies its own prompt.
412
-
413
- `supportsOverride` also gates the hint and the row action, so a profile that ignores overrides never advertises a create action or claims a file is the prompt's source.
414
-
415
- **`ctrl+r` reload.** Matches the refresh key on the Skills and MCPs tabs. Re-discovery is implicit — `composeHostContext` hits the filesystem on every call, so re-fetching picks up new files — and the real work is `onRefreshInstructions` rebuilding the live agent through the existing `rebuildActiveAgent()` so a running session adopts the new prompt without a TUI restart. While a run is in flight the rebuild is deferred onto `pendingAgentRebuildRef` and applied by `drainFollowUps` once the turn ends, because swapping the system prompt underneath a streaming turn is not safe. The row notice reports which happened (`agent rebuilt` vs `rebuild queued until the current run finishes`) since the consequences differ.
416
-
417
- A mid-session rebuild costs more than it looks like it should, which is why this is an explicit keypress rather than a filesystem watcher:
418
-
419
- - It replaces the system prompt on an existing conversation, guaranteeing a **prompt-cache miss** on the next turn, and the model sees different doctrine mid-conversation.
420
- - `rebuildActiveAgent` destroys the stale agent, which **closes its MCP connections**. The fresh agent reconnects lazily on its next run, so the turn after a reload pays that reconnect. The notice says so (`MCP servers reconnect on the next turn`).
421
-
422
- **No active session.** Settings opens on every screen but `auth`, so the tab is reachable from home / sessions / model-picker before `agentRef.current` exists. The action then composes a preview of the *next* session instead of reporting an empty context: `composeHostContext(profile, allowInteraction)` — the same helper `buildAgent` uses at activation, so the two can't drift — supplies the system prompt and the discovered instruction files, and `buildCatalog` over the enabled discovered skills supplies the catalog. The result carries `live: false` and the tab renders a note, because this view necessarily omits what the agent appends at run time (MCP instructions, extension system fragments, subagent definitions) and extension-contributed skills, which are only merged during `buildAgent`.
423
-
424
- | Kind | Source | Activation |
425
- |---|---|---|
426
- | `toggle` | `SETTINGS_TOGGLES` (`BooleanSettingKey`) | `enter` flips the boolean |
427
- | `choice` | `SETTINGS_CHOICES` | `enter` cycles to the next option (wraps) |
428
- | `action` | `SettingsActions` (host-supplied) | `enter` invokes the callback |
429
- | `skill` | `skillsCatalog` prop | `enter` toggles `enabledSkills` |
430
- | `mcp` | `mcpsCatalog` prop | `enter` toggles `enabledMcps` (+ `ctrl+L` login / `ctrl+O` logout when applicable) |
431
- | `mcp tool` | per-server tool catalog from `mcp-tools-cache.json` (mirrored into `DiscoveryContextValue.mcpToolsByServer`) | `tab` expands / collapses the parent server row, `enter` toggles `Settings.disabledMcpTools[serverName]` — the deny-list is unioned with the JSON-side `disabledTools` in `buildMcpServers` |
432
-
433
- `App` wires two general actions: `Keybindings` (open `keybindings.json` in `$EDITOR`) and `Authentication` (open `AuthScreen`). Authentication is gated on `!busy && !pendingApproval` so re-config can't tear down an in-flight run.
434
-
435
- **Editor launching.** `launchEditor(path)` resolves `$VISUAL` / `$EDITOR`, falling back to the platform opener (`open` / `xdg-open` / `cmd /c start`). Those variables hold a command *line*, not a bare executable — `cursor --wait`, `code --wait`, `subl -w` — so `parseEditorCommand` (`src/tui/editor-command.ts`, quote-aware, no backslash escaping so Windows paths survive) splits the flags off and keeps them before the path. The promise resolves on the child's `'spawn'` event and rejects on `'error'`: `child_process.spawn` reports ENOENT through an event rather than a throw, so without that funnel a missing binary escaped every caller's `try`/`catch` as an unhandled emitter error and crashed into OpenTUI's error console. Callers therefore get a real rejection — the Instructions tab renders it as a row notice, the keybindings action logs it under `ZIDANE_DEBUG`.
436
-
437
- A single shared search input filters the active tab's list (`label + description` for general items, `name + description` for skills, `name + transport + command/URL` for MCP servers). Typing resets the cursor on the active tab to 0; tab switches (`←/→`) preserve each tab's cursor (clamped against the current filtered size).
438
-
439
- For the live source of truth, see `SETTINGS_TOGGLES` / `SETTINGS_CHOICES` in `src/chat/settings-context.tsx`. The current shape includes:
440
-
441
- - **Toggles** — `safeMode`, `resumeLastSession`, `hideSubagentOutput`, `persistToolResults`, `autoCompact`, `showAllProjects`, `showThinking`, `showToolResults`, `showFullShellOutput`, `showEditDiffs`, `smoothStreaming`, `showTodoIndicator`, `showUsageIndicator`, `showThrobber`.
442
- - **Choices** — `interactionMode` (Ask normally / Skip interactions / Disable tools), `toolCallDisplay` (Formatted / Full / Hidden), `autoCompactThreshold` (60% / 70% / 80% / 90%), `theme` (every entry in `BUILTIN_THEMES`), `targetFps` (30 / 60 / 120).
443
-
444
- `enabledSkills` / `enabledMcps` deliberately default to `undefined` (every discovered entry enabled). The Settings modal seeds the allowlist with the current discovery on first toggle so newly-added entries don't silently flip on — the user opts them in.
252
+ Reachable from every screen but `auth`; with no live agent yet (home/sessions/model-picker), the Instructions tab composes a preview of the *next* session (`live: false`) via the same `composeHostContext` helper `buildAgent` uses.
445
253
 
446
254
  ## Completion + slash-commands
447
255
 
448
- The TUI ships two providers out of the box — **skills** (`/`) and **files** (`@`). Typing the trigger above a textarea opens a popover above the prompt block:
449
-
450
- ```
451
- ┌─ skills — 3 matches ──────────────────────────────────────────┐
452
- │ ▶ research in-depth research with citations │
453
- │ review code review with severity tags │
454
- │ plan write a plan before coding │
455
- │ │
456
- │ ↑↓ navigate · ↵ / tab select · esc close │
457
- └───────────────────────────────────────────────────────────────┘
458
- ┌──────────────────────────────────────────────────────────────┐
459
- │ /re| │
460
- └──────────────────────────────────────────────────────────────┘
461
- ```
462
-
463
- Skills (`/`) substring-match `name + description`; prefix matches sort first. Files (`@`) rank exact-name → name-prefix → name-substring → path-substring. Commit inserts `<trigger><value> ` and the cursor lands ready for the next token.
464
-
465
- On submit, `App` walks the buffer for references via every provider's `parseReferences(text)`. For skill refs it calls `agent.activateSkill(name)` (idempotent) before `agent.run()` — the slash-command stays in the prompt text so the model still sees it and the activation is just a self-documented hint. File refs flow through verbatim today (the host can attach contents downstream).
466
-
467
- References are highlighted in two surfaces:
468
-
469
- - **Live textarea** — `PromptBlock` calls `useChipHighlights(textareaRef, completion.references, chipStyle)`, which `clearAllHighlights` + `addHighlightByCharRange`s one range per ref on every change. JS string offsets are translated to display columns via `offsetToHighlightColumn` (newlines consume zero columns in the native API). `resolveChipStyleId(style, providerId)` performs the kind-specific → `chips.default` fallback on the styleId side; the echo path uses the paired `resolveChipColor(chips, providerId)` over the same fallback rule.
470
- - **Submitted echo** — `UserPromptBlock` walks the message via `splitPromptSegments` and renders each chip as its own `<text fg bg>` flex item, picking the bg/fg pair from `theme.surfaces.chips[providerId]` (or `chips.default`).
471
-
472
- Per-kind colors are theme-defined: `skills` rides the theme's brand color (yellow on default, mauve on Catppuccin, pink on Vaporwave); `files` rides the theme's `model`/secondary accent. Hosts can register their own provider with an arbitrary id — chips inherit `chips.default` until the host extends the theme's `chips` map.
473
-
474
- File discovery uses `git ls-files --cached --others --exclude-standard` when available so `.gitignore` is honored automatically. Non-repo cwds fall back to a depth-walked scan with a hard-coded skip set (`node_modules`, `dist`, `.git`, …). Cap: 10 000 files.
475
-
476
- Adding a third provider: build it in `zidane/chat` against `CompletionProvider<TItem>`, pass it alongside the others in `completionProviders` — the popup picks it up automatically.
477
-
478
- ## Transcript scrolling
479
-
480
- `<Transcript>` mounts a `<scrollbox>` with OpenTUI's native auto-pin: `stickyScroll` + `stickyStart="bottom"`. The scrollbox stays pinned to the bottom while content grows, and detaches the moment the user scrolls up — re-attaches as soon as they hit the bottom edge again. This is the path that keeps streamed text + tool results glued under the cursor while a run is busy without manual `requestAnimationFrame` glue in React.
481
-
482
- Two scroll effects layer on top, both keyed off React state:
483
-
484
- - **Auto-pin** (native): OpenTUI handles the `[items, busy]` → "stay at bottom" loop. No React work; survives a 60 fps stream without forcing re-layouts.
485
- - **`selectedTurnId` effect** (React): `useEffect([selectedTurnId, anchors, ownership])` calls `scrollbox.scrollChildIntoView(id)` via `requestAnimationFrame` so OpenTUI's layout pass has settled before measuring. Snaps to `scrollHeight` for the last turn (or a turn that owns the last result-only row) so a tall response's tail stays visible.
486
-
487
- The two coexist — selecting a turn jumps to it; exiting select-turn mode (`esc`) plus the next streamed delta re-engages the native sticky-bottom.
488
-
489
- **Scrollback is memory-bounded.** Every rendered transcript event keeps live renderables + native buffers even off-screen (~0.13MB RSS each, measured), so an unbounded array OOMs long sessions. `capTranscriptEvents` (`chat/streaming-pure.ts`) trims the display to the newest ~1,200 events (cap 1,500, turn-boundary-safe) with a cumulative "… N earlier transcript items hidden" marker at the top — session turns on disk stay complete, and re-activating the session replays the newest window again. Applied on every stream-drain commit and at every `eventsFromTurns` replay site; embedders can tune or disable it via `StreamBufferOptions.maxTranscriptEvents` (`null` = unbounded, pre-cap behavior).
490
-
491
- **Large markdown is chunk-rendered.** OpenTUI's `<markdown>` runs marked's lexer, which is quadratic over paragraph-heavy input (~6s for one 256KiB block). `MarkdownBlock` splits documents past ~24KiB into sibling renderables at fence-aware, list-safe blank-line boundaries (`chunkMarkdownForRender`) — visually equivalent, linear cost, single-element fast path for normal replies.
492
-
493
- `Settings.smoothStreaming` (default **on**) drip-feeds streamed text at a smooth cadence (typewriter) instead of in stream bursts; the auto-pin loop tracks each delta naturally because each tick still grows the scrollbox content.
494
-
495
- ## Select-turn mode
496
-
497
- Press `ctrl+s` on the chat screen (idle, ≥ 1 turn, no pending approval / interaction) to enter select-turn mode:
498
-
499
- - The textarea blurs; `↑` / `↓` cycle through `selectableTurnIds(events, settings)` (top-level only — `settings` decides whether result-only user turns are merged with their tool-call assistant turn).
500
- - The selected turn's entire row span fills with `theme.surfaces.selection` so it reads as one continuous block.
501
- - `↵` opens `TurnDetailsModal` for that turn (fork / delete / copy).
502
- - `esc` exits the mode; the next streaming delta re-engages sticky-bottom auto-scroll.
503
-
504
- The last turn snaps to the bottom of the scrollbox on selection so the tail of a tall response stays visible.
505
-
506
- ## Auto-compact UI
507
-
508
- When `Settings.autoCompact` is on, the TUI injects `behavior.autoCompact` into each `agent.run()` via `chatAutoCompactBehavior({ enabled, threshold, rawContextWindow })`. The SDK loop owns the trigger: when a turn's effective input (`input + cacheRead + cacheCreation`) crosses `Settings.autoCompactThreshold × effectiveContextWindow`, it runs the summary mid-run (or at end-of-run), appends the `compact-summary` marker, persists it, and fires `compact:start` / `compact:end`.
256
+ Two built-in providers: **skills** (`/`, substring match on name+description, prefix sorts first) and **files** (`@`, exact-name → prefix → substring → path-substring; `git ls-files --cached --others --exclude-standard` when in a repo, else a depth-walked scan capped at 10,000 files). Commit inserts `<trigger><value> `. On submit, `App` walks the buffer via each provider's `parseReferences(text)`; skill refs call `agent.activateSkill(name)` before `agent.run()` (slash text stays visible to the model).
509
257
 
510
- The TUI hook wiring:
258
+ References highlight in two places: the live textarea (`useChipHighlights`) and the submitted echo (`UserPromptBlock` via `splitPromptSegments`), both keyed off `theme.surfaces.chips[providerId]` (fallback `chips.default`). Adding a third provider: implement `CompletionProvider<TItem>` in `zidane/chat`, pass it in `completionProviders` — the popup picks it up automatically.
511
259
 
512
- 1. `compact:start` appends `🗜 Auto-compacting conversation (used N% of effective context)…` and lights the title-row `StatusSpinner` in `theme.colors.accent` (vs. `warn` for streaming).
513
- 2. `compact:end` on success reads the marker the loop just appended and emits the boundary card via `compactSummaryEvent(block, turnId)`, the same mapper `eventsFromTurns` uses on reload.
514
- 3. `compact:end` also updates the footer's `ctx` indicator to the post-compact effective-token estimate.
515
- 4. Abort/error paths clear the spinner; aborts stay silent because they are part of run cancellation.
260
+ ## MCP servers + OAuth
516
261
 
517
- There is no host-side in-flight ref, no background post-run compaction promise, and no pre-run barrier anymore. Cancelling / tearing down the run cancels the loop-native summary through the run's own `AbortSignal`.
262
+ Discovered servers (`mcps.json` + `mcp.json` under `.{prefix}/` or `.agents/`, project + user dirs; `mcps.json` wins name collisions) live as `DiscoveredMcp[]`. Each row carries a badge from the `mcp-auth-state` reducer:
518
263
 
519
- The `'compact-summary'` `StreamEvent` (carrying `compact: { replacedCount, model, compactedAt, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }`) renders as a boundary card; the user can still scroll above it to see the original turns — compaction never deletes from disk.
520
-
521
- ## Tool-result persistence
522
-
523
- `Settings.persistToolResults` (default **on**) lets the agent loop replace oversize `tool_result` outputs with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB head preview. Wire details:
524
-
525
- - `behavior.persistThreshold` defaults to 8 KiB on the built-in profiles; the TUI overrides to 0 when the user toggles persistence off.
526
- - `behavior.persistExcludeTools` = `DEFAULT_PERSIST_EXCLUDE_TOOLS` from `zidane/chat` (`read_file`, `tool_search`, `skills_use`, `skills_read`, `present_plan`, `ask_user`, `spawn`).
527
- - `behavior.persistDir` is `<userDir>/sessions/<sessionId>/tool-results/` (always pinned to user dir, never the project dir) — `resolvePersistDir` from `zidane`.
528
- - Flipping the toggle takes effect on the **next** session activation; in-flight streams aren't torn down. Persisted blobs already on disk stay readable via `read_file` on the stub's `path` attribute.
529
-
530
- ## MCP servers + OAuth login
531
-
532
- Discovered MCP servers (from `mcps.json` AND `mcp.json` under `.{prefix}/` or `.agents/` in both project + user dirs) live as `DiscoveredMcp[]` in `App`'s state. The two filenames merge per-name, with `mcps.json` winning collisions. Non-fatal parse errors surface as `DiscoveryError[]` in a warn-colored preamble on the Settings → MCP tab.
533
-
534
- Each row carries an OAuth status badge driven by the `mcp-auth-state` reducer:
535
-
536
- | Badge | Status | Meaning |
537
- |---|---|---|
538
- | `✓ authed` | `{ kind: 'authed' }` | tokens stored + bootstrap connected |
539
- | `! needs login` | `{ kind: 'needs-auth', reason: 'no-tokens' \| 'auto-promoted' }` | explicit OAuth config or 401 + RFC 9728 metadata detected |
540
- | `… authorizing` | `{ kind: 'authorizing', url? }` | interactive login in flight |
541
- | `✗ <error>` | `{ kind: 'error', error }` | last login attempt failed; message is verbatim |
542
- | (blank) | `{ kind: 'idle' }` | nothing to say (stdio server, or not yet bootstrapped) |
543
-
544
- Status state is read via `useMcpAuthState()` from `zidane/chat` so live updates propagate into open modals (the `<McpAuthProvider>` mounted above `<ModalRoot>` is what makes that work).
545
-
546
- Interactive login flow (triggered by `ctrl+L` on a needs-auth row in `SettingsModal`, or `l` in the standalone `McpsSettingsModal`):
547
-
548
- 1. App calls `loginMcpServer(config, { store, hooks, signal })` from `zidane/mcp`.
549
- 2. `startOAuthCallback` spawns a loopback `127.0.0.1` HTTP listener on a random port.
550
- 3. `McpOAuthProvider` is built with `redirectUri` pointing at the loopback; `client.connect()` triggers `redirectToAuthorization`, which reaches the host through `onAuthorizationUrl` (and fires `mcp:auth:url` for other listeners). `onLoginMcp` dispatches `auth-url` / `auth-success` / `auth-error` **directly** rather than reading them back off the agent hooks: `agentRef.current` is null before the first session activation and while `rebuildActiveAgent` swaps agents (the window the post-add auto-login runs in), and a hook-only path left the panel on a spinner with no URL, no copy button and no paste input — for a login that had already succeeded in the browser. The modal renders the URL via `<McpAuthorizingPanel>`; `tryOpenBrowser` opens it automatically.
551
-
552
- This phase is bounded by `connectTimeoutMs` (default 30s) and races the abort signal, because the SDK's `connect()` accepts no `AbortSignal` — without that, an unreachable server hangs pre-URL forever and `esc` can't reach it.
553
- 4. SDK throws `UnauthorizedError`; we wait on the callback for the `?code=...` redirect. The same `<McpAuthorizingPanel>` includes a paste-back input (step 2 of the panel) — submitting pipes the value through `fetchOAuthRedirect`, which `fetch()`s the loopback callback URL from inside the agent process and lands a `?code=...` on the listener exactly like a real browser would. This unblocks SSH sessions where the loopback redirect can't reach the remote server directly. The panel prints the exact `redirect_uri` the request was minted with (`redirectUriFromAuthUrl`) so there's no guessing about which address to copy out of the browser's URL bar, and it force-focuses the input on mount — `focused` as a prop alone leaves keys with whichever field owned focus before the takeover mounted.
554
- 5. `transport.finishAuth(code)` exchanges the code for tokens; `provider.saveTokens` persists via `createFileMcpCredentialStore(userDir)`.
555
- 6. Reconnect with the new tokens; `client.listTools()` returns the catalog.
556
- 7. App rebuilds the active agent so the now-authenticated server connects on the next run.
264
+ | Badge | Meaning |
265
+ |---|---|
266
+ | `✓ authed` | tokens stored, bootstrap connected |
267
+ | `! needs login` | explicit OAuth config or 401 + RFC 9728 metadata |
268
+ | `… authorizing` | interactive login in flight |
269
+ | `✗ <error>` | last attempt failed |
270
+ | (blank) | idle (stdio server, or not yet bootstrapped) |
557
271
 
558
- `ctrl+O` (logout) wipes credentials via the store and dispatches `{ type: 'logout', name }`. `esc` on an `authorizing` row dispatches `{ type: 'login:cancel', name }` before the Modal's generic esc-close fires (single keystroke cancels + dismisses). `onCancelLoginMcp` dispatches that event *before* aborting the controller so a wedged flow still tears the panel down immediately; the login's own catch re-dispatches it when the abort finally lands.
272
+ `ctrl+L` on a needs-auth row (SettingsModal) or `l` (standalone `McpsSettingsModal`) starts `loginMcpServer` — opens a loopback callback listener, surfaces the auth URL via `<McpAuthorizingPanel>` (browser-open button + copy + paste-back input for SSH sessions where the loopback can't reach back), exchanges the code, persists tokens, reconnects, rebuilds the active agent. `ctrl+O` logs out and wipes credentials; `esc` on an `authorizing` row cancels the flow. Full OAuth flow + AI-provider auth: [CHAT.md § OAuth](./CHAT.md#oauth-two-surfaces).
559
273
 
560
274
  ## Auth wizard (AI providers)
561
275
 
562
- `AuthScreen` and its `SetupWizard` cover three steps:
563
-
564
- 1. **Pick provider** — every entry in `config.providers`. Methods column derives from `supportsOAuth(descriptor)`.
565
- 2. **Pick method** — API key or OAuth (gated on `descriptor.oauthProvider`).
566
- 3. **Enter API key** → writes via `setProviderCredential(dataDir, descriptor, { kind: 'apikey', value })` and seeds `process.env[descriptor.envKey]`, OR **OAuth running** → `runOAuthLogin(descriptor, { onUrl, onPrompt, onProgress, signal })`. The URL is surfaced via `<OAuthAuthBlock>` — two clickable buttons (`ctrl+b` open in browser, `ctrl+y` copy the URL), the full URL in a framed panel with its own `[copy]` button and chunked into per-row hyperlinks, and a paste-back input. Submitting the input either resolves a pending pi-ai `onPrompt` (providers with `usesCallbackServer: false`, e.g. Anthropic Claude Pro/Max) or, when no prompt is pending, fires the pasted URL through `fetchOAuthRedirect` so the loopback callback server completes through its normal path — the SSH escape hatch. The screen pre-flips its spinner copy via `oauthUsesManualCodePaste(descriptor)` so the manual-paste providers say "paste the code below" before pi-ai actually prompts.
567
-
568
- A pre-existing user picker (when at least one provider is already authed) lists current credentials plus a `+ add or re-configure` entry that opens the wizard with a `← back` affordance. Esc on the wizard bails to the picker (or exits the TUI on a true first launch).
276
+ `AuthScreen`/`SetupWizard`: pick provider → pick method (API key or OAuth, gated on `supportsOAuth`) → enter key (`setProviderCredential`) or run OAuth (`runOAuthLogin`, surfaced via `<OAuthAuthBlock>` with browser-open, copy, and paste-back). A pre-existing user picker (when ≥1 provider is authed) lists current credentials plus `+ add or re-configure`. Esc bails to the picker, or exits the TUI on true first launch.
569
277
 
570
278
  ## Execution runtimes (in-process vs daemon)
571
279
 
572
- The TUI is one UI over two interchangeable engines. `activateSession`
573
- (`src/tui/app.tsx`) is the single fork point:
574
-
575
- - **In-process (default)** — `agentRef.current = buildAgent(...)`; the agent
576
- loop, hooks, gates, and MCP connections live in this window's process.
577
- This is the battle-tested path and the floor everything degrades to.
578
- - **Daemon** (`useDaemon` setting, experimental) — `daemonRef.current =
579
- connectDaemonSession(...)` (`src/tui/runtime/daemon.ts`); execution lives
580
- in the shared daemon and this window is presentation. Multiple windows on
581
- one session converge on the same live stream, queue, drafts, approvals,
582
- and presence. Full protocol + daemon lifecycle: `docs/IPC.md`.
583
-
584
- **Mutual exclusivity is structural**: an activation populates exactly one of
585
- `agentRef` / `daemonRef` — no code path ever reconciles two live sources of
586
- truth. The ~29 daemon touchpoints are shaped `if (daemonRef.current) { RPC }
587
- else { agent }`, and both branches feed the **same React state** (stream
588
- buffer, safe-mode queue, interactions queue, queue-box mirror, todo
589
- indicator, HUD overlay) — the render layer cannot tell the runtimes apart,
590
- which is why every screen/modal/keybinding works in both modes without a
591
- fork.
592
-
593
- Coexistence rules (why mixing windows is safe):
594
-
595
- - **The store is the neutral ground.** Both runtimes read/write the same
596
- `sessions.db` / `state.json` through canonical code; a window's live view
597
- re-converges with the store at every run end and on `sessions.touch`
598
- (out-of-band appends from `/` commands and `!` shell).
599
- - **Cross-process run guard.** The loop persists session-level
600
- `status: 'running'`; both runtimes check it (with a 5-minute freshness
601
- bound against `updatedAt`) before starting a run, so an in-process window
602
- and the daemon can never stack `agent.run()` on one session — each side
603
- gets an explicit error instead of interleaved turns.
604
- - **Settings single-writer.** While a controller is connected the daemon
605
- owns `state.json` settings writes (`publishSettings` → `settings.set`);
606
- locally only the `useDaemon` key persists. With no controller (auth
607
- screens, disconnect grace, local mode) the pre-IPC local persistence is
608
- byte-identical.
609
-
610
- Containment (turning it off must always work):
611
-
612
- - Every daemon touchpoint gates on the live controller — with the setting
613
- off, no daemon code runs beyond a module import.
614
- - `zidane --no-daemon` / `ZIDANE_NO_DAEMON=1` beat the setting entirely
615
- (usable even if the settings UI is unreachable).
616
- - Activation is **fail-soft**: a daemon connect failure falls back to
617
- `buildAgent` for that session with a visible notice — a broken daemon
618
- path can never brick a window.
619
- - Teardown order is load-bearing: dispose the controller FIRST (so local
620
- `denyAll()`/`cancelAll()` can't settle shared daemon HITL state), clear
621
- the connected flag, bump the activation generation (supersedes an
622
- in-flight connect), then drop the remote mirrors (draft, attachments,
623
- HUD overlay, presence).
624
-
625
- Known asymmetries in daemon mode (documented, not silent): agent-spawning
626
- `/` commands (`spawnAgents`/`runAgent`) need a local agent and degrade with
627
- an explicit error; `!` shell and `/` commands execute locally in the
628
- issuing window (their persisted output converges everywhere via
629
- `sessions.touch`).
280
+ `activateSession` (`src/tui/app.tsx`) is the single fork point: **in-process** (default) builds the agent loop in this window's process; **daemon** (`Settings.useDaemon`, experimental) connects to a shared daemon (`src/tui/runtime/daemon.ts`) so multiple windows converge on the same live stream, queue, drafts, approvals, and presence. An activation populates exactly one of `agentRef`/`daemonRef` — never both — and both branches feed the same React state, so every screen/modal/keybinding works unmodified in either mode.
630
281
 
631
- ## Embedding
282
+ Coexistence: the store (`sessions.db`/`state.json`) is neutral ground both runtimes read/write; a cross-process run guard (`status: 'running'`, 5-minute freshness bound) stops two runtimes stacking `agent.run()` on one session; while a controller is connected the daemon owns `state.json` settings writes. Turning it off always works: every daemon touchpoint gates on the live controller, `--no-daemon`/`ZIDANE_NO_DAEMON=1` beat the setting, and a connect failure fails soft to `buildAgent` with a visible notice. Full protocol, lifecycle, and the daemon-mode asymmetries (`/` commands needing a local agent, `!`/`/` executing in the issuing window): [IPC.md](./IPC.md).
632
283
 
633
- Drive `<App>` against your own renderer when you need:
284
+ ## Embedding
634
285
 
635
- - Multiple TUIs in one process (`runTui` rejects the second invocation).
636
- - Custom lifecycle around the renderer (post-destroy cleanup, custom `onDestroy`).
637
- - A different store adapter without leaking it through `runTui`'s defaults.
286
+ Drive `<App>` against your own renderer for multiple TUIs in one process, custom renderer lifecycle, or a different store adapter:
638
287
 
639
288
  ```tsx
640
289
  import { createCliRenderer } from '@opentui/core'
@@ -648,82 +297,35 @@ const renderer = await createCliRenderer({ exitOnCtrlC: true, debounceDelay: 0 }
648
297
  createRoot(renderer).render(<App config={config} />)
649
298
  ```
650
299
 
651
- `runTui` is essentially this snippet plus tree-sitter parser registration (`runTui` imports + calls `registerTreeSitterParsers()` synchronously at boot and `initTreeSitterWorker()` asynchronously after mount) plus a `process.exit(0)` on dismiss. Embedders that skip `runTui` can rely on OpenTUI's lazy `initialize()` — the first `<markdown>` fence triggers worker boot on demand — at the cost of one frame of un-highlighted fenced code on first paint. Hosts that need extra grammars pre-registered should mirror the `runTui` body (the tree-sitter module isn't re-exported from the `zidane/tui` barrel).
652
-
653
- For a non-OpenTUI shell (Electron / Tauri / web), **don't** import `<App>` — see `docs/CHAT.md` → **GUI checklist** for the renderer-agnostic provider order, hook wiring, and required tool factories.
300
+ `runTui` is this snippet plus Tree-sitter registration (`registerTreeSitterParsers()` sync, `initTreeSitterWorker()` async) plus `process.exit(0)` on dismiss. Skipping `runTui` still works — OpenTUI's `initialize()` lazily boots the worker on the first `<markdown>` fence — at the cost of one unhighlighted frame. For a non-OpenTUI shell, don't import `<App>`; see [CHAT.md § GUI checklist](./CHAT.md#gui-checklist).
654
301
 
655
302
  ## Auto-update
656
303
 
657
- The footer also surfaces a transient prompt-cache toast, fed by the `'cache:break'` agent hook (`installCacheBreakLogger` is installed per agent) and visible for ~8 seconds, carrying the approximate re-billed token count and its estimated dollar cost (from the same price registry as the footer cost figure; the `~$` segment is omitted when no rate is published for the model — no fabricated $0). Severity is split so the chip never cries wolf: an attributable break renders warn-colored as **`⚡ cache break · <culprit> · +Nk tok · ~$0.48`**, naming the culprit dimension (`system`, `tools`, `messagePrefix`, …); a byte-stable break — the TTL lapsing over normal think-time between prompts — renders dim as **`⚡ cache expired · +Nk tok · ~$0.48`**. A fresh break restarts the window with the newest culprit; a session switch clears it immediately. Hidden while the cache is healthy, dropped in `minimal` UI mode, sheds first on narrow terminals — a transient cost signal, not an action. Break detail lines land in the Logs tab under `ZIDANE_DEBUG`.
658
-
659
- The TUI quietly polls the npm registry once a day for a newer release of `zidane-tui` and surfaces a passive `↑ vX.Y.Z` chip in the footer when one is available. The check is cache-driven (one HTTP roundtrip per 24 h, persisted under `userDir/update-check-<pkg>@<channel>.json`), 3-second timeout, never blocks boot, swallows network errors. Opt out via `Settings.checkForUpdates`, or the env vars `ZIDANE_NO_UPDATE` / `NO_UPDATE_NOTIFIER` / `CI` (any non-empty value disables it).
660
-
661
- The explicit `zidane upgrade` subcommand bypasses both the setting and the env opt-outs — it's a user gesture:
304
+ The footer polls npm once a day for a newer `zidane-tui` release and shows a passive `↑ vX.Y.Z` chip (cache-driven, 3s timeout, never blocks boot). Opt out via `Settings.checkForUpdates` or `ZIDANE_NO_UPDATE`/`NO_UPDATE_NOTIFIER`/`CI`. It also shows a transient prompt-cache toast (`⚡ cache break · <culprit> · +Nk tok · ~$0.48` warn-colored, or dim `⚡ cache expired · …` for TTL-only breaks) for ~8s after a `'cache:break'` hook fires.
662
305
 
663
306
  ```bash
664
307
  zidane upgrade # check + run the host package manager
665
- zidane upgrade --check # check only, print the latest version
666
- zidane upgrade --in-place # download the tarball + atomic-rename the binary
667
- zidane upgrade --dry-run # resolve everything, don't actually install
668
- zidane upgrade --channel next # query a non-`latest` dist-tag
669
- zidane upgrade --package-manager bun # force a specific PM
670
- zidane upgrade --force # install even if we're already on latest
308
+ zidane upgrade --in-place # download tarball, atomic-rename the binary (POSIX only)
309
+ zidane upgrade --channel next # non-`latest` dist-tag
310
+ zidane upgrade --force # install even if already on latest
671
311
  ```
672
312
 
673
- ### Package-manager detection
674
-
675
- `detectPackageManager()` resolves the install command in this order:
676
-
677
- 1. **Path heuristics on the running binary** — Volta (`~/.volta/bin/`, `$VOLTA_HOME`), Bun (`~/.bun/install/global/…`, `$BUN_INSTALL`), pnpm (`~/.local/share/pnpm`, `~/Library/pnpm`, `$PNPM_HOME`), Yarn classic (`~/.yarn/bin`). Volta is treated as a first-class PM (`volta install zidane-tui@latest`) so the catalog stays consistent — writing through the inner PM would silently re-pin the old version on the next shell.
678
- 2. **`npm_config_user_agent` env var** — set during PM `install` runs; the prefix (`npm/`, `pnpm/`, `yarn/`, `bun/`) decides.
679
- 3. **Fallback** — npm.
680
-
681
- `--in-place` is refused on Volta-managed installs (the catalog needs the PM path) and on Windows (no POSIX unlink-while-running). Errors degrade to a clear message + non-zero exit code; the user can then drop the flag and run via the PM.
313
+ `detectPackageManager()` resolves the install command from path heuristics (Volta, Bun, pnpm, Yarn) → `npm_config_user_agent` → npm fallback. `--in-place` is refused on Volta-managed installs and Windows.
682
314
 
683
- ### Reusing for a custom consumer package
684
-
685
- `zidane/chat` exports the entire pipeline so a host wrapping `runTui` for their own CLI doesn't fork anything:
315
+ A host wrapping `runTui` for its own CLI can reuse the whole pipeline without forking:
686
316
 
687
317
  ```ts
688
- import pkg from '../package.json' with { type: 'json' }
689
- import { runTui } from 'zidane/tui'
690
318
  import { runUpdateCommand } from 'zidane/chat'
319
+ import { runTui } from 'zidane/tui'
691
320
 
692
- const PACKAGE_NAME = 'my-cli'
693
-
694
- // Subcommand routing — handle `my-cli upgrade …` before runTui.
695
- const args = process.argv.slice(2)
696
- if (args[0] === 'upgrade') {
697
- const result = await runUpdateCommand({
698
- packageName: PACKAGE_NAME,
699
- currentVersion: pkg.version,
700
- argv: args.slice(1),
701
- cacheDir: `${homedir()}/.my-cli`,
702
- // Override the platform-package prefix if your tarballs live at
703
- // `my-cli-darwin-arm64` etc; defaults to `${packageName}-`.
704
- })
321
+ if (process.argv[2] === 'upgrade') {
322
+ const result = await runUpdateCommand({ packageName: 'my-cli', currentVersion: pkg.version, argv: process.argv.slice(3) })
705
323
  process.exit(result.exitCode)
706
324
  }
707
-
708
- // Wire the footer chip + background check by passing `autoUpdate`.
709
- await runTui({
710
- autoUpdate: { packageName: PACKAGE_NAME, currentVersion: pkg.version },
711
- })
325
+ await runTui({ autoUpdate: { packageName: 'my-cli', currentVersion: pkg.version } })
712
326
  ```
713
327
 
714
- The `Settings.checkForUpdates` toggle and the footer chip are wired automatically by `<App>`. For a non-OpenTUI shell, compose `useUpdateCheck` + `buildUpdateHint` directly against your own status bar — both live in `zidane/chat`, no React-DOM / OpenTUI dependency.
715
-
716
- | Export | Purpose |
717
- |---|---|
718
- | `checkForUpdate(opts)` | Registry poll with TTL cache. Returns `UpdateStatus`. Never throws. |
719
- | `useUpdateCheck(opts)` | React hook that schedules the check after first paint, owns its own AbortController. |
720
- | `buildUpdateHint(status, opts)` | Pure helper that maps an `UpdateStatus` to a footer `Hint` (or `null`). |
721
- | `performSelfUpdate(opts)` | Spawn the host package manager to re-install globally. |
722
- | `performInPlaceSelfUpdate(opts)` | Download the platform-tarball, atomic-rename over the running binary. POSIX-only. |
723
- | `detectPackageManager(opts)` | Path + user-agent heuristics. Returns `{ id, argv, note? }`. |
724
- | `resolvePlatformPackage(opts)` | Map `(platform, arch, libc)` to `<prefix><key>`. Same table as `tui/bin/zidane.mjs`. |
725
- | `runUpdateCommand(opts)` | Drop-in handler for the `upgrade` subcommand. Returns the exit code the consumer should pass to `process.exit`. |
726
- | `compareSemver`, `parseSemver` | Standalone semver compare. No external dep. |
328
+ `checkForUpdate`, `useUpdateCheck`, `buildUpdateHint`, `performSelfUpdate`, `performInPlaceSelfUpdate`, `detectPackageManager`, `resolvePlatformPackage`, `runUpdateCommand`, `compareSemver` all live in `zidane/chat` — no React-DOM/OpenTUI dependency.
727
329
 
728
330
  ## File layout
729
331
 
@@ -731,37 +333,55 @@ The `Settings.checkForUpdates` toggle and the footer chip are wired automaticall
731
333
  src/tui/
732
334
  index.tsx runTui + public exports
733
335
  app.tsx App + AppShell + ThemedShell — provider stack + agent lifecycle
734
- screens.tsx AuthScreen, SessionsScreen, ChatScreen, wizard steps, PromptBlock, ApprovalBlock, QueuedMessagesBlock
735
- components.tsx Transcript (OpenTUI `stickyScroll` + `stickyStart="bottom"` for auto-pin; `selectedTurnId` scroll effect on top), EventLine, MarkdownBlock, SubagentBlock, Footer, Spinner, StatusSpinner, TitleOverlay, EditDiffBlock (outcomes-aware: per-hunk applied/denied/skipped/failed badges via `summarizeOutcomes`), ToolCallBlock, UserPromptBlock
736
- modal.tsx ModalRoot + Modal + useModalAwareFocus
336
+ screens.tsx AuthScreen, SessionsScreen, ChatScreen, PromptBlock, ApprovalBlock, QueuedMessagesBlock
337
+ components.tsx Transcript, EventLine, MarkdownBlock, SubagentBlock, Footer, Spinner, StatusSpinner, TitleOverlay, EditDiffBlock, NoticeRow, TaskNotificationBlock
338
+ modal.tsx ModalRoot + Modal (+ rightTitle slot) + useModalAwareFocus
737
339
  model-picker.tsx Cross-provider searchable model list modal
738
- effort-picker.tsx Reasoning-effort modal
739
- settings-modal.tsx Tabbed settings panel (Agent / UI / Keybindings / Authentication / Instructions / Skills / MCPs)
340
+ model-options-picker.tsx Reasoning-effort + feature-toggle modal (ctrl+l) — supersedes effort-picker.tsx in-app
341
+ effort-picker.tsx Legacy effort-only modal, kept exported for embedders
342
+ settings-modal.tsx Tabbed settings panel (Agent / UI / Keybindings / Authentication / Instructions / Skills / MCPs / Extensions)
343
+ keybindings-modal.tsx Standalone read-only keybindings catalog (ctrl+y)
740
344
  instruction-rows.ts Instructions-tab row model + preview clamping (pure)
741
- editor-command.ts $EDITOR / $VISUAL command-line tokenizer (pure)
742
- agent-picker.tsx Multi-agent profile modal (accentColor re-exported from zidane/chat)
345
+ agent-picker.tsx Multi-agent profile modal
743
346
  skills-settings.tsx Skills list + toggle modal (standalone for embedders)
744
347
  mcps-settings.tsx MCP servers list + toggle modal (standalone for embedders)
745
- session-details-modal.tsx Stats + delete / export / title / compact (ctrl+x)
746
- turn-details-modal.tsx Fork / delete / copy (opened from select-turn mode)
747
- file-edit-approval-modal.tsx FileEditApprovalModal + SingleEditApprovalModal + MultiEditApprovalModal + UnresolvedHunkPanel + `originatorSuffix` (` · child-N` attribution). Inline-mounted in ChatScreen's transcript slot, keyed on `request.id` for force-remount on queue advance — bridges ApprovalDecision (including { kind: 'partial', mask }) to the gate via the helpers in `zidane/chat`'s `edit-approval` module.
748
- todo-indicator.tsx Single-line "in progress todo" badge between the queue block and the prompt (gated on `Settings.showTodoIndicator`; hides itself when nothing's in progress)
749
- todos-modal.tsx Read-only viewer for the active todowrite list (ctrl+t). Resolves the session-shared slot for top-level runs and the per-run slot for subagents; falls back to the archive snapshot when the live slot is empty so a just-completed batch stays visible — see CHAT.md Todos for the keying + archive contracts.
750
- artifacts-modal.tsx `/artifacts` + ctrl+shift+a: what the session produced, grouped by kind (built-ins first, extension kinds after, unknown kinds via the `label + uri` fallback). `↵` opens the href (browser / editor), `tab` toggles session ↔ project scope, `c` copies the locator, `d` removes. Data comes through a `load` getter + `subscribe` tick (local `artifact:recorded` hook or daemon `artifact:recorded` / `artifact:changed` signals), so PRs the agent opens while the modal is up appear live; the focused row gets a one-shot lazy `resolve()` ("merged?") whose result is persisted via the store / `artifacts.upsert`. Extension `renderDetail` panes mount under the list inside an error boundary. See EXTENSIONS.md → Session artifacts.
751
- interaction-block.tsx InteractionBlock + plan picker + question wizard
348
+ extensions-settings.tsx Extensions & plugins list + toggle modal (standalone for embedders)
349
+ extension-renderers.tsx ExtensionRenderersProvider — extension-contributed row/panel renderers
350
+ extension-rows.ts Row model for the Extensions tab (pure)
351
+ extension-hud.tsx Extension-contributed HUD overlay
352
+ create-extension-modal.tsx Scaffold-a-new-extension flow
353
+ claude-import-modal.tsx Import sessions / MCP servers / skills from Claude Code
354
+ cwd-picker.tsx Directory picker (ctrl+h)
355
+ session-details-modal.tsx Stats + delete / export / title / compact (ctrl+shift+x)
356
+ turn-details-modal.tsx Fork / delete / copy / edit (opened from select-turn mode)
357
+ file-edit-approval-modal.tsx SingleEditApprovalModal + MultiEditApprovalModal + UnresolvedHunkPanel; inline-mounted, keyed on request.id
358
+ cancel-tool-modal.tsx In-flight tool picker (ctrl+k)
359
+ todos-modal.tsx Read-only todowrite viewer (ctrl+t); falls back to the archive snapshot when the live list is empty
360
+ artifacts-modal.tsx Session/project artifacts (ctrl+shift+a, /artifacts) — see ARTIFACTS.md
361
+ context-panel.tsx Context-usage breakdown (ctrl+shift+g)
362
+ usage-panel.tsx Subscription usage panel (ctrl+p)
363
+ use-provider-usage.ts useActiveProviderUsage (footer) + useAllProviderUsage (panel) hooks
364
+ oauth-auth-block.tsx AI-provider OAuth block (browser-open, copy, paste-back)
365
+ oauth-url-block.tsx Shared OAuth URL display primitive
366
+ interaction-block.tsx InteractionBlock + PlanReviewPanel + question wizard
752
367
  toggle-list-modal.tsx Generic checkbox-list modal (ToggleListModal)
753
368
  completion-popup.tsx Provider-agnostic autocomplete popover
754
- discovery-shell.tsx DiscoveryShell — wires DiscoveryProvider from `zidane/chat` with the three SWR slots (files / skills / mcps)
755
- crush-throbber.tsx CrushThrobber — gradient activity glyph for the thinking affordance (gated on `Settings.showThrobber`, off by default)
756
- clipboard.ts OSC 52 writer used by the detail modals
369
+ discovery-shell.tsx DiscoveryShell — wires DiscoveryProvider with files / skills / mcps SWR slots
370
+ crush-throbber.tsx Gradient activity glyph (Settings.showThrobber, off by default)
371
+ clipboard.ts / clipboard-image.ts OSC 52 writer / clipboard image+text capture
757
372
  theme.ts buildMdStyle, useMdStyle, MdStyleProvider, ChipStyleProvider, useChipStyle, useChipHighlights
758
- tree-sitter.ts Extra grammar registration (registerTreeSitterParsers + initTreeSitterWorker + setupTreeSitter)
373
+ tree-sitter.ts Grammar registration + worker boot (registerTreeSitterParsers, initTreeSitterWorker, setupTreeSitter)
374
+ bash-runner.ts `!`-prefixed inline shell command execution
375
+ history-search-worker.ts / -client.ts Background prompt-history search
759
376
  ```
760
377
 
761
378
  ## Related
762
379
 
763
- - `docs/CHAT.md` — renderer-agnostic engine consumed underneath. **Per-edit approval** for the gate / modal contract, **Edit-diff rendering** for `previewEditPayload`, **Safe-mode** for the widened `ApprovalDecision` shape, **Discovery context** for the live-catalog primitive.
764
- - `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
765
- - `docs/SKILL.md` — skill system, lifecycle, and authoring.
766
- - `docs/ARCHITECTURE.md` — agent loop, hook firing order, gate precedence.
767
- - `docs/IPC.md` — daemon protocol, signal fan-out, sync matrix, containment guarantees.
380
+ - [CHAT.md](./CHAT.md) — renderer-agnostic engine. Owns Settings, Todos, Safe-mode, Keybindings catalog, Per-edit approval, Edit-diff rendering, Tool call display, Context-usage breakdown, Subscription usage, `SYSTEM.md` override, OAuth, `StreamEvent`.
381
+ - [ARTIFACTS.md](./ARTIFACTS.md) — artifact producers, storage, extension API.
382
+ - [INTERACTIONS.md](./INTERACTIONS.md) — `present_plan` / `ask_user` protocol.
383
+ - [SKILL.md](./SKILL.md) — skill system, lifecycle, authoring.
384
+ - [EXTENSIONS.md](./EXTENSIONS.md) — extension API (renderers, keybindings, HUD).
385
+ - [ARCHITECTURE.md](./ARCHITECTURE.md) — agent loop, hook firing order, gate precedence.
386
+ - [IPC.md](./IPC.md) — daemon protocol, signal fan-out, sync matrix, containment guarantees.
387
+ - [RUN_IN_BACKGROUND.md](./RUN_IN_BACKGROUND.md) — background task lifecycle.