# Zidane TUI

Terminal shell for the chat engine. React + OpenTUI on top of `zidane/chat`. Ships as `zidane/tui` — **Bun-only** because of `bun:sqlite` (via the session store) and OpenTUI's native bindings. A non-terminal host (Electron / Tauri / web) imports `zidane/chat` and provides its own renderer; see `docs/CHAT.md`.

```bash
bun run build
bun run tui
```

```ts
import { runTui } from 'zidane/tui'

await runTui()                                         // ~/.zidane/sessions.db + state.json
await runTui({ prefix: '.myapp' })                     // ~/.myapp/...
await runTui({ providers: { ...BUILTIN_PROVIDERS } })  // custom registry
await runTui({ agents: { ...BUILTIN_AGENTS, debug: myProfile } })
await runTui({ projectDb: true })                      // per-repo `<git-root>/.zidane/sessions.db`
```

`runTui` is a one-shot launcher: it boots OpenTUI, registers Tree-sitter grammars, resolves config, mounts `<App>`, then `process.exit(0)`s on dismiss (Ctrl+C / Esc on auth). Calling it twice throws — global terminal state can only be hijacked once.

For embedding, mount `<App config={resolveConfig(...)} />` against your own `createCliRenderer()`. For a non-OpenTUI shell, drop `<App>` and compose the `zidane/chat` providers directly (see `docs/CHAT.md` → **GUI checklist**).

Env-var overrides: `ZIDANE_STORAGE_DIR`, `ZIDANE_PREFIX`, `ZIDANE_PROJECT_DB`, `ZIDANE_NO_UPDATE` (silences the auto-update check), `ZIDANE_DEBUG` (enables renderer `gatherStats`; dumps fps / frametime to stderr on exit).

Renderer fps is a regular user setting (`Settings → Renderer FPS`, `30 / 60 / 120`). The on-disk value seeds the renderer at boot; subsequent flips apply live via `renderer.targetFps` / `renderer.maxFps`. `clampFps` (in `zidane/chat`) guards bounds.

## Composition

`<App>` is one component; everything else slots underneath it. Provider order is load-bearing.

```
ConfigProvider
└── SettingsProvider                 ← persists settings.json on change
    └── ThemedShell                  ← reads settings.theme → ThemeProvider
        └── MdStyleProvider          ← SyntaxStyle.fromStyles(theme) once per theme
            └── ChipStyleProvider    ← per-provider chip bg/fg SyntaxStyle for the live textarea
                └── SafeModeProvider     ← FIFO approval queue
                    └── InteractionsProvider ← FIFO plan / ask_user queue
                        └── McpAuthProvider   ← per-server OAuth status map
                            └── DiscoveryShell ← live skills / mcps / files catalogs (DiscoveryProvider)
                                └── ModalRoot ← single overlay slot
                                    └── AppShell ← screens + footer
```

`McpAuthProvider` + `DiscoveryShell` mount **above** `<ModalRoot>` so live updates reach open modals — settings opened during a refresh re-renders, an MCP login mid-modal flips its badge. The TUI reads theme via `useColors()`, settings via `useSettings()`, modal via `useModal()` — never reach around the stack.

## Public exports

```ts
import {
  // Launcher + root
  runTui, App,
  // Screens
  AuthScreen, SessionsScreen, ChatScreen,
  type QueuedPreview, type QueueShortcuts,
  // Modal layer
  Modal, type ModalProps, ModalRoot, useModal, useModalAwareFocus,
  // Modals
  ModelPickerModal, EffortPickerModal, AgentPickerModal,
  SettingsModal, SkillsSettingsModal, McpsSettingsModal,
  SessionDetailsModal, TurnDetailsModal,
  ToggleListModal,
  // Inline blocks (embed-friendly, also used by ChatScreen internally)
  InteractionBlock,
  // Picker / action types
  type PickedModel, type SettingsActions,
  type SessionDetailsModalActions, type SessionExportResult, type SessionCompactResult,
  type TurnDetailsModalActions,
  // Transcript + chrome
  Transcript, Footer, Spinner, StatusSpinner, TitleOverlay,
  CompletionPopup,
  // OpenTUI-specific display helpers
  buildMdStyle, useMdStyle, onInputSubmit,
  renderHintSpans,
  // Shared types
  type MetaSegment, type ContextUsage,
  // Deprecated back-compat re-exports from `zidane/chat`.
  // New code should import these directly from `zidane/chat`.
  type Hint, hintsLength, clipHintsToWidth, truncateTrailing,
  isVisible, marginTopFor, isEditErrorResult, isTurnHighlighted,
  selectableTurnIds, turnSelectionOwnership,
  accentColor,
  TOOL_DISPLAY, displayNameFor, formatToolCall,
  type ToolDisplayMeta, type ToolFormatLine,
  type PromptSegment, type PromptSegmentRef, splitPromptSegments,
  type MarkdownSegment, splitMarkdownCodeBlocks,
  computeTurnAnchors, type TranscriptItem,
} from 'zidane/tui'
```

