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/README.md +18 -9
- package/docs/ACP.md +6 -0
- package/docs/ARCHITECTURE.md +179 -356
- package/docs/ARTIFACTS.md +130 -0
- package/docs/CHAT.md +426 -1312
- package/docs/EXECUTION_CONTEXT.md +46 -19
- package/docs/EXTENSIONS.md +215 -623
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -344
- package/docs/PLUGINS.md +2 -0
- package/docs/README.md +57 -0
- package/docs/RESTATE.md +199 -491
- package/docs/RUN_IN_BACKGROUND.md +107 -558
- package/docs/SKILL.md +171 -238
- package/docs/TUI.md +213 -593
- package/package.json +1 -1
package/docs/TUI.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Zidane TUI
|
|
2
2
|
|
|
3
|
-
Terminal shell for
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
## Quick start
|
|
23
22
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
Renderer fps is `Settings.targetFps` (30/60/120): seeds the renderer at boot, live flips apply via `renderer.targetFps`/`maxFps`.
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
## Composition
|
|
31
30
|
|
|
32
31
|
```
|
|
33
32
|
ConfigProvider
|
|
34
33
|
└── SettingsProvider ← persists settings.json on change
|
|
35
|
-
└── ThemedShell ←
|
|
36
|
-
└── MdStyleProvider ← SyntaxStyle.fromStyles(theme) once per theme
|
|
37
|
-
└── ChipStyleProvider ← per-provider chip bg/fg
|
|
38
|
-
└── SafeModeProvider
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
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
|
-
|
|
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.
|
|
95
|
-
| `App` | Top-level component
|
|
96
|
-
| `ChatScreen` | Transcript +
|
|
97
|
-
| `AuthScreen`
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `EffortPickerModal` |
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `SkillsSettingsModal`, `McpsSettingsModal` | Standalone surfaces
|
|
105
|
-
| `ToggleListModal` | Generic checkbox-list modal
|
|
106
|
-
| `SessionDetailsModal`
|
|
107
|
-
| `TurnDetailsModal`
|
|
108
|
-
| `CompletionPopup` | Provider-agnostic popover
|
|
109
|
-
| `Transcript`, `Footer`, `Spinner`, `StatusSpinner` |
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
127
|
-
| `sessions` | User opens the session list | Pick existing
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
|
164
|
-
|
|
165
|
-
|
|
|
166
|
-
| `
|
|
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
|
|
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
|
|
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)
|
|
195
|
-
| `ctrl+
|
|
196
|
-
| `ctrl+
|
|
197
|
-
| `ctrl+
|
|
198
|
-
| `ctrl+t` | `openTodos` | chat (session attached) | open
|
|
199
|
-
| `ctrl+shift+a` | `openArtifacts` | chat (session attached) | open
|
|
200
|
-
| `ctrl+shift+g` | `openContextPanel` | chat |
|
|
201
|
-
| `ctrl+
|
|
202
|
-
| `ctrl+
|
|
203
|
-
| `ctrl+
|
|
204
|
-
| `ctrl+
|
|
205
|
-
| `ctrl+
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
212
|
-
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
|
222
|
-
|
|
223
|
-
|
|
|
224
|
-
| | `
|
|
225
|
-
| | `
|
|
226
|
-
| | `
|
|
227
|
-
| | `
|
|
228
|
-
| | `
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
181
|
+
## Modals
|
|
273
182
|
|
|
274
|
-
###
|
|
183
|
+
### ModelPickerModal
|
|
275
184
|
|
|
276
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
204
|
+
### TodosModal
|
|
336
205
|
|
|
337
|
-
`
|
|
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
|
-
- `
|
|
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
|
-
|
|
210
|
+
### ArtifactsModal
|
|
343
211
|
|
|
344
|
-
|
|
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
|
-
###
|
|
214
|
+
### ContextPanelModal
|
|
347
215
|
|
|
348
|
-
|
|
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
|
-
|
|
218
|
+
### UsagePanelModal
|
|
351
219
|
|
|
352
|
-
|
|
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
|
-
|
|
222
|
+
## Theme + code highlighting
|
|
358
223
|
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
+
## Transcript rendering
|
|
367
231
|
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
242
|
+
## Settings modal
|
|
384
243
|
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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+
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
+
## Embedding
|
|
634
285
|
|
|
635
|
-
|
|
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
|
|
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
|
|
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 --
|
|
666
|
-
zidane upgrade --
|
|
667
|
-
zidane upgrade --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
735
|
-
components.tsx Transcript
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
755
|
-
crush-throbber.tsx
|
|
756
|
-
clipboard.ts
|
|
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
|
|
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
|
-
-
|
|
764
|
-
-
|
|
765
|
-
-
|
|
766
|
-
-
|
|
767
|
-
-
|
|
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.
|