| Export | Purpose |
|---|---|
| `runTui(options?)` | One-shot launcher. Boots OpenTUI + tree-sitter + renders `<App>`. Exits the process on dismiss. |
| `App` | Top-level component. Accepts a fully-resolved `ResolvedConfig`; embedders mount this against their own renderer. |
| `ChatScreen` | Transcript + auto-grow textarea + approval picker + interaction wizard + completion popup + queued-messages block. See [ChatScreen props](#chatscreen) for the full prop list. |
| `AuthScreen` | First-run + re-config provider picker. Drives the OAuth / API-key wizard. |
| `SessionsScreen` | Session list. Sorted by recency via the store. |
| `Modal`, `ModalRoot`, `useModal`, `useModalAwareFocus` | Overlay layer. `Modal` owns its own esc-to-close handler. `useModalAwareFocus(preferred)` returns `false` while a modal is open so background inputs blur. **Single-slot:** `modal.open(node)` replaces the active node; modals do not stack. |
| `ModelPickerModal` | Cross-provider searchable model picker. Returns `{ providerKey, modelId }` on commit; cross-provider picks trigger a provider swap + session re-activation in the host. |
| `EffortPickerModal` | Reasoning-effort picker. Lists `'off' / 'minimal' / 'low' / 'medium' / 'high'` plus `'adaptive'` when `supportsAdaptive` is true (Anthropic only). Surfaced via `ctrl+l` when `modelSupportsReasoning(descriptor, model)`. |
| `SettingsModal` | Tabbed: **Agent** (behaviour, persistence), **UI** (display, theming, animations), **Keybindings** (read-only catalog · `↵` opens `keybindings.json`), **Authentication** (current provider + detected providers · `↵` pops the AuthScreen), **Skills**, **MCP servers**. Category tab order is data-driven by `SETTINGS_CATEGORIES` from `zidane/chat`. Keybindings + Authentication tabs only appear when the host wires the `keybindings` / `authentication` data props; otherwise the legacy "open keybindings file" / "re-authenticate" rows render in the UI / Agent tabs as a fallback. One shared search input filters the active tab; `←/→` cycle tabs (preventDefault keeps the input cursor put), `↑/↓` navigate, `↵` toggle/cycle/pick/edit. MCP tab adds `ctrl+L` login, `ctrl+O` logout, and `esc` cancel-authorizing (Modal's esc-close still fires alongside). Sized at `maxWidth: 160, maxHeight: 50` — wider than the legacy modal so the Keybindings catalog + Authentication status block sit comfortably. |
| `AgentPickerModal` | Multi-agent profile picker. Hidden when only one profile is registered. |
| `SkillsSettingsModal`, `McpsSettingsModal` | Standalone surfaces re-exported for embedders. The main `SettingsModal` integrates the same lists as tabs — prefer that for the in-app flow. `McpsSettingsModal` uses single-letter shortcuts (`l` / `o`); the integrated tab uses `ctrl+L` / `ctrl+O`. |
| `ToggleListModal` | Generic checkbox-list modal for `settings.enabledX` allowlists. Powers both the Skills + MCP server pickers; reuse with `keyOf` + `settingKey` + `renderDetail` to add a third toggle surface. |
| `SessionDetailsModal` | `ctrl+x` modal. Actions (host wires which are available): `g` generate title, `e` export markdown, `j` export JSON, `k` compact, `d` delete (two-press), `c` copy id. Renders stats + total token usage rolled up from `session.runs`. |
| `TurnDetailsModal` | Opened from select-turn mode (`ctrl+s` → ↵). `f` fork session at this turn (two-press), `d` delete turn (two-press), `c` copy turn text. Preview is scrollable + capped at 8 000 chars. |
| `CompletionPopup` | Provider-agnostic popover. Consumes any `CompletionState<TItem>` — reads `label` + `description` only, so providers stay typed without leaking into the renderer. |
| `Transcript`, `Footer`, `Spinner`, `StatusSpinner` | Event renderer, status bar, animated braille spinner, and a 1-cell title-row activity glyph. |
| `TitleOverlay` + `MetaSegment` | Colored title (left, defaults to `COLOR.brand`) + optional right-aligned meta on a bordered-box's top border. Plain string → dim label; `MetaSegment[]` → per-segment colors. Render as a SIBLING of the bordered box (not a child) so the border row isn't clipped. |
| `renderHintSpans`, `hintsLength`, `Hint` | Footer + prompt-hint primitives. A `Hint` is `{ key, label, keyColor?, labelColor? }`; renderers compose them via `renderHintSpans(hints, COLOR)`. |
| `ContextUsage` | `{ used, max }` shape passed to `<Footer context>`. Drives the right-aligned context indicator. |
| `buildMdStyle(theme)`, `useMdStyle()` | Convert a `Theme` to an OpenTUI `SyntaxStyle`. Hook returns the shared instance for the active theme. |
| `onInputSubmit(handler)` | Cast helper — bypasses an OpenTUI / DOM type collision on `<input onSubmit>`. |
| `isVisible`, `marginTopFor`, `isEditErrorResult` | Re-exported from `zidane/chat` (`store` module). Pure transcript-view rules — a GUI shell renders against the same matrix. |
| `accentColor` | Re-exported from `zidane/chat` (`agents` module). Resolves an `AgentAccent` token against a 4-role color subset. |
| `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`. |

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.

## Screens

| Screen | When | Primary interaction |
|---|---|---|
| `auth` | No usable provider credentials, or user picked "Re-configure providers" | Provider picker → wizard (OAuth / API key) → re-detect |
| `sessions` | User opens the session list | Pick existing or `+ new` |
| `chat` | Default once a provider + session are resolved | Type → submit → stream |

### ChatScreen

`<ChatScreen>` accepts (full list — most props have sensible defaults):

```ts
events, settings, busy, compacting,
session, selectedTurnId,
onSubmit, completionProviders, onPopupOpenChange,
pending,            onApproval,        // safe-mode head
pendingInteraction, onInteraction,     // plan / ask_user head
queuedMessages, queueSelectionIndex, queueShortcuts,
onEnterQueueFromEmptyPrompt,
promptTriggerHints,
```

The slot priority has two layers — a top layer that may take over the **transcript** slot, and a bottom layer below the transcript:

**Transcript-slot takeover**:

- **`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).

**Bottom slot** (priority order):

1. **Non-file-edit `pendingApproval`** → `ApprovalBlock` (4 options: accept once · accept for session · accept + remember · deny). Esc aborts the run via the parent handler.
2. **`pendingInteraction != null`** → `InteractionBlock` (plan picker or question wizard, depending on `request.kind`).
   - **Live** (`busy === true`) — esc aborts the run.
   - **Resumed** (`busy === false`, persisted `tool_call` without matching `tool_result`) — esc leaves the form, the session re-enqueues on next activation.
3. Otherwise → `PromptBlock` (textarea + history) — kept mounted even while `busy` so the user can type-ahead. 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.

File-edit pending falls through to the bottom slot's prompt branch — the inline modal owns the conversation area and the keystroke dispatch, so the prompt stays interactive (queue-only).

When the queue has entries, a `QueuedMessagesBlock` renders between the transcript and the prompt:

| Key (default) | Action |
|---|---|
| `↑` on an empty buffer | enter queue-selection mode (focus the queue box, blur the textarea) — natural-feel path; no global shortcut needed |
| `enterQueueSelection` (unbound by default) | same as above, useful when the user wants to enter without emptying their draft first |
| `↑` / `↓` | navigate within the queue |
| `ctrl+↵` (`pushQueuedMessage`) | push the selected message via `agent.steer()` — delivered to the live run between tool calls |
| `backspace` (`dropQueuedMessage`; Mac: the laptop key labeled `delete`) | drop the selected message; auto-exit back to the prompt when the queue empties |
| `esc` | exit queue-selection mode |

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.

`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.

## Keyboard

Every customizable shortcut routes through `<userDir>/keybindings.json` and is parsed via `matchesBinding(event, spec)` from `zidane/chat`. The full action catalog lives in `KEYBINDING_DEFS`; defaults are merged with user overrides on launch.

### Global shortcuts (chat screen unless noted)

| Key (default) | Action id | Where | Notes |
|---|---|---|---|
| `enter` | — | textarea (popup closed) | submit |
| `enter` / `tab` | — | textarea (popup open) | commit highlighted completion |
| `shift+enter` | — | textarea | newline |
| `↑` / `↓` | — | textarea (popup open) | move popup highlight |
| `↑` / `↓` at buffer edges | — | textarea (popup closed) | cycle prompt history (snapshots draft on entry) |
| `↑` on empty buffer | — | textarea | enter queue-selection mode (when queue is non-empty) |
| `esc` | — | popup open | dismiss popup |
| `esc` | — | run in flight / approval pending | abort run + drain approval + interaction queues |
| `esc` | — | modal open | close modal |
| `ctrl+o` | `openSettings` | non-auth | open Settings modal |
| `ctrl+x` | `openSessionDetails` | chat (idle) or sessions list (focused row) | open session-details modal |
| `ctrl+m` | `openModelPicker` | chat (idle) | open cross-provider model picker |
| `ctrl+l` | `openEffortPicker` | chat (idle, model has reasoning) | open reasoning-effort picker |
| `ctrl+s` | `enterSelectTurnMode` | chat (idle) | enter select-turn mode (transcript navigation) |
| `ctrl+t` | `openTodos` | chat (session attached) | open the active run's `todowrite` checkpoints (read-only) |
| `ctrl+g` | `openContextPanel` | chat | open the context-usage breakdown panel (read-only) |
| `ctrl+y` | `openKeybindings` | non-auth | open the keybindings panel — read-only catalog grouped by surface, with `↵` to edit `~/.zidane/keybindings.json` |
| `ctrl+k` | `cancelToolCall` | chat (busy, ≥1 in-flight call) | open the cancel-tool picker — pick one call to flip via `agent.cancelTool(callId)`, or press `a` to cancel every in-flight call. Distinct from `esc abort` (whole-run abort); siblings keep running |
| `shift+tab` | `cycleAgent` | chat (idle, ≥2 profiles) | cycle to next agent profile |
| `ctrl+↵` | `pushQueuedMessage` | queue-selection mode | push selected queued message into the live run |
| `backspace` | `dropQueuedMessage` | queue-selection mode | drop selected queued message |
| `ctrl+a` | (textarea built-in) | textarea | select-all (use `home` / `ctrl+e` for emacs-style line ends) |

### Modal-internal shortcuts

These only fire while the modal in question is mounted, so single-letter defaults don't clash with each other.

| Modal | Key | Action id | Notes |
|---|---|---|---|
| `TurnDetailsModal` | `f` | `turnFork` | two-press confirm |
| | `d` | `turnDelete` | two-press confirm |
| | `c` | `turnCopy` | copy turn content via OSC 52 |
| `SessionDetailsModal` | `g` | `sessionGenerateTitle` | generate title via active provider/model |
| | `e` | `sessionExportMarkdown` | export under `.{prefix}/sessions/` |
| | `j` | `sessionExportJson` | export under `.{prefix}/sessions/` |
| | `k` | `sessionCompact` | LLM-compact older turns (synthetic `compact-summary` marker turn) |
| | `d` | `sessionDelete` | two-press confirm |
| | `c` | `sessionCopyId` | copy full session id via OSC 52 |

`SettingsModal` MCP tab adds `ctrl+L` (login) / `ctrl+O` (logout) on the focused row. The standalone `McpsSettingsModal` (for embedders) uses single-letter `l` / `o`.

### Keybindings file

`<userDir>/keybindings.json` is JSONC (`//` and `/* … */` comments allowed). Tolerant parser:

- Case-insensitive (`Ctrl+O` ≡ `ctrl+o`).
- Order-insensitive (`shift+ctrl+o` works).
- `+` and `-` separators both accepted (`ctrl-o`).
- Modifier aliases: `cmd` / `command` / `super` / `win` → `meta`; `option` / `opt` → `alt`; `ctrl` / `control`.
- Key aliases: `esc` ↔ `escape`, `enter` / `ret` ↔ `return`, `del` ↔ `delete`, `pgup` / `pgdn` ↔ `pageup` / `pagedown`.

Invalid / unknown entries silently fall back to defaults (with a `ZIDANE_DEBUG` log). User customizations are sacred — `ensureKeybindingsFile` never overwrites an existing file.

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.

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.

**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()`.

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.

## Subagent rendering

A subagent run emits `spawn-start` / `spawn-end` markers and a stream of tagged child events (`depth > 0`, `childId: child-N`).

`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.

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`.

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.

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`.

## Theme + markdown

Theme switching is real-time. Cycle via Settings → Theme; every `useColors()` / `useSelectStyle()` / `useSurfaces()` / `useMdStyle()` consumer re-renders.

`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.

`ChipStyleProvider` performs the analogous job for the live textarea's per-provider chip pills (`useChipStyle()` returns one `SyntaxStyle` keyed by `theme.surfaces.chips[providerId]`).

Fenced code blocks get language-specific highlighting via Tree-sitter. Registered grammars: `python`, `bash` (aliases `sh`/`shell`/`zsh`), `json`, `rust` (`rs`), `go` (`golang`), `yaml` (`yml`), `html` (`htm`), `css`, `haskell` (`hs`), `c`, `cpp` (`c++` / `cxx` / `cc`), `csharp` (`cs` / `c#`), `elixir` (`ex` / `exs`), `sql` (`psql` / `mysql` / `sqlite` / `plsql` — TUI binary only), plus everything OpenTUI ships by default (TypeScript / JavaScript / JSX / TSX). Wasms download lazily on first use and cache under OpenTUI's data path; SQL is vendored into the TUI binary via `ZIDANE_LOCAL_TREE_SITTER_SQL_WASM` (set by `tui/src/tree-sitter-bundle.ts`). `registerTreeSitterParsers()` registers them synchronously at boot, `initTreeSitterWorker()` spins up the worker off the critical path — failures fall back to plain text without breaking the renderer.

### Tool call display

`Settings.toolCallDisplay` picks one of three renderings for `'tool'` events. All three live behind the `<EventLine>` dispatch in `components.tsx`:

| Mode | Line shape | Notes |
|---|---|---|
| `'hidden'` | (event filtered out by `isVisible`) | Transcript reads as pure chat. |
| `'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. |
| `'full'` | `↳ <Verb> <name>` header + native `<code filetype="json">` block | Debug view; renders the raw `input` object pretty-printed. Capped at 32 KiB. |

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.

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.

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.

### Edit-diff rendering

`Settings.showEditDiffs` (default **on**) renders `edit` / `multi_edit` / `write_file` tool calls as a unified diff via the native `<diff>` renderable. Wire path:

- `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.
- `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`.
- 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.
- 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.

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.

### File-edit approval modal

File-edit tools (`edit` / `multi_edit` / `write_file`) get their own approval surface — `FileEditApprovalModal` (`src/tui/file-edit-approval-modal.tsx`) — instead of the generic `ApprovalBlock`. The modal branches on hunk count:

- **`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.
- **`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:
  - All hunks on → bulk decision (`accept-once` / `accept-session` / `accept-safelist`) preserving the safelist path identical to single-edit.
  - All hunks off → `'deny'`.
  - 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.

**Inline mount**. The modal renders inside `ChatScreen`'s transcript slot via `flexGrow: 1`. The transcript stays mounted with `visible: !fileEditPending` — OpenTUI maps that to Yoga's `Display.None`, removing the box from layout entirely so the sibling modal claims the slot via its own `flexGrow: 1`. Memoized turn anchors, scrollbox position, and lazy markdown chrome all survive the open → close round-trip. The prompt + footer stay anchored at the bottom (queue-only while `busy`). `ChatScreen` pushes an empty `<></>` placeholder onto the global modal context so `useModalAwareFocus()` keeps releasing background focus and the app-level `esc` / `ctrl+s` shortcuts stay gated; the actual UI is sibling, not stacked.

**Force-remount on queue advance**. The modal is keyed on `request.id` (`<FileEditApprovalModal key={fileEditPending.id} … />`). Without the key, `MultiEditApprovalModal`'s `mask` / `cursor` / `zone` and `SingleEditApprovalModal`'s `selected` would carry over from the previous call when the safe-mode queue pops one approval and exposes the next. React re-uses the component instance otherwise and the user would see a checkbox list whose state matches the prior modal.

**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.

**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.

**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.

`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.

### Tool-call cancellation

`CancelToolModal` (`src/tui/cancel-tool-modal.tsx`) surfaces the in-flight tool registry maintained by `App.tsx` and routes the user's pick to `agent.cancelTool(callId)`. The shortcut is `ctrl+k` (`cancelToolCall`), gated on `busy && !pendingApproval && inFlightTools.length > 0` so it never opens on a dead-empty state.

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".

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.

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.

### Background-task completion banner

`shell({ run_in_background: true })` parks a process under a `bash_<n>` handle (see [SKILL.md "Background tasks"](./SKILL.md#background-tasks) for the full lifecycle). When the task exits — naturally, via `shell_kill`, or via `agent.destroy()` — the agent queues a `<task-notification>` block that arrives on the model's next turn AND surfaces in the transcript as a one-line banner.

`TaskNotificationBlock` (`src/tui/components.tsx`) is the renderer. Layout: `⌁` glyph + task id + status (`exited 0` / `killed`) + duration (`4.2s` / `42m`) + absolute path to the log file. Status-accented:

- `exited` with code 0 → dim (success is quiet).
- `exited` with non-zero code OR `killed` → warn (model should pay attention).

The `app.tsx` host wires `behavior.tasksDir = resolveTasksDir({ userDir, sessionId })` at session activation so the framework knows where to put log files. Without this, the shell tool surfaces a clean error to the model when it tries to spawn a background task.

On replay (session reload), `eventsFromTurns` re-synthesizes `'task-notification'` `StreamEvent`s from the persisted `<task-notification>` XML in user-turn text blocks — the raw XML block is dropped from the user-prompt stream so the banner doesn't double-paint alongside it. Field decoding is tolerant: missing tags fall back to defaults so older transcripts pre-dating a field still render cleanly.

### Active skills — footer chip + pinned-skills persistence

When at least one skill is active, the footer hint row gets a passive `✦ N skill(s)` chip (`COLOR.brand` accent). The chip is purely informational — no shortcut wired — but it gives the user a passive surface for "tool restrictions are in effect" without having to scroll back through the transcript looking for a `skills_use` call. Driven by `activeSkillNames: ReadonlySet<string>` in `App.tsx`, fed by the agent's `skills:activate` / `skills:deactivate` hooks.

Slash-command activations (`/skill-name`) call `agent.activateSkill()` programmatically, BEFORE `agent.run()`. The framework deactivates every active skill at run-end (`reason: 'run-end'`) and the session-resume rehydrator only scans for `skills_use` `tool_call` blocks — so a slash activation would silently lose its `allowed-tools` enforcement on the next prompt. The TUI fixes this with a **pinned-skills set persisted to session metadata** under `zidane.activeSkills`:

- The `skills:deactivate` listener filters by reason — `'run-end'` keeps the entry, `'model'` / `'explicit'` / `'reset'` drain it. So a user-driven `/skill-name` stays pinned across the framework's per-run cleanup; a model-driven `skills_use({ mode: 'deactivate' })` correctly releases the pin.
- `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.
- `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).
- `persistPinnedSkills` mirrors every state change back into metadata via `session.setMeta(...)`, sorted for stable on-disk ordering.

The teardown path also clears the in-memory pinned set on session swap (`setActiveSkillNames(new Set())`) so the next session starts from its own persisted state, not the previous one's residue.

### Todos surface — indicator + modal

Two thin chrome layers on top of the renderer-agnostic `useActiveTodos` hook (see [Todos in CHAT.md](./CHAT.md#todos) for the data contract — the keying rule, the auto-clear behavior, and the legacy-bag migration). Both surfaces share `TODO_STATUS_GLYPHS` so a row in the modal and the inline indicator read as the same visual language.

- **`TodoIndicator`** (`src/tui/todo-indicator.tsx`) — single-line `◐  <in-progress content>` badge mounted between the queue block and the prompt in `ChatScreen`. Hides itself (returns `null`) when there's no session, no active run, no `in_progress` item, the terminal is too narrow to render a meaningful tail, or `Settings.showTodoIndicator` is off. Read-only — not focusable, not clickable. Truncates multi-line / oversized content to one visual line so the chat zone stays calm.
- **`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.

Both surfaces resolve "what list to show" via `useActiveTodos(session)`, which carries both the live `todos` (model-facing) and the `archive` (sidecar — see [Todos in CHAT.md](./CHAT.md#todos) for the archive contract). The indicator only renders when there's a live `in_progress` item (an archived in-progress item is by definition stale and hidden). The modal renders `todos` when non-empty, falling back to `archive` when the live slot has been auto-cleared.

### Context-usage panel

- **`ContextPanelModal`** (`src/tui/context-panel.tsx`) — opened via `ctrl+g` (`openContextPanel`), closes on `esc`. Read-only, on-demand: the `App.tsx` handler calls `agent.getContextBreakdown({ model, effectiveWindow, autocompactBuffer, compactThreshold })` (see [Context-usage breakdown in CHAT.md](./CHAT.md#context-usage-breakdown)) and opens the modal with the result — guarded so a stale async resolve (session switched mid-fetch) doesn't pop a panel for the wrong agent. Renders a single full-width stacked bar (one fixed-hue segment per live category, the autocompact-buffer zone pinned at the tail from the threshold to the window end, free space as the remaining track) above per-category rows (swatch · label · token count · percent). Estimated rows carry a `~` prefix; the footnote `~ estimated — exact counts unavailable for this provider` shows only when ≥1 live category is heuristic. Below the categories: an "active skills" section (informational — an activated skill's body lives in the conversation, already counted there) and a "last turn" section with the exact cache-read / cache-write / fresh-input / output split from `TurnUsage`. Expandable rows (MCP tools, per-rule files) toggle with `↵`/`space`; `↑↓` move the cursor across expandable groups. The panel works before the first message via the agent's skills-only pre-run assembly. The bar palette is shared verbatim with the GUI popover so both surfaces read identically.

In addition, every `todowrite` tool call in the transcript renders a small "what's in progress" sub-list directly beneath the formatted call line. The sub-list reads the call's `input.todos` payload (the checkpoint the model just wrote), filters to `status === 'in_progress'`, and prints one indented `◐ <content>` row per item. Hidden when the payload has no in-progress items (e.g. all-completed close-out) so the transcript stays tight. This is purely declarative — it reads the event's own payload, not session state, so it's stable across re-renders and matches what the model intended at that checkpoint (not the latest list state, which the indicator + modal already surface).

Because top-level runs share `session.metadata.todos.session`, a list written in one prompt **stays visible across the next prompt** — the user aborts mid-task, sends a follow-up, and the indicator + modal continue to show the same list until the model rewrites or auto-clears it. Subagent runs surface their own slot when they're the live run (`pickActiveRunId` picks the latest running run); when a subagent completes, the active resolution reflows to the parent's session slot on the next paint.

`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.

`Settings.showTodoIndicator` (default `true`) hides only the inline indicator. The modal is always accessible — opening it is an explicit user gesture.

## Settings rows

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.

| Kind | Source | Activation |
|---|---|---|
| `toggle` | `SETTINGS_TOGGLES` (`BooleanSettingKey`) | `enter` flips the boolean |
| `choice` | `SETTINGS_CHOICES` | `enter` cycles to the next option (wraps) |
| `action` | `SettingsActions` (host-supplied) | `enter` invokes the callback |
| `skill` | `skillsCatalog` prop | `enter` toggles `enabledSkills` |
| `mcp` | `mcpsCatalog` prop | `enter` toggles `enabledMcps` (+ `ctrl+L` login / `ctrl+O` logout when applicable) |
| `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` |

`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.

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).

For the live source of truth, see `SETTINGS_TOGGLES` / `SETTINGS_CHOICES` in `src/chat/settings-context.tsx`. The current shape includes:

- **Toggles** — `safeMode`, `allowInteraction`, `resumeLastSession`, `hideSubagentOutput`, `persistToolResults`, `autoCompact`, `showAllProjects`, `showThinking`, `showToolResults`, `showEditDiffs`, `smoothStreaming`, `showTodoIndicator`, `showThrobber`.
- **Choices** — `toolCallDisplay` (Formatted / Full / Hidden), `autoCompactThreshold` (60% / 70% / 80% / 90%), `theme` (every entry in `BUILTIN_THEMES`), `targetFps` (30 / 60 / 120).

`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.

## Completion + slash-commands

The TUI ships two providers out of the box — **skills** (`/`) and **files** (`@`). Typing the trigger above a textarea opens a popover above the prompt block:

```
┌─ skills — 3 matches ──────────────────────────────────────────┐
│ ▶ research    in-depth research with citations                │
│   review      code review with severity tags                  │
│   plan        write a plan before coding                      │
│                                                               │
│ ↑↓ navigate · ↵ / tab select · esc close                      │
└───────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ /re|                                                          │
└──────────────────────────────────────────────────────────────┘
```

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.

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).

References are highlighted in two surfaces:

- **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.
- **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`).

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.

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.

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.

## Transcript scrolling

`<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.

Two scroll effects layer on top, both keyed off React state:

- **Auto-pin** (native): OpenTUI handles the `[items, busy]` → "stay at bottom" loop. No React work; survives a 60 fps stream without forcing re-layouts.
- **`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.

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.

`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.

## Select-turn mode

Press `ctrl+s` on the chat screen (idle, ≥ 1 turn, no pending approval / interaction) to enter select-turn mode:

- 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).
- The selected turn's entire row span fills with `theme.surfaces.selection` so it reads as one continuous block.
- `↵` opens `TurnDetailsModal` for that turn (fork / delete / copy).
- `esc` exits the mode; the next streaming delta re-engages sticky-bottom auto-scroll.

The last turn snaps to the bottom of the scrollbox on selection so the tail of a tall response stays visible.

## Auto-compact UI

When `Settings.autoCompact` is on and the latest turn's `usage.input` (cache-aware) crosses `Settings.autoCompactThreshold × effectiveContextWindow`, the TUI:

1. Appends an `info` event: `🗜 Auto-compacting conversation (used N% of effective context)…`
2. Lights the title-row `StatusSpinner` in `theme.colors.accent` (vs. `warn` for streaming).
3. Runs `compactConversation` in the background — the prompt area stays interactive.
4. Stores the in-flight promise on a ref; the next `onSubmitPrompt` awaits it before calling `agent.run()`, so a quick user submit is queued behind the pending compaction (no race against `appendTurns(summaryTurn, …restored)`).
5. On completion, emits `✓ Compacted: N turn(s) elided[, M dropped (PTL)][, K file(s) restored] → ~X effective tokens.` and updates the footer's `ctx` indicator.
6. Never auto-continues the conversation — the user's explicit prompt drives the next turn.

`teardown()` aborts the in-flight call so navigating away mid-compaction cancels the LLM call instead of letting it summarize a session the user has left.

The `'compact-summary'` `StreamEvent` (carrying `compact: { replacedCount, model, compactedAt, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }`) renders as a boundary card; the user can still scroll above it to see the original turns — compaction never deletes from disk.

## Tool-result persistence

`Settings.persistToolResults` (default **on**) lets the agent loop replace oversize `tool_result` outputs with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB head preview. Wire details:

- `behavior.persistThreshold` defaults to 8 KiB on the built-in profiles; the TUI overrides to 0 when the user toggles persistence off.
- `behavior.persistExcludeTools` = `DEFAULT_PERSIST_EXCLUDE_TOOLS` from `zidane/chat` (`read_file`, `tool_search`, `skills_use`, `skills_read`, `present_plan`, `ask_user`, `spawn`).
- `behavior.persistDir` is `<userDir>/persisted-tool-results/<sessionId>/` (always pinned to user dir, never the project dir) — `resolvePersistDir` from `zidane`.
- 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.

## MCP servers + OAuth login

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.

Each row carries an OAuth status badge driven by the `mcp-auth-state` reducer:

| Badge | Status | Meaning |
|---|---|---|
| `✓ authed` | `{ kind: 'authed' }` | tokens stored + bootstrap connected |
| `! needs login` | `{ kind: 'needs-auth', reason: 'no-tokens' \| 'auto-promoted' }` | explicit OAuth config or 401 + RFC 9728 metadata detected |
| `… authorizing` | `{ kind: 'authorizing', url? }` | interactive login in flight |
| `✗ <error>` | `{ kind: 'error', error }` | last login attempt failed; message is verbatim |
| (blank) | `{ kind: 'idle' }` | nothing to say (stdio server, or not yet bootstrapped) |

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).

Interactive login flow (triggered by `ctrl+L` on a needs-auth row in `SettingsModal`, or `l` in the standalone `McpsSettingsModal`):

1. App calls `loginMcpServer(config, { store, hooks, signal })` from `zidane/mcp`.
2. `startOAuthCallback` spawns a loopback `127.0.0.1` HTTP listener on a random port.
3. `McpOAuthProvider` is built with `redirectUri` pointing at the loopback; `client.connect()` triggers `redirectToAuthorization` which fires `mcp:auth:url` (the modal renders the URL via `<McpAuthorizingPanel>` — `tryOpenBrowser` opens it automatically).
4. SDK throws `UnauthorizedError`; we wait on the callback for the `?code=...` redirect. The same `<McpAuthorizingPanel>` includes a paste-back input — submitting the redirect URL pipes it 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.
5. `transport.finishAuth(code)` exchanges the code for tokens; `provider.saveTokens` persists via `createFileMcpCredentialStore(userDir)`.
6. Reconnect with the new tokens; `client.listTools()` returns the catalog.
7. App rebuilds the active agent so the now-authenticated server connects on the next run.

`ctrl+O` (logout) wipes credentials via the store and dispatches `{ type: 'logout', name }`. `esc` on an `authorizing` row dispatches `{ type: 'login:cancel', name }` before the Modal's generic esc-close fires (single keystroke cancels + dismisses).

## Auth wizard (AI providers)

`AuthScreen` and its `SetupWizard` cover three steps:

1. **Pick provider** — every entry in `config.providers`. Methods column derives from `supportsOAuth(descriptor)`.
2. **Pick method** — API key or OAuth (gated on `descriptor.oauthProvider`).
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>` (see below) — a prominent OSC 8 "Click here to open auth URL" button + the full URL chunked into per-row hyperlinks + 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.

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).

## Embedding

Drive `<App>` against your own renderer when you need:

- Multiple TUIs in one process (`runTui` rejects the second invocation).
- Custom lifecycle around the renderer (post-destroy cleanup, custom `onDestroy`).
- A different store adapter without leaking it through `runTui`'s defaults.

```tsx
import { createCliRenderer } from '@opentui/core'
import { createRoot } from '@opentui/react'
import { resolveConfig } from 'zidane/chat'
import { createTuiStore } from 'zidane/session/sqlite'
import { App } from 'zidane/tui'

const config = resolveConfig({ store: paths => createTuiStore(paths.db) })
const renderer = await createCliRenderer({ exitOnCtrlC: true, debounceDelay: 0 })
createRoot(renderer).render(<App config={config} />)
```

`runTui` is essentially this snippet plus tree-sitter parser registration (`runTui` imports + calls `registerTreeSitterParsers()` synchronously at boot and `initTreeSitterWorker()` asynchronously after mount) plus a `process.exit(0)` on dismiss. Embedders that skip `runTui` can rely on OpenTUI's lazy `initialize()` — the first `<markdown>` fence triggers worker boot on demand — at the cost of one frame of un-highlighted fenced code on first paint. Hosts that need extra grammars pre-registered should mirror the `runTui` body (the tree-sitter module isn't re-exported from the `zidane/tui` barrel).

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.

## Auto-update

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).

The explicit `zidane upgrade` subcommand bypasses both the setting and the env opt-outs — it's a user gesture:

```bash
zidane upgrade                       # check + run the host package manager
zidane upgrade --check               # check only, print the latest version
zidane upgrade --in-place            # download the tarball + atomic-rename the binary
zidane upgrade --dry-run             # resolve everything, don't actually install
zidane upgrade --channel next        # query a non-`latest` dist-tag
zidane upgrade --package-manager bun # force a specific PM
zidane upgrade --force               # install even if we're already on latest
```

### Package-manager detection

`detectPackageManager()` resolves the install command in this order:

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.
2. **`npm_config_user_agent` env var** — set during PM `install` runs; the prefix (`npm/`, `pnpm/`, `yarn/`, `bun/`) decides.
3. **Fallback** — npm.

`--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.

### Reusing for a custom consumer package

`zidane/chat` exports the entire pipeline so a host wrapping `runTui` for their own CLI doesn't fork anything:

```ts
import pkg from '../package.json' with { type: 'json' }
import { runTui } from 'zidane/tui'
import { runUpdateCommand } from 'zidane/chat'

const PACKAGE_NAME = 'my-cli'

// Subcommand routing — handle `my-cli upgrade …` before runTui.
const args = process.argv.slice(2)
if (args[0] === 'upgrade') {
  const result = await runUpdateCommand({
    packageName: PACKAGE_NAME,
    currentVersion: pkg.version,
    argv: args.slice(1),
    cacheDir: `${homedir()}/.my-cli`,
    // Override the platform-package prefix if your tarballs live at
    // `my-cli-darwin-arm64` etc; defaults to `${packageName}-`.
  })
  process.exit(result.exitCode)
}

// Wire the footer chip + background check by passing `autoUpdate`.
await runTui({
  autoUpdate: { packageName: PACKAGE_NAME, currentVersion: pkg.version },
})
```

The `Settings.checkForUpdates` toggle and the footer chip are wired automatically by `<App>`. For a non-OpenTUI shell, compose `useUpdateCheck` + `buildUpdateHint` directly against your own status bar — both live in `zidane/chat`, no React-DOM / OpenTUI dependency.

| Export | Purpose |
|---|---|
| `checkForUpdate(opts)` | Registry poll with TTL cache. Returns `UpdateStatus`. Never throws. |
| `useUpdateCheck(opts)` | React hook that schedules the check after first paint, owns its own AbortController. |
| `buildUpdateHint(status, opts)` | Pure helper that maps an `UpdateStatus` to a footer `Hint` (or `null`). |
| `performSelfUpdate(opts)` | Spawn the host package manager to re-install globally. |
| `performInPlaceSelfUpdate(opts)` | Download the platform-tarball, atomic-rename over the running binary. POSIX-only. |
| `detectPackageManager(opts)` | Path + user-agent heuristics. Returns `{ id, argv, note? }`. |
| `resolvePlatformPackage(opts)` | Map `(platform, arch, libc)` to `<prefix><key>`. Same table as `tui/bin/zidane.mjs`. |
| `runUpdateCommand(opts)` | Drop-in handler for the `upgrade` subcommand. Returns the exit code the consumer should pass to `process.exit`. |
| `compareSemver`, `parseSemver` | Standalone semver compare. No external dep. |

## File layout

```
src/tui/
  index.tsx                     runTui + public exports
  app.tsx                       App + AppShell + ThemedShell — provider stack + agent lifecycle
  screens.tsx                   AuthScreen, SessionsScreen, ChatScreen, wizard steps, PromptBlock, ApprovalBlock, QueuedMessagesBlock
  components.tsx                Transcript (OpenTUI `stickyScroll` + `stickyStart="bottom"` for auto-pin; `selectedTurnId` scroll effect on top), EventLine, MarkdownBlock, SubagentBlock, Footer, Spinner, StatusSpinner, TitleOverlay, EditDiffBlock (outcomes-aware: per-hunk applied/denied/skipped/failed badges via `summarizeOutcomes`), ToolCallBlock, UserPromptBlock
  modal.tsx                     ModalRoot + Modal + useModalAwareFocus
  model-picker.tsx              Cross-provider searchable model list modal
  effort-picker.tsx             Reasoning-effort modal
  settings-modal.tsx            Tabbed settings panel (Agent / UI / Keybindings / Authentication / Skills / MCPs)
  agent-picker.tsx              Multi-agent profile modal (accentColor re-exported from zidane/chat)
  skills-settings.tsx           Skills list + toggle modal (standalone for embedders)
  mcps-settings.tsx             MCP servers list + toggle modal (standalone for embedders)
  session-details-modal.tsx     Stats + delete / export / title / compact (ctrl+x)
  turn-details-modal.tsx        Fork / delete / copy (opened from select-turn mode)
  file-edit-approval-modal.tsx  FileEditApprovalModal + SingleEditApprovalModal + MultiEditApprovalModal + UnresolvedHunkPanel + `originatorSuffix` (` · child-N` attribution). Inline-mounted in ChatScreen's transcript slot, keyed on `request.id` for force-remount on queue advance — bridges ApprovalDecision (including { kind: 'partial', mask }) to the gate via the helpers in `zidane/chat`'s `edit-approval` module.
  todo-indicator.tsx            Single-line "in progress todo" badge between the queue block and the prompt (gated on `Settings.showTodoIndicator`; hides itself when nothing's in progress)
  todos-modal.tsx               Read-only viewer for the active todowrite list (ctrl+t). Resolves the session-shared slot for top-level runs and the per-run slot for subagents; falls back to the archive snapshot when the live slot is empty so a just-completed batch stays visible — see CHAT.md Todos for the keying + archive contracts.
  interaction-block.tsx         InteractionBlock + plan picker + question wizard
  toggle-list-modal.tsx         Generic checkbox-list modal (ToggleListModal)
  completion-popup.tsx          Provider-agnostic autocomplete popover
  discovery-shell.tsx           DiscoveryShell — wires DiscoveryProvider from `zidane/chat` with the three SWR slots (files / skills / mcps)
  crush-throbber.tsx            CrushThrobber — gradient activity glyph for the thinking affordance (gated on `Settings.showThrobber`, off by default)
  clipboard.ts                  OSC 52 writer used by the detail modals
  theme.ts                      buildMdStyle, useMdStyle, MdStyleProvider, ChipStyleProvider, useChipStyle, useChipHighlights
  tree-sitter.ts                Extra grammar registration (registerTreeSitterParsers + initTreeSitterWorker + setupTreeSitter)
```

## Related

- `docs/CHAT.md` — renderer-agnostic engine consumed underneath. **Per-edit approval** for the gate / modal contract, **Edit-diff rendering** for `previewEditPayload`, **Safe-mode** for the widened `ApprovalDecision` shape, **Discovery context** for the live-catalog primitive.
- `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
- `docs/SKILL.md` — skill system, lifecycle, and authoring.
- `docs/ARCHITECTURE.md` — agent loop, hook firing order, gate precedence.
