# Zidane Chat Engine

Renderer-agnostic chat surface. Wraps the agent loop with auth + sessions + safe-mode + theme + settings + interactions + keybindings + MCP OAuth state and exposes them as React contexts. **No terminal dependencies** — `zidane/chat` powers `zidane/tui` and any future GUI / web shell on equal footing.

```ts
import { resolveConfig } from 'zidane/chat'
```

Three subpaths, three platform contracts:

| Package | Pulls | Runtime |
|---|---|---|
| `zidane` | core agent loop, providers, tools, MCP, compact, loop persistence | Bun / Node |
| `zidane/chat` | + React contexts, session reconstruction, theme, safe-mode, interactions, keybindings, MCP OAuth state | Bun / Node + React |
| `zidane/tui` | + OpenTUI primitives, screens, components | **Bun + terminal** (`bun:sqlite`, native OpenTUI bindings) |

A non-Bun host (Electron / Tauri / Next.js) imports `zidane/chat` only. The split is build-enforced — `dist/chat.js` contains zero OpenTUI imports and zero `bun:sqlite`.

## GUI checklist

What a non-TUI shell wires to reach feature parity with `zidane/tui`. All renderer-agnostic.

**Provider stack** — mount in this order (`ThemedShell` reads `settings.theme` and wraps `<ThemeProvider>`; an embedder rendering modals also adds `<DiscoveryProvider>` above `<ModalRoot>`):

```tsx
import {
  ConfigProvider, SettingsProvider, ThemeProvider, SafeModeProvider,
  InteractionsProvider, McpAuthProvider, DiscoveryProvider,
  DEFAULT_SETTINGS, resolveConfig, resolveTheme, useSettings,
} from 'zidane/chat'

<ConfigProvider config={resolveConfig({ store: myStoreFactory })}>
  <SettingsProvider initial={{ ...DEFAULT_SETTINGS, ...config.initialSettings }} onChange={...}>
    <ThemedShell>
      <SafeModeProvider>
        <InteractionsProvider>
          <McpAuthProvider>
            <DiscoveryProvider value={discoveryValue}>
              <YourScreens />
            </DiscoveryProvider>
          </McpAuthProvider>
        </InteractionsProvider>
      </SafeModeProvider>
    </ThemedShell>
  </SettingsProvider>
</ConfigProvider>
```

The TUI's `<App>` (`src/tui/app.tsx`) adds two OpenTUI-only providers underneath — `MdStyleProvider` + `ChipStyleProvider` — that pre-build `SyntaxStyle` instances. A GUI maps the same styles to CSS variables / Tailwind tokens directly off the `Theme` object instead.

`ChatOptions.store` is required — chat ships no default so the bundle stays platform-neutral. Terminal hosts pass `createTuiStore` from `zidane/session/sqlite`; an Electron / Tauri host can pass `createFileMapStore` or any custom `SessionStore` (IndexedDB, remote, in-memory).

**Agent build** — wire interaction tools, OAuth-aware MCP connector, project skills + MCPs, and the cwd-anchored system prompt:

```ts
import {
  buildBuildSystem, buildSkillsConfig, buildMcpServers,
  createFileMcpCredentialStore, createInteractionTools, makeRequestInteraction,
  defaultSkillScanPaths, discoverProjectSkills, discoverProjectMcps,
} from 'zidane/chat'
import { connectMcpServers } from 'zidane/mcp'
import { McpOAuthProvider } from 'zidane/mcp'

const credentialStore = createFileMcpCredentialStore(paths.userDir)
const skillsScan = defaultSkillScanPaths({ cwd: projectDir, prefix })
const skillsConfig = buildSkillsConfig({ scan: skillsScan, enabled: settings.enabledSkills })
const { servers: discoveredMcps } = discoverProjectMcps({ cwd: projectDir, prefix })
const mcpServers = buildMcpServers({ discovered: discoveredMcps, enabled: settings.enabledMcps })

const agent = createAgent({
  ...profile.preset,
  system: buildBuildSystem({ cwd: projectDir }),
  skills: skillsConfig,
  mcpServers,
  tools: {
    ...profile.preset.tools,
    ...createInteractionTools({ requestInteraction: makeRequestInteraction(interactionsActions) }),
  },
  provider: descriptor.factory(),
  session,
  mcpConnector: configs => connectMcpServers(configs, undefined, agent.hooks, {
    buildAuthProvider: cfg => new McpOAuthProvider({ name: cfg.name, store: credentialStore }),
  }),
})
```

**Required hooks** (wire on every `buildAgent` call — see `src/tui/app.tsx` for the canonical wiring):

| Hook | Purpose | Consumer in chat layer |
|---|---|---|
| `tool:gate`, `child:tool:gate`, `mcp:tool:gate`, `child:mcp:tool:gate` | Approval gate | `useSafeModeActions().requestApproval(name, input, originator?)` + (for file-edit tools) `resolveApprovalForPayload`; partial → rebind `ctx.input` to the approved subset + stash outcomes in a pending-annotation map. See **Per-edit approval**. |
| `tool:transform`, `child:tool:transform` | Per-edit annotation | Look up `callId` in the pending-annotation map and append `buildEditOutcomesAnnotation(outcomes)` to `ctx.result`. Clear the entry. |
| `agent:done` | Stranded-annotation sweep | `pendingAnnotations.clear()` — covers completed / aborted / error paths so a never-fired `tool:transform` doesn't leak. |
| `mcp:auth:required`, `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error`, `mcp:connect` | OAuth badge state | `useMcpAuthDispatch` → `reduceMcpAuth` |
| `stream:thinking`, `stream:text`, `child:stream:thinking`, `child:stream:text` | Streaming deltas | `useStreamBuffer().queueStreamDelta` |
| `tool:before`, `tool:after`, `mcp:tool:after`, `child:tool:before`, `child:tool:after` | Tool call/result events | `stream.appendImmediate` |
| `spawn:before`, `spawn:complete`, `spawn:error`, `child:stream:end` | Subagent markers | `stream.appendImmediate` + `finalizeStreamingMarkdownForOwner` |
| `turn:after` | Finalize trailing markdown, capture context size, run `shouldAutoCompact` | `stream.flushAndUpdate` + `triggerAutoCompact` |

**Post-turn auto-compact** — call `shouldAutoCompact({ enabled, threshold, inputTokens, rawContextWindow, alreadyCompacting })` after `agent.run()` resolves. On `'fire'`, run `compactConversation` from `zidane` in the background, store the promise on a ref, and **await it before the next `agent.run()`** so the next prompt sees the post-compaction history. See **Auto-compaction** below.

**Session activation** — call `pendingInteractionsFromTurns(session.turns)` on activation. If non-empty, enqueue each request with a resolver that collects responses into a `Map`, and once full calls `buildResumedToolResultsTurn(requests, responses, { turnId, runId })` + appends + triggers a prompt-less `agent.run()`. See `docs/INTERACTIONS.md` for the full protocol.

**Tool-result persistence** — set `behavior.persistDir` (per session, under `paths.userDir`) and let `DEFAULT_PERSIST_EXCLUDE_TOOLS` filter out the always-inline tools. Toggle off by setting `behavior.persistThreshold = 0` (the `<persisted-output>` branch in the loop short-circuits).

**Session export / details** — `writeSessionExport`, `renderSession`, `generateSessionTitle`, `compactConversation`, `deleteTurnSafely`, `truncateTurnsAt` cover every action a session-details surface needs.

Everything else (file completion, slash-commands, edit-diff rendering, chip pills, keybindings parsing) is detailed in the per-module sections below.

## Sub-modules

The table below indexes every named export; sections further down dive into the surfaces a GUI builder needs.

| Module | Surface |
|---|---|
| `agents` | Multi-profile registry — `AgentProfile`, `AgentRegistry`, `AgentAccent`, `BUILD_AGENT`, `PLAN_AGENT`, `BUILTIN_AGENTS`, `DEFAULT_AGENT_ID`, `DEFAULT_PERSIST_EXCLUDE_TOOLS`, `resolveAgentId`, `singleAgentRegistry`, `accentColor`. See **Agents** below. |
| `agent-prompt` | Composable system-prompt fragments — `IDENTITY_PREFIX`, `DOING_TASKS_DOCTRINE`, `ACTIONS_WITH_CARE_DOCTRINE`, `TOKEN_DISCIPLINE_DOCTRINE`, `COMMUNICATION_DOCTRINE`, `SUBAGENT_GUIDANCE`, `PLAN_MODE_DOCTRINE`, `PLAN_MODE_DOCTRINE_NO_PROMPTS`, `INTERACTION_GUIDANCE`, `INTERACTION_GUIDANCE_NO_PROMPTS`, `envSection`, `buildBuildSystem`, `buildPlanSystem`. Types: `BuildSystemOptions`, `EnvSectionOptions`. See **System prompt** below. |
| `agents-md` | Personal-instructions discovery — `discoverAgentsMd({ cwd, home, prefix, scope })` walks `~/.agents/AGENTS.md`, `~/.{prefix}/AGENTS.md`, `~/.claude/CLAUDE.md`, `<repo>/AGENTS.md`, `<repo>/.agents/AGENTS.md`, `<repo>/.{prefix}/AGENTS.md`, `<repo>/CLAUDE.md` and loads **every** file that exists (broadest → narrowest, deduped by absolute path; empty / whitespace-only files skipped). The `scope` option (`'none'` / `'user'` / `'project'` / `'both'`, default `'both'`) restricts which scopes contribute — `'none'` short-circuits before any filesystem access. Renders a `userInstructions` block consumed by `buildBuildSystem` / `buildPlanSystem`. Types: `AgentsMdFile`, `AgentsMdResult`, `AgentsMdScope`. |
| `auth` | `detectAuth(dataDir, providers)` reports which providers have usable credentials and which auth method each accepts. `ProviderAuth.methods[].source` is `'apikey' \| 'oauth'`. |
| `auto-compact` | `shouldAutoCompact(input)` — pure predicate returning `'fire' \| 'skip'`. See **Auto-compaction**. |
| `boot-profiler` | `bootTick(label)` / `bootProfileEnabled()` — `ZIDANE_BOOT_PROFILE=1` opt-in wall-clock checkpoints for boot-path perf work. |
| `browser` | `tryOpenBrowser(url)` — best-effort cross-platform launcher (`open` / `xdg-open` / `start`); used by both provider OAuth and MCP OAuth. |
| `color-gradient` | `blendHsl`, `buildLinearRamp` — HSL-space ramp builders shared by the TUI's throbber + title overlay. |
| `completion` | Extensible prompt autocompletion. `CompletionProvider<TItem>`, `useCompletion`, helpers (`findActiveTrigger`, `applyInsert`, `collectReferences`, `mergeReferences`). See **Completion**. |
| `completion-skills`, `completion-files` | Built-in `/` (skills) + `@` (files) providers. Submit helpers: `uniqueSkillNamesFromReferences`, `uniqueFilesFromReferences`. |
| `config` + `config-context` | `resolveConfig`, `resolveStoragePaths`, `useConfig`, `ChatOptions`, `ResolvedConfig`, `ResolvedPaths`, `ModelInfo`, `ProviderRegistry`. See **Required options**. |
| `xdg` | XDG Base Directory resolver — `resolveStorageDirs({ prefix, storageDir?, home?, platform?, env? })` returns `{ configDir, dataDir, stateDir, cacheDir, mode }`. Honors `XDG_*_HOME` / `ZIDANE_*_DIR` env vars; preserves legacy `~/<prefix>/` for existing setups. Types: `StorageDirs`, `StorageMode`, `StorageSlot`. See **Storage-slot layout**. |
| `credentials` | AI-provider credential store. `setProviderCredential`, `readProviderCredential`, `removeProviderCredential`, `readCredentials`, `writeCredentials`, `credentialsPath`, `applyApiKeyEnv` (called by `resolveConfig` before any factory runs). Owner-only file mode. |
| `discovery-context` + `discovery-slot` | Live catalog plumbing: `DiscoveryProvider`, `useDiscovery`, `useDiscoveryOptional`, `createDiscoverySlot` (stale-while-revalidate primitive). Propagates fresh catalogs (files, skills, MCPs) into open modals. See **Discovery context** below. |
| `edit-approval` | Pure helpers bridging the safe-mode gate to the host-side per-hunk flow — `resolveApprovalForPayload`, `maskToOutcomeKinds`, `buildEditOutcomesAnnotation`, `parseEditOutcomesFromResult`, `summarizeOutcomes`, `ResolvedApproval`. See **Per-edit approval**. |
| `edit-diff` | Diff plumbing — `extractEditPayload`, `previewEditPayload`, `applyEditPayload`, `buildUnifiedDiff`, `buildContextualDiff`, `computeLineDiff`, `computeInlineDiff`, `splitLines`, `tokenize`, `filetypeFromPath`. See **Edit-diff rendering**. |
| `enabled-toggle-set` | `useEnabledToggleSet({ catalog, keyOf, settingKey })` — generic state machine for `enabledSkills` / `enabledMcps` (undefined = all enabled, `[]` = off, `[names]` = allowlist). |
| `files-discovery` | `listProjectFiles({ cwd, signal? })` → `FileEntry[]`, gitignored paths excluded. |
| `format` | `fmtTokens`, `ageString`, `shortId`, `compactPath` — display-only helpers. |
| `generate-title` | `generateSessionTitle({ session, provider, model, signal? })` + `cleanTitle`. |
| `hints` | Footer + prompt-hint plumbing — `Hint`, `hintsLength`, `clipHintsToWidth`, `truncateTrailing`. Plain-text math; rendering is the consumer's job. |
| `interactions` | Plan + Q&A protocol (`present_plan`, `ask_user`). See **Interactions** and `docs/INTERACTIONS.md`. |
| `keybindings` | Action catalog + JSONC overrides — `KEYBINDING_DEFS`, `KEYBINDING_DEF_BY_ACTION`, `DEFAULT_KEYBINDINGS`, `parseBindingSpec`, `matchesBinding`, `mergeKeybindings`, `formatBindingForDisplay`, `groupBindings`, `KEYBINDING_KEY_COL_WIDTH`, `readKeybindings`, `ensureKeybindingsFile`, `keybindingsPath`, `stripJsonComments`. See **Keybindings**. |
| `markdown-segments` | `splitMarkdownCodeBlocks(text)` → `MarkdownSegment[]` (`'prose' \| 'code'`). Lets transcript renderers wrap fences with custom chrome (copy buttons, language pills) without re-parsing. |
| `mcp-auth-state` | Pure reducer — `reduceMcpAuth(state, event)`, `getMcpAuthStatus`, `McpAuthStatus`, `McpAuthEvent`, `McpAuthStateMap`. See **MCP OAuth state**. |
| `mcp-auth-context` | React provider — `McpAuthProvider`, `useMcpAuthState`, `useMcpAuthDispatch`. Two contexts split so dispatch consumers don't re-render on state churn. |
| `mcp-credentials` | File-backed `McpCredentialStore` at `<dataDir>/mcp-credentials.json` (0o600). `createFileMcpCredentialStore`, `patchMcpCredential`, `mcpCredentialsPath`. |
| `mcp-rows` | Renderer-agnostic flat row model shared by the standalone `McpsSettingsModal` and the inline MCP tab inside the unified `SettingsModal`. `buildVisibleMcpRows(catalog, expanded, toolsByServer)` → `VisibleMcpRow[]` (`'server' \| 'tool' \| 'tool-empty'`); `indexOfServerRow`, `parentServerName`. |
| `mcp-tool-toggle` | Per-tool deny-list state machine over `Settings.disabledMcpTools[serverName]`. `useMcpToolToggleSet({ serverName, catalog })` for a single server; `useMcpToolToggleMap({ catalogByServer })` for the multi-server settings UI (stable hook count across React renders). `buildToolToggle` for renderer-agnostic factory use. |
| `mcp-tools-cache` | Per-server tool catalog persistence at `<dataDir>/mcp-tools-cache.json`. `loadMcpToolsCache`, `saveMcpToolsCache`, `clearMcpToolsCache`, `mcpToolsCachePath`, `CachedMcpToolList`, `McpToolsCache`. `subscribeMcpToolsCache(agent.hooks, { dataDir, onChange? })` listens on `mcp:tools:list` and writes the file; `refreshMcpToolsCatalog({ servers, hooks, ... })` opens transient connections to repopulate without a full agent run. |
| `mcps-discovery` | `discoverProjectMcps`, `parseMcpsFile`, `buildMcpServers`, `defaultMcpsConfigPaths`, `DiscoveredMcp`, `DiscoveryError`, `DiscoveryResult`. `buildMcpServers` now also accepts `disabledTools: Settings['disabledMcpTools']` and unions it into each config's `disabledTools` (the JSON-side deny-list still wins on collisions). See **Project MCP servers**. |
| `model-catalog` | Cross-provider unified model picker assembly — `buildModelCatalog`, `filterModelCatalog`, `indexOfEntry`, `CatalogEntry`. |
| `oauth` | `runOAuthLogin(descriptor, { onUrl, onPrompt, onProgress, signal })` + `supportsOAuth(descriptor)` + `oauthUsesManualCodePaste(descriptor)` — AI-provider OAuth (distinct from MCP OAuth). |
| `oauth-redirect` | `fetchOAuthRedirect(pastedUrl)` — SSH escape hatch. Validates `pasted` is a loopback URL, fires a GET so the in-process callback server (pi-ai's or `startOAuthCallback`'s) receives the request through its normal handler. Used by the TUI to accept a redirect-URL paste when the browser couldn't reach the callback server directly. |
| `path-display` | `formatPathForCwd(projectRel, projectRoot, cwd)` — rewrites a project-root-relative path (as emitted by `listProjectFiles`) into the form the agent's CWD-resolving tools actually find. Wired into `createFilesCompletionProvider({ formatPath })` by the TUI shell. |
| `project-root` | `findGitRoot(cwd)` — walks upward looking for `.git`, returns absolute path or `null`. Used for session scope tagging + export anchors. |
| `prompt-segments` | `splitPromptSegments(text, refs)` → `PromptSegment[]`. GUI maps the same segments to inline-block chip pills. |
| `project-user-paths` | `projectUserPaths({ subPath, cwd, home, prefix })` — shared search-order builder for project + user config discovery. Powers `defaultSkillScanPaths` (`'skills'`) and `defaultMcpsConfigPaths` (calls it twice, once per `mcps.json` / `mcp.json`); the same convention extends to any new discovery surface. |
| `providers` | `ProviderDescriptor` registry + helpers — `BUILTIN_PROVIDERS` (`anthropicDescriptor`, `openaiDescriptor`, `openrouterDescriptor`, `cerebrasDescriptor`, `arceeDescriptor`, `localDescriptor`), `modelsForDescriptor`, `getModelInfo`, `getContextWindow`, `effectiveContextWindow`, `modelSupportsReasoning`, `OUTPUT_RESERVE_TOKENS`, `credKeyOf`, `piIdOf`. `cursorDescriptor` is exported but NOT in `BUILTIN_PROVIDERS` (OAuth works, inference not wired). See **Local provider**. |
| `safe-mode` | Per-project safelist + matchers — `IMPLICITLY_SAFE_TOOLS`, `suggestSafelistEntry`, `addToSafelist`, `getSafelist`, `isOnSafelist`, `matchesSafelistEntry`, `readProjects`, `writeProjects`, `projectsFilePath`. |
| `safe-mode-context` | `SafeModeProvider` owns a FIFO approval queue. `useSafeModeQueue()` returns the live array; `useSafeModeActions()` returns stable `{ requestApproval, resolveHead, denyAll }`. |
| `session-export` | `renderSession`, `resolveSessionExportTarget`, `writeSessionExport`, `SessionExportFormat`, `SessionExportAnchor`, `SessionExportTarget`. |
| `settings-context` | `SettingsProvider`, `useSettings`, `DEFAULT_SETTINGS`, `SETTINGS_TOGGLES`, `SETTINGS_CHOICES`, `SETTINGS_CATEGORIES`, `BooleanSettingKey`, `SettingsCategory`, `SettingsCategoryDescriptor`. See **Settings**. |
| `skills-discovery` | `discoverProjectSkills`, `buildSkillsConfig`, `defaultSkillScanPaths`. |
| `store` | Session reconstruction + persisted UI state + transcript view rules — `eventsFromTurns`, `lastContextSizeFromTurns`, `listSessionMeta`, `deriveSessionTitle`, `titleFromTurns`, `selectableTurnIds`, `stripSpawnTokensLine`, `toolCallPreview`, `toolResultText`, `createStateStore`, `loadState`, `saveState`, `isVisible`, `marginTopFor`, `isEditErrorResult`, `isTurnHighlighted`, `sumRunCosts`, `turnSelectionOwnership`, `TuiState`, `StateStoreApi`. |
| `streaming` | `useStreamBuffer`, `finalizeStreamingMarkdown`, `finalizeStreamingMarkdownForOwner`, `turnContextSize`. Per-owner finalize handles concurrent parent + child streams. |
| `theme` + `theme-context` | `Theme`, `BUILTIN_THEMES`, `resolveTheme`, `resolveChipColor`, `DEFAULT_THEME`, themes (`CATPPUCCIN_*`, `VAPORWAVE_THEME`), hooks (`useTheme`, `useColors`, `useSelectStyle`, `useSurfaces`, `useSyntaxStyles`). See **Theme**. |
| `todos` | `todowrite` / `todoread` tool factory — `createTodoTools` (returns a `Preset`), `isTodoTool`, `getTodosForRun`, `setTodosForRun`, `pruneTodosByRun`, `TODOWRITE_TOOL`, `TODOREAD_TOOL`, `TODOS_METADATA_KEY`, `TODO_WRITE_COUNTS_METADATA_KEY`, `TodoItem`, `TodoStatus`, `TodosBag`, `CreateTodoToolsOptions`. Persistent task checkpointing — top-level runs share a session-scoped slot (continuity across prompts); subagent runs get their own slot (isolation). On by default: a run-scoped `block-after` dedup (`maxIdenticalWrites`) hard-blocks consecutive identical payloads (loop breaker). Optional blanket per-run budget via `behavior.toolBudgets`; a soft in-body identical dedup also short-circuits to "No change". Compose with `composePresets` or spread. See **Todos** below. |
| `tool-formatters` | Per-tool display verbs + curated input formatters — `TOOL_DISPLAY`, `displayNameFor`, `formatToolCall`, `ToolDisplayMeta`, `ToolFormatLine`. Powers the `'formatted'` `toolCallDisplay` view. See **Tool call display**. |
| `transcript-anchors` | `computeTurnAnchors(items)` → `TranscriptItem[]` — assigns `turn-anchor-<turnId>` ids consumers wire into their renderer's scroll-into-view (TUI: `ScrollBoxRenderable.scrollChildIntoView`; GUI: DOM `element.scrollIntoView`). |
| `turn-operations` | `deleteTurnSafely`, `truncateTurnsAt`, `turnAsText`, `countNeighbors`. Fork / delete with orphan tool-pair cleanup. |
| `types` | Re-exports `Owner`, `Picked`, `Screen`, `SessionMeta`, `Settings`, `StreamEvent`, `EditPayload`, `EditHunk`, `EditOutcome`, `EditOutcomeKind`, `ToolCallDisplay`. |

## Required options

`resolveConfig(options)` accepts a single object. Everything is optional except `store`:

```ts
resolveConfig({
  // Required: session storage adapter.
  // Instance or factory `(paths) => SessionStore`.
  store: paths => createTuiStore(paths.db),

  // Storage location.
  prefix: '.zidane',                // default `.zidane`
  storageDir: process.env.HOME,     // default homedir; resolves to `${storageDir}/${prefix}`

  // Project-scoped session storage (off by default).
  // When `true` AND inside a git repo, sessions.db + state.json land at
  // `<git-root>/.{prefix}/`; credentials + safelist stay at user dir.
  projectDb: false,
  cwd: process.cwd(),               // override the project-root walk root

  // Provider registry — defaults to BUILTIN_PROVIDERS (Anthropic + OpenAI + OpenRouter + Cerebras + Arcee + Local).
  providers: { ...BUILTIN_PROVIDERS, mine: myDescriptor },

  // Agent registry — defaults to BUILTIN_AGENTS (Build + Plan).
  agents: { ...BUILTIN_AGENTS, debug: myProfile },
  defaultAgent: 'build',

  // Legacy single-agent shorthand. Mutually exclusive with `agents`.
  preset: myPreset,                 // wrapped in a one-entry registry
})
```

`resolveConfig` applies API keys into `process.env` and writes `ZIDANE_CREDENTIALS_PATH` before any factory runs — wizards on fresh installs render without throwing "no API key" errors.

### Env vars

| Var | Effect |
|---|---|
| `ZIDANE_PREFIX` | Overrides `options.prefix`. |
| `ZIDANE_STORAGE_DIR` | Pin all four storage slots to a single dir (legacy single-dir layout). When unset, the XDG resolver kicks in on Linux (or any platform with an `XDG_*_HOME` set / per-slot `ZIDANE_*_DIR` set). |
| `ZIDANE_CONFIG_DIR` | Override the XDG config slot (default `$XDG_CONFIG_HOME/zidane`). Ignored when `ZIDANE_STORAGE_DIR` is set. |
| `ZIDANE_DATA_DIR` | Override the XDG data slot (default `$XDG_DATA_HOME/zidane`). |
| `ZIDANE_STATE_DIR` | Override the XDG state slot (default `$XDG_STATE_HOME/zidane`). |
| `ZIDANE_CACHE_DIR` | Override the XDG cache slot (default `$XDG_CACHE_HOME/zidane`). |
| `ZIDANE_PROJECT_DB` | `'0' / 'false' / 'off' / 'no'` → off; `'1' / 'true' / 'on' / 'yes'` → on. Sits between `options.projectDb` and the user-config file. |
| `ZIDANE_CREDENTIALS_PATH` | Set by `resolveConfig` itself; the harness providers read it. |
| `ZIDANE_DEBUG` | Enables stderr logging for swallowed errors (teardown, save, MCP bootstrap). |
| `LOCAL_LLM_BASE_URL` | Local provider endpoint, e.g. `http://localhost:11434/v1` (Ollama). Required for `local`. Mirrored from the wizard's `baseURL` field. See **Local provider**. |
| `LOCAL_LLM_API_KEY` | Optional bearer key for the local endpoint (most local servers are unauthenticated; defaults to a placeholder). |
| `LOCAL_LLM_DEFAULT_MODEL` | Default model id the local server exposes (e.g. `llama3.1:8b`). Surfaced as the `local` provider's single picker entry. |
| `LOCAL_LLM_CONTEXT_WINDOW` | Override the assumed context window for the local model (positive integer). Defaults to `8192`; invalid values fall back to the default. |

### Local provider

`localDescriptor` (key `local`, label *Local LLM*) points zidane at any
self-hosted OpenAI-compatible server — Ollama, vLLM, LM Studio, Lemonade, llama.cpp's server,
text-generation-webui, etc. It's a thin wrapper over the shared `openaiCompat` provider, so it
speaks the standard `POST /chat/completions` + SSE dialect.

It has **no `envKey` and no fixed model catalogue** — both the endpoint and the model come from
the user. Configuration goes through the wizard's `customFields` (persisted in the apikey
credential and mirrored into env vars by `applyApiKeyEnv` at launch):

| Field | Env var | Required | Notes |
|---|---|---|---|
| Base URL | `LOCAL_LLM_BASE_URL` | yes | e.g. Ollama `http://localhost:11434/v1`, vLLM `http://localhost:8000/v1`, LM Studio `http://localhost:1234/v1`. |
| API key | `LOCAL_LLM_API_KEY` | no | Only for servers started with auth (vLLM `--api-key`, gateways). Blank → placeholder. |
| Default model | `LOCAL_LLM_DEFAULT_MODEL` | no | The model id your server exposes. When set, it's surfaced as the provider's single model-picker entry. |

**Configure via the TUI:** open the auth screen → pick *Local LLM* → the wizard
walks the three fields → credentials are saved; the provider then appears in the model picker once a
default model is set. (From Settings → Authentication, the *add or re-configure* action jumps
straight into this wizard.)

**Configure via env (headless / hosts):** set `LOCAL_LLM_BASE_URL` (+ optionally the others) before
launch — `detectAuth` treats the provider as available once all required fields resolve.

**Context window** is unknown over the OpenAI-compat API, so it defaults to a conservative `8192`
(footer indicator + auto-compaction lean on this — under-estimating is the safe direction). Raise it
with `LOCAL_LLM_CONTEXT_WINDOW` for larger-context models. Vision / cache / reasoning are off by
default (most OSS local deployments strict-validate the request schema); pass a custom descriptor
calling `local({ capabilities })` to flip them.

### Project-DB precedence

Project mode resolves through: `options.projectDb` > env var > `<configDir>/config.json` `projectDb` flag > default (off). It activates only when the resolution lands on `true` **and** a git repo is found above `cwd`; otherwise everything stays at the user-scoped slots.

### Storage-slot layout

`zidane` writes to four logical slots. The XDG resolver (`resolveStorageDirs`) picks between four resolution modes:

| Mode | Trigger | Layout |
|---|---|---|
| `legacy-explicit` | `options.storageDir` set OR `ZIDANE_STORAGE_DIR` set | All four slots collapse to `<storageDir>/<prefix>`. |
| `legacy-existing` | `~/<prefix>/` exists on disk | All four slots collapse to `~/<prefix>/`. Existing users keep their setup unchanged. |
| `xdg` | Linux, OR any `XDG_*_HOME` / `ZIDANE_*_DIR` set | Slots fan out per spec (see below). |
| `default` | Otherwise (mac/windows fresh) | All four slots collapse to `~/<prefix>/`. |

In XDG mode the four slots map to:

| Slot | Default | Holds |
|---|---|---|
| `configDir` | `$XDG_CONFIG_HOME/zidane` (`~/.config/zidane`) | `credentials.json`, `mcps.json`, `mcp-credentials.json`, `keybindings.json`, `config.json`, `projects.json` (safelist), `skills/`. |
| `dataDir` | `$XDG_DATA_HOME/zidane` (`~/.local/share/zidane`) | `sessions.db`. |
| `stateDir` | `$XDG_STATE_HOME/zidane` (`~/.local/state/zidane`) | `state.json`. |
| `cacheDir` | `$XDG_CACHE_HOME/zidane` (`~/.cache/zidane`) | Persisted tool-result blobs, background-task logs, auto-update registry cache. |

### `ResolvedPaths`

```ts
interface ResolvedPaths {
  dir: string                // effective root (configDir or projectDir)
  userDir: string            // legacy alias for `configDir` — credentials + safelist live here
  projectDir: string | null  // `<git-root>/<prefix>` when project mode is on
  db: string                 // `<dataDir>/sessions.db` (or projectDir/sessions.db in project mode)
  state: string              // `<stateDir>/state.json` (or projectDir/state.json in project mode)
  configDir: string          // see Storage-slot layout
  dataDir: string
  stateDir: string
  cacheDir: string
  storageMode: 'legacy-explicit' | 'legacy-existing' | 'xdg' | 'default'
}
```

### `ResolvedConfig`

```ts
interface ResolvedConfig {
  prefix: string
  storageDir: string
  paths: ResolvedPaths
  providers: ProviderRegistry
  agents: AgentRegistry
  initialAgentId: string
  store: SessionStore
  stateStore: StateStoreApi
  modelsFor: (key: ProviderKey) => readonly ModelInfo[]
  initialState: TuiState
  initialSettings: Partial<Settings>
  resumeProvider: ProviderAuth | null
  initialPicked: Picked | null
  keybindings: KeyBindings
}
```

### Persisted UI state (`state.json`)

```ts
interface TuiState {
  lastProvider?: ProviderKey
  lastSessionId?: string
  lastModelByProvider?: Partial<Record<ProviderKey, string>>
  lastEffortByModel?: Record<string, ThinkingLevel>
  lastAgent?: string
  settings?: Partial<Settings>
}
```

`createStateStore(path)` returns `{ load, save }`; atomic write via the shared `writeFileAtomic` helper (`tmp + rename`, sibling temp suffix folds in `process.pid + Date.now()` so two writers in the same process can't collide) so a crash mid-write never leaves a half file. Corrupt state files are treated as absent so the user can still reach the auth screen. The same helper backs every persisted JSON in the chat layer — state, safelist (`projects.json`), credentials (`credentials.json`, mode `0o600`), MCP credentials (`mcp-credentials.json`, mode `0o600`), `keybindings.json`.

## Agents

A profile bundles a `Preset` with display metadata so a GUI can render a picker, persist the choice, and rebuild the agent on switch. Profiles are self-contained — the host's chat-level `preset` (if any) is NOT merged underneath.

```ts
interface AgentProfile {
  id: string                          // persisted in `TuiState.lastAgent`
  label: string                       // displayed in the picker + status bar
  description: string                 // one-line picker hint
  preset: Preset                      // applied to `createAgent()`
  accent?: AgentAccent                // 'brand' | 'accent' | 'warn' | 'model'
}
```

Built-ins (`BUILTIN_AGENTS`):

- **`BUILD_AGENT`** (`build`) — full tool access: `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`, `glob`, `grep`, `webSearch`, `fetchUrl`, plus `spawn` (with `persist: true` so reloads can reconstruct the subagent tree) and `todowrite` / `todoread` layered in via `composePresets(..., createTodoTools())` (see **Todos**).
- **`PLAN_AGENT`** (`plan`) — read-only: `readFile`, `listFiles`, `glob`, `grep`, `webSearch`, `fetchUrl`. System prompt is `PLAN_MODE_DOCTRINE`. No todos — a read-only exploration session has nothing to checkpoint.

Both share `SHARED_BEHAVIOR`:

```ts
{
  // context / token discipline
  compactStrategy: 'tail',
  compactThreshold: 128 * 1024,
  compactKeepTurns: 4,
  elideStaleReads: true,
  toolDisclosure: 'lazy',
  toolOutputBudget: 64 * 1024,
  toolOutputBudgetExcludeTools: DEFAULT_BUDGET_EXCLUDE_TOOLS,
  persistThreshold: 8 * 1024,
  persistExcludeTools: DEFAULT_PERSIST_EXCLUDE_TOOLS,

  // loop prevention (ON in the chat profiles; OFF in the SDK core)
  requireReadBeforeEdit: true, // reject edit/multi_edit on a file not read this
                               // session; write_file seeds read-state so the
                               // write→edit flow still works
  dedupTools: {                // replay-from-cache on identical discovery calls
    glob: discoveryDedupHash,
    grep: discoveryDedupHash,
    list_files: discoveryDedupHash,
  },
  repeatGuard: {},             // consecutive-identical streak: block @4, abort @8
                               // (defaults track `shell` + `*_execute_sql`)
  thinkingDecay: { afterTurn: 10, factor: 0.5, floor: 1024 }, // no-op unless a
                               // thinking budget is set
}
```

`DEFAULT_PERSIST_EXCLUDE_TOOLS` = `['read_file', 'tool_search', 'skills_use', 'skills_read', 'present_plan', 'ask_user', 'spawn', 'todowrite', 'todoread']`. SDK consumers building custom profiles can extend this for any host-defined tool whose output should stay inline regardless of size. `DEFAULT_BUDGET_EXCLUDE_TOOLS` = `['tool_search', 'skills_use', 'skills_read']` (context-loading tools exempt from the per-turn output budget).

The loop-prevention knobs are the one place the chat profiles diverge from the SDK core's conservative defaults. `requireReadBeforeEdit` is the only one that can *reject* a previously-succeeding call — but the refusal is a recoverable `tool_result` (`Edit error: <path> has not been read in this session. Call read_file first …`), not a hard error, so a model self-corrects in one turn. `repeatGuard` blocks the 4th back-to-back identical `shell` / `*_execute_sql` call and aborts the run at the 8th (via the agent's `AbortController`, preceded by a `repeat-guard:exceeded` event) — the only layer that stops a model ignoring the `Blocked:` reason. `dedupTools` replay and `thinkingDecay` are free / inert until triggered.

`resolveAgentId(registry, requestedId, defaultId)` picks the next active profile with fallbacks: requested id > defaultId > first registry key > `null`. `singleAgentRegistry(preset)` wraps a legacy `preset` into a one-entry registry — used internally when the host passed `preset` without `agents`.

`accentColor(profile.accent, COLOR)` resolves an `AgentAccent` token (`'brand' | 'accent' | 'warn' | 'model'`, defaulting to `'accent'`) to a hex string from the four-role color subset the caller passes. Renderer-agnostic — the TUI's footer + picker call it with `useColors()`, a GUI can hand it a CSS-variable lookup with the same shape.

## System prompt

Renderer-agnostic fragments designed to be byte-stable across sessions so the Anthropic ephemeral cache breakpoint keeps hitting. Every constant in `agent-prompt` is a self-contained section; the two composers (`buildBuildSystem`, `buildPlanSystem`) assemble them in cache-friendly order around the `SYSTEM_PROMPT_BOUNDARY` marker:

```ts
buildBuildSystem({ cwd: projectDir, allowInteraction: true })
// STATIC (cached):
//   IDENTITY_PREFIX
//   + DOING_TASKS_DOCTRINE + ACTIONS_WITH_CARE_DOCTRINE + TOKEN_DISCIPLINE_DOCTRINE
//   + SUBAGENT_GUIDANCE + COMMUNICATION_DOCTRINE
//   + (allowInteraction ? INTERACTION_GUIDANCE : INTERACTION_GUIDANCE_NO_PROMPTS)
//   + opts.userInstructions
// ── __ZIDANE_SYSTEM_PROMPT_BOUNDARY__ ──
// DYNAMIC (not cached):
//   envSection({ cwd, date, platform })

buildPlanSystem({ cwd: projectDir, allowInteraction: true })
// STATIC (cached):
//   IDENTITY_PREFIX
//   + (allowInteraction ? PLAN_MODE_DOCTRINE : PLAN_MODE_DOCTRINE_NO_PROMPTS)
//   + TOKEN_DISCIPLINE_DOCTRINE + COMMUNICATION_DOCTRINE
//   + (allowInteraction ? INTERACTION_GUIDANCE : INTERACTION_GUIDANCE_NO_PROMPTS)
//   + opts.userInstructions
// ── __ZIDANE_SYSTEM_PROMPT_BOUNDARY__ ──
// DYNAMIC (not cached):
//   envSection(...)
```

The provider attaches `cache_control` to the static prefix only; per-turn `<env>` rewrites in the TUI re-bill the dynamic suffix but don't invalidate the cached doctrine above. When `cwd` is omitted (no `envSection` to render), the marker is dropped automatically — single-block cache, identical wire bytes to before. See `docs/ARCHITECTURE.md` § Prompt caching > System-prompt boundary for the underlying mechanism.

Pass `allowInteraction: false` (mirror of `Settings.allowInteraction`) to swap in the no-prompts variants — the host should also drop `ask_user` / `present_plan` from the tool set at agent build time so the model can't call tools that aren't reflected in the prompt.

**Per-turn env rewrite.** The TUI installs a `system:transform` hook that calls `replaceDynamicSection(ctx.system, envSection(...))` each turn — refreshes the cwd / project root / date below the boundary while leaving the cached static prefix byte-identical:

```ts
import { replaceDynamicSection } from 'zidane'

agent.hooks.hook('system:transform', (ctx) => {
  ctx.system = replaceDynamicSection(ctx.system, envSection({ cwd, projectRoot, allowInteraction }))
})
```

SDK consumers calling `createAgent({ ...BUILD_AGENT.preset })` directly get the cwd-less, marker-free variant and can wrap their own env section via `appendDynamicSection` / `replaceDynamicSection`.

Custom profiles: import the doctrine fragments and spread them in any order — `buildBuildSystem` is just one composition. Use `joinSystemPrompt(static, dynamic)` to opt into the boundary in your own composer, or skip the marker entirely for whole-prompt caching.

## Settings

`SettingsProvider` holds a `Settings` value and persists changes via `onChange` (called from `useEffect([settings])` to avoid double-firing under React 18 strict-mode dev).

```ts
interface Settings {
  showThinking: boolean
  /**
   * Tool call rendering mode — `'hidden' | 'formatted' | 'full'`.
   * Default: `'formatted'`. Legacy `state.json` files migrate on first load.
   */
  toolCallDisplay: ToolCallDisplay
  showToolResults: boolean
  /** Route every tool call through the approval gate. Default: on. */
  safeMode: boolean
  /** Retain `ask_user` + `present_plan` tools and the matching prompt sections. Off strips both at session activation and swaps in the no-prompts doctrine variants. Default: on. */
  allowInteraction: boolean
  /** Collapse subagent runs to start/end markers. Default: on. */
  hideSubagentOutput: boolean
  /** Active theme id — `resolveTheme` falls back to DEFAULT on unknown ids. */
  theme: string
  /** Cross-project sessions list. Default: off. */
  showAllProjects: boolean
  /** Auto-resume the last session on launch (snapshotted at mount). Default: on. */
  resumeLastSession: boolean
  /** Persist >8 KiB tool outputs to disk as <persisted-output> stubs. Default: on. */
  persistToolResults: boolean
  /** Fire compaction when latest turn's input usage crosses `threshold × effectiveWindow`. Default: on. */
  autoCompact: boolean
  /** Fraction in (0, 1). Settings modal cycles 0.6 / 0.7 / 0.8 / 0.9. Default: 0.8. */
  autoCompactThreshold: number
  /** Render edit/multi_edit/write_file as unified diffs. Default: on. */
  showEditDiffs: boolean
  /** Renderer target fps. Settings modal cycles 30 / 60 / 120; `clampFps` guards bounds. Default: 60. */
  targetFps: number
  /** Drip-feed streamed text at a smooth cadence (typewriter) instead of in stream bursts. Default: on. */
  smoothStreaming: boolean
  /** Inline gradient throbber at the transcript tail while a run is streaming. Default: off. */
  showThrobber: boolean
  /** Skills allowlist. `undefined` = every discovered skill; `[]` = off. */
  enabledSkills?: readonly string[]
  /** MCPs allowlist. Same semantics. */
  enabledMcps?: readonly string[]
}

type ToolCallDisplay = 'hidden' | 'formatted' | 'full'
```

`useSettings()` returns `{ settings, toggle, setSetting }`. `toggle` is restricted at the type level to `BooleanSettingKey` (`[K in keyof Settings]-?: Settings[K] extends boolean ? K : never`); `setSetting` is the general writer.

Three tables drive any settings UI (TUI tabs today, GUI panels tomorrow) — they're the source of truth so new settings land automatically:

- `SETTINGS_CATEGORIES: SettingsCategoryDescriptor[]` — display order + labels for the high-level buckets (`agent`, `ui`). Each toggle / choice declares which bucket it belongs to via its `category` field.
- `SETTINGS_TOGGLES: SettingsToggle[]` — boolean rows. `key` is the narrowed `BooleanSettingKey`; `category: SettingsCategory` routes the row to the right tab.
- `SETTINGS_CHOICES: SettingsChoice[]` — value-from-options rows. `category` field on every entry. Currently spans both buckets: `autoCompactThreshold`, `userInstructionsScope` (agent); `toolCallDisplay`, `editDiffDisplay`, `theme`, `uiMode`, `targetFps` (ui).

## Theme

`Theme` is plain JSON: hex strings + booleans. Renderer agnostic.

```ts
interface Theme {
  id: string
  label: string
  colors: ThemeColors        // brand, accent, model, warn, error, dim, mute, border, borderActive
  select: ThemeSelect        // backgroundColor, focusedBackgroundColor, …
  surfaces: ThemeSurfaces    // modal, chips, selection, diff
  syntax: SyntaxStyles       // Record<token, { fg?, bg?, bold?, italic?, underline?, dim? }>
}

interface ThemeSurfaces {
  modal: string
  chips: ChipColorMap                       // `{ default: ChipColor, [providerId]?: ChipColor }`
  selection: string                         // bg for the select-turn highlight
  diff: DiffSurfaces                        // addBg/removeBg/addFg/removeFg + optional *ContentBg
}

interface ChipColor { bg: string; fg: string }
type ChipColorMap = { default: ChipColor } & Partial<Record<string, ChipColor>>
```

`resolveChipColor(chips, providerId)` performs the kind-specific → `chips.default` fallback. Use it instead of indexing the map directly — host-registered completion providers automatically inherit the default chip tone until the host extends the map.

`SyntaxStyles` keys cover two surfaces — markdown structure captures (`markup.heading`, `markup.bold`, `markup.italic`, `markup.raw.block`, `markup.quote`, …) and Tree-sitter language tokens (`keyword`, `string`, `function`, `type`, `comment`, …). One table drives both, so a code fence inside a markdown block shares the active theme.

| Theme id | Label | Tonal target |
|---|---|---|
| `default` | Default | Bolt brand-blue cascade on dark terminal |
| `catppuccin-mocha` | Catppuccin Mocha | Mauve accent, darkest base |
| `catppuccin-macchiato` | Catppuccin Macchiato | Softer dark |
| `catppuccin-frappe` | Catppuccin Frappé | Warmer dark |
| `catppuccin-latte` | Catppuccin Latte (light) | Light terminal only |
| `vaporwave` | Vaporwave | Neon pink on near-black |

`resolveTheme(id)` falls back to `DEFAULT_THEME` on unknown ids — a renamed theme in `state.json` never breaks rendering. Adding a theme is one entry in `BUILTIN_THEMES` (`src/chat/theme.ts`); the picker auto-populates from `Object.values(BUILTIN_THEMES)`.

## Safe-mode

Per-tool approval gate with a per-project safelist. The agent emits a `tool:gate` hook; the chat layer's wired handler:

1. If `IMPLICITLY_SAFE_TOOLS` covers the call → allow. The list is `['read_file', 'list_files', 'glob', 'grep', 'ask_user', 'present_plan', 'todowrite', 'todoread', 'skills_use']` — pure reads, interaction prompts (the picker IS the gate), todo metadata, and skill activation (mutates a per-agent Map; no shell/disk/network — gating it would also break the model's recovery path when it deactivates a skill after an `AgentToolNotAllowedError`). `skills_read` (touches disk) and `skills_run_script` (executes scripts) are intentionally NOT on the list.
2. If the project safelist (`~/.zidane/projects.json`) matches → allow.
3. Else push an `ApprovalRequest` onto `SafeModeProvider`'s queue and await.

```ts
type ApprovalDecision
  = | 'accept-once'
    | 'accept-session'
    | 'accept-safelist'
    | 'deny'
    | { kind: 'partial', mask: readonly boolean[] }

type ApprovalOriginator
  = | { kind: 'parent' }
    | { kind: 'child', label: string }      // label is the `child-N` tag

type RequestApproval = (
  tool: string,
  input: Record<string, unknown>,
  originator?: ApprovalOriginator,
) => Promise<ApprovalDecision>

interface ApprovalRequest {
  id: string
  tool: string
  input: Record<string, unknown>
  resolve: (decision: ApprovalDecision) => void
  /** Caller attribution. Absent ≡ `{ kind: 'parent' }`. */
  originator?: ApprovalOriginator
}
```

`ApprovalDecision` is a discriminated union — exhaustive switches must handle the object form, emitted by the per-edit modal for file-edit tools (`edit` / `multi_edit` / `write_file`). The `mask` is 1:1 with `EditPayload.hunks`; `true` = apply, `false` = deny. Bridge it via `resolveApprovalForPayload` (see **Per-edit approval**); non-edit gates collapse `partial` to `allow`.

`ApprovalOriginator` lets the modal show ` · child-N` attribution when a subagent's gate bubbles up through the parent's hook bus. The TUI's `applyGate` reads `ctx.childId` (set by `BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`) and threads it through:

```ts
const originator: ApprovalOriginator = ctx.childId
  ? { kind: 'child', label: ctx.childId }
  : { kind: 'parent' }
const decision = await requestApproval(name, input, originator)
```

```tsx
const { requestApproval, resolveHead, denyAll } = useSafeModeActions()
const queue = useSafeModeQueue()
const pending = queue[0] ?? null

// Renderer maps the pending request to a picker; calling `resolveHead(decision)`
// pops the head with one of the five decision shapes.
// `denyAll()` drains the entire queue with `deny` — used on session teardown.
```

`accept-safelist` calls `addToSafelist(dataDir, projectDir, suggestSafelistEntry(tool, input))` so subsequent calls with the same shape skip the gate. The safelist lives at `<dataDir>/projects.json` (user dir, never the project dir). `partial` never writes a safelist entry — the safelist key is the tool name + an arg shape, with no hunk-level scope.

**Parallel-call deny semantics**: each pending approval resolves on its own. A single `deny` does **not** cascade through the queue — the gate handler sets `ctx.block = true` + `ctx.reason = 'User denied this tool call'` for THAT call and returns, so the model receives `Blocked: User denied this tool call` as that one tool's result and the turn continues; other parallel approvals stay queued and prompt independently. The user's explicit "stop everything" gesture is the host-level `esc abort run` shortcut, which calls `cancelRunOnDenial()` → `denyAll()` + `agent.abort()` in one shot.

## Todos

`todowrite` and `todoread` give the model a place to plan multi-step work and stream progress as it executes. Monolithic-replacement semantics — every `todowrite` overwrites the active list in full. State lives at `session.metadata.todos: TodosBag` and is split by run kind:

```ts
interface TodosBag {
  /** Top-level slot — shared across runs whose `parentRunId` is unset. */
  session?: TodoItem[]
  /** Per-subagent-run slots, keyed by the child run's id. */
  byRun?: Record<string, TodoItem[]>
}
```

The keying rule, given a `runId`:

- **Top-level run** (no `parentRunId`) → `bag.session`. All top-level runs in the same session share this slot, so a list written in one prompt **survives across the next prompt**. The user aborts a run mid-task, sends a follow-up, and `todoread` returns the same list automatically — the model picks up where it left off without a host-side carry-over hook.
- **Subagent run** (`parentRunId` set) → `bag.byRun[runId]`. Each child has its own slot; parallel children stay isolated from each other and from the parent. Matches Claude Code's TodoWrite v1 keying (`agentId ?? sessionId`).

**Auto-clear on completion**: when every item is `completed`, the slot is wiped to `[]` on write — prevents stale "all done" lists from shadowing the next prompt's context. The model's reply summarizes the close-out (`Marked N items complete — list cleared.`); only the persisted slot is reset. Same rationale as Claude Code's `TodoWriteTool.ts:70`.

**Archive sidecar**: every non-empty write also stashes the list under `bag.archive` (mirroring the active slot's routing — `archive.session` for top-level, `archive.byRun[runId]` for subagents). Empty writes — whether from auto-clear or an explicit `setTodosForRun(session, runId, [])` — **preserve** the archive. UI surfaces (the TUI modal in particular) fall back to it when the live slot is empty so users can still glance back at "what was just completed" until the model writes a new list. The archive is invisible to `todoread` — strictly model-facing reads always go to the live slot.

Public API (re-exported from `zidane/chat`):

- **Factory** — `createTodoTools(options?)` returns a `Preset` carrying `{ tools, behavior }`. By default `behavior` holds a **run-scoped `block-after` dedup** for `todowrite` (`behavior.dedupTools.todowrite`, `scope: 'run'`) that hard-blocks consecutive *identical* payloads (the real loop breaker), plus an optional per-run write budget when `maxWritesPerRun > 0`. A separate **soft** in-body identical dedup (`dedupIdentical`) resolves against the active slot and returns "No change…". Compose with `composePresets` or spread it into your agent setup like any other preset.
- **Identities** — `TODOWRITE_TOOL`, `TODOREAD_TOOL`, `isTodoTool(name)`.
- **Metadata accessors** — `getTodosForRun(session, runId)`, `setTodosForRun(session, runId, items)`, `getArchivedTodosForRun(session, runId)`, `pruneTodosByRun(session)`, `TODOS_METADATA_KEY`, `TODO_WRITE_COUNTS_METADATA_KEY`. All route through the keying rule above — top-level reads/writes the session slot, subagent reads/writes its own. The archive accessor reads `bag.archive` with the same routing.
- **UI selectors** (renderer-agnostic, used by the TUI's todo indicator + modal; reusable by any GUI shell):
  - `useActiveTodos(session) → ActiveTodosState` — React hook. Recomputes on every parent re-render; selector is O(runs + todos) so memoization is unnecessary (and was previously incorrect — `Session.runs` is mutated in place by `completeRun` / `abortRun`).
  - `selectActiveTodos(session) → ActiveTodosState` — pure selector, identical contract, no React.
  - `pickActiveRunId(session) → runId | null` — "the run UI surfaces should reflect right now" (latest-running, falling back to most-recently-appended).
  - `TODO_STATUS_GLYPHS: Record<TodoStatus, string>` — single source of truth for status icons across surfaces.
- **Types** — `TodoItem`, `TodoStatus` (`'pending' | 'in_progress' | 'completed' | 'cancelled'`), `TodosBag` (`{ session?, byRun?, archive? }`), `TodoTally`, `ActiveTodosState` (carries both `todos` and `archive` so UI surfaces can render the close-out batch after auto-clear), `CreateTodoToolsOptions`.

`CreateTodoToolsOptions`:

| Option | Default | Notes |
|---|---|---|
| `maxItems` | `100` | Per-call item cap. Excess items are truncated by the recursive validator and reported in the tool_result. |
| `maxIdenticalWrites` | `5` | **On by default.** Hard stop for *consecutive identical* payloads — plumbs a run-scoped `behavior.dedupTools.todowrite` in `block-after` mode: the gate replays the first `N-1` identical writes, then refuses the `N`th (and every subsequent identical one) with `Blocked: …`. Safe-by-default because it only ever fires on a byte-identical repeat of the already-tracked list, so a real multi-step plan (different list each step) never trips it. `scope: 'run'` keeps a parent and a subagent from sharing the counter. Set to `0` to opt out. |
| `identicalBlockReason` | built-in | `(count) => string`. Custom refusal text for the `maxIdenticalWrites` block. |
| `maxWritesPerRun` | `0` | Blanket per-run write cap on **all** writes (distinct ones too). **Off by default** — `todowrite` is a state-transition tool, capping every call punishes the legitimate "finish the N-item plan" path (which needs ≥ N+1 calls). Prefer `maxIdenticalWrites` (identical-only). Set to a positive `N` to opt in; the factory then plumbs `behavior.toolBudgets.todowrite = { max: N, onExceed }`. |
| `onMaxWrites` | `'steer'` | `'steer'` or `'block'`. Only takes effect when `maxWritesPerRun > 0`. |
| `remindAfter` | `3` | Append a "you've checkpointed N times — slow down" nudge to the tool_result once the cumulative count reaches this. Count is scoped the same way the data is (session-shared for top-level runs, per-run for subagents) so it reflects use of the *active* slot, not noisy per-prompt resets. Set to `0` to opt out. |
| `reminderText` | built-in | `(count, items) => string \| undefined`. Return `undefined` / `''` to suppress for this call. |
| `dedupIdentical` | `true` | The **soft** in-body short-circuit: when the incoming payload is identical to the current slot (session-shared for top-level, per-run for subagents), the body returns "No change…" instead of rewriting. Orthogonal to the **hard** gate block (`maxIdenticalWrites`) — this one is advisory and the model can ignore it. Counter still advances on no-op re-writes so the reminder catches spam. Set `false` to skip the check entirely. |
| `writeDescription` / `readDescription` | built-in | Override the JSON-schema-level descriptions. |

`BUILD_AGENT` opts in via `composePresets`; `PLAN_AGENT` doesn't (read-only mode has nothing to checkpoint). Hosts building custom profiles use the same primitive:

```ts
import { composePresets, definePreset } from 'zidane/presets'
import { createTodoTools } from 'zidane/chat'

const myPreset = composePresets(
  definePreset({
    name: 'mine',
    tools: { ...basicTools },
    behavior: { ...SHARED_BEHAVIOR },
  }),
  createTodoTools({ remindAfter: 3 }),
)
```

`composePresets` deep-merges `behavior.dedupTools` and `behavior.toolBudgets` per tool name, so layering order is forgiving: a downstream preset overriding `toolBudgets.todowrite` keeps the rest of the factory's wiring intact, and a host wiring its own `dedupTools.todowrite` entry (for telemetry or alternate caching) survives composition. For the simplest case ("just add the tools to an existing config"), plain spread is also valid since the factory returns a `Preset`:

```ts
createAgent({ ...basicPreset, ...createTodoTools(), provider })
```

Spread is shallow-merge, so two presets that both touch `dedupTools` / `toolBudgets` should go through `composePresets` instead.

Hygiene is layered across the lifecycle:

- **Input time** — schema validation drops malformed items; per-call ID dedup keeps last-wins semantics; empty initial writes don't create a slot.
- **Auto-clear** — when every item is `completed`, the live slot is wiped on write. Prevents the "all done" list from sitting in the model's context and shadowing the next prompt. The close-out list lands in `bag.archive` so the TUI modal can keep showing it until the model writes a fresh batch.
- **Per-call reminder** — rides on the tool's own `tool_result`, so the model sees the nudge in the result it just received (no `system:transform` plumbing). Counter is scoped to the active slot (session-shared / per-run).
- **Consecutive-identical hard block (on by default)** — `maxIdenticalWrites` plumbs a run-scoped `behavior.dedupTools.todowrite` in `block-after` mode. After N byte-identical writes in a row the gate refuses with `Blocked: …` (and `isError`), so the model can't spin on the same list forever. Run-scoped (`scope: 'run'`) so a parent and a subagent never share the counter — the precise conflation that kept `todowrite` off `behavior.dedupTools` historically, now fixed by the per-run dedup key. Only ever fires on identical payloads, so a legitimate multi-step plan is untouched.
- **Per-run write budget (opt-in)** — when the host sets `maxWritesPerRun > 0`, plumbed through `behavior.toolBudgets.todowrite`; same gate, same `tool-budget:exceeded` event. Counts every dispatched call including identical re-writes. Off by default — prefer the identical-only block above.
- **Identical-payload soft dedup** — handled inside the tool body, against the current slot. The slot resolution naturally splits top-level from subagent, so an identical re-write in the active scope short-circuits to "No change" and a structurally identical write in a different scope (different subagent) writes correctly. Advisory; the gate block above is the hard backstop.
- **Session-level reconciliation** — `pruneTodosByRun(session)` drops orphan subagent slots after a `setRuns()` fork or any out-of-band run-list mutation. The top-level `session` slot is unaffected (it's session-scoped, not per-run, so there's no orphan to GC). Not automatic on `save()`.
- **Subagent isolation** — falls out of `parentRunId`-keyed routing in `getTodosForRun` / `setTodosForRun`; each child run owns its own slot.

Renderer integration: the `TOOL_DISPLAY` registry ships entries for both tools (`Todos N items · …` line shape that matches the tool's result summary), and `DEFAULT_PERSIST_EXCLUDE_TOOLS` skips them from disk-persistence (the latest snapshot is the only state of interest).

### UI surface — opt-in indicator + always-on modal

The chat layer ships two renderer-agnostic primitives any host can build a UI on. The TUI wires both (see **TUI.md**); a GUI shell would reuse `useActiveTodos` + `TODO_STATUS_GLYPHS` and draw its own chrome.

- **Inline indicator.** Single-line "▸ <in-progress content>" badge to render near the prompt input. Show iff `selectActiveTodos(session).inProgress` is non-null. Gated on `Settings.showTodoIndicator` (default `true`). When the active run has no `in_progress` item the indicator returns null — never a placeholder, never visual noise.
- **Todos modal.** Read-only viewer for the active run's slot. Renders the per-status tally header + a scrollable list of rows (`TODO_STATUS_GLYPHS[status]` + content). No interactive controls — the model is the only writer. The host wires it behind the `openTodos` keybinding action (default `ctrl+t` — see **Keybindings** below); the modal closes on `esc`. Resolves the active run via `pickActiveRunId` (latest running, fallback most-recently-appended) so a child subagent's slot surfaces when it's the live run.

The hook recomputes on every parent re-render (cheap O(runs + todos) selector) so a `setTodosForRun` write — whether to the session slot or a subagent's — lands in the indicator and modal on the next paint without any memoization-cache invalidation gymnastics.

For surfaces that don't sit on the streaming-buffer cascade — typically modal trees rendered above the chat shell — the host should drive an explicit re-render by subscribing to the agent's `tool:after` hook filtered to `TODOWRITE_TOOL` (the hook payload's `name` field). The TUI's `TodosModal` does this so it stays live while open; the indicator gets it for free because it sits inside `ChatScreen` and rides the existing event cascade.

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

## Interactions

`present_plan` and `ask_user` let the agent pause for explicit user input. **The call IS the persisted state** — both tools land in `session.turns` as regular `tool_call` blocks, and pending entries are derived from disk via `pendingInteractionsFromTurns(turns)`.

Public API (re-exported from `zidane/chat`):

- **Tools** — `createInteractionTools({ requestInteraction })`, `ASK_USER_TOOL`, `PRESENT_PLAN_TOOL`, `isInteractionTool`.
- **Queue** — `InteractionsProvider`, `useInteractionsQueue`, `useInteractionsActions`, `makeRequestInteraction(actions)`, `PendingInteractionEntry`.
- **Derivation** — `pendingInteractionsFromTurns(turns)`, `buildResumedToolResultsTurn(requests, responses, opts)`, `serializeInteractionResponse(response)`.
- **Types** — `InteractionRequest`, `InteractionResponse`, `PlanRequest`, `QuestionRequest`, `PlanResponse`, `QuestionResponse`, `PlanPayload`, `QuestionPayload`, `Question`, `TextQuestion`, `SelectQuestion`, `ConfirmQuestion`, `QuestionType`, `QuestionChoice`, `PlanStep`, `PlanDecision`, `AnswerValue`, `InteractionsActions`.

A GUI shell:

1. Mounts `<InteractionsProvider>`.
2. Injects the tools at agent build time:
   `createInteractionTools({ requestInteraction: makeRequestInteraction(useInteractionsActions()) })`.
3. Renders the head of the queue with its own UI (form / dialog / inline block).
4. On session activation, calls `pendingInteractionsFromTurns(turns)` — non-empty results enqueue resumed entries whose resolvers collect responses into a single `Map`, and once full call `buildResumedToolResultsTurn(...)` + `agent.run()`.

See `docs/INTERACTIONS.md` for the full protocol (payload shapes, resumed-flow contract, smoke-test checklist).

## Keybindings

A user-overridable action catalog. Defaults declared in `KEYBINDING_DEFS`; user overrides live at `<userDir>/keybindings.json` (JSONC). `readKeybindings(userDir)` returns the merged map; `resolveConfig` calls it and exposes it as `ResolvedConfig.keybindings`.

```ts
type KeyAction
  = | 'openSettings' | 'openSessionDetails' | 'openModelPicker' | 'openEffortPicker'
    | 'openTodos' | 'openContextPanel' | 'openKeybindings' | 'cycleAgent' | 'enterSelectTurnMode'
    | 'enterQueueSelection' | 'pushQueuedMessage' | 'dropQueuedMessage'
    | 'turnFork' | 'turnDelete' | 'turnCopy'
    | 'sessionDelete' | 'sessionCopyId' | 'sessionGenerateTitle'
    | 'sessionExportMarkdown' | 'sessionExportJson' | 'sessionCompact'
```

Each `KeyBindingDef` carries `{ action, default, label, description }` — the `description` is rendered as a comment in the auto-generated `keybindings.json`. Defaults follow `ctrl+<letter>` for globals, single letters for modal-internal actions.

`matchesBinding(event, spec)` is strict on modifiers: all declared must be pressed AND no extras. The parser is tolerant — case-insensitive, `+` / `-` separators, modifier aliases (`cmd` / `command` / `super` / `win` → `meta`; `option` / `opt` → `alt`), key aliases (`esc` ↔ `escape`, `enter` / `ret` ↔ `return`, `del` ↔ `delete`, `pgup` / `pgdn` ↔ `pageup` / `pagedown`).

`ensureKeybindingsFile(userDir)` writes a fully-commented defaults file on first launch. Existing files are never overwritten — user customizations are sacred.

A GUI can reuse `KEYBINDING_DEFS` for its own command palette / cheat-sheet surface, and `matchesBinding` for its keyboard router. TUI-specific keys (which terminal actions own which letters) are detailed in `docs/TUI.md`.

## Auto-compaction

Pure post-turn predicate that decides whether `compactConversation` should fire. Zero I/O, zero state — same inputs → same answer.

```ts
const decision = shouldAutoCompact({
  enabled: settings.autoCompact,
  threshold: settings.autoCompactThreshold,
  inputTokens: lastTurnUsage.input + lastTurnUsage.cacheRead + lastTurnUsage.cacheCreation,
  rawContextWindow: getContextWindow(descriptor, model),   // null for custom models
  alreadyCompacting: !!inFlightPromise,
})

if (decision.kind === 'fire') {
  // decision.usedFraction, decision.effectiveWindow available for the announce banner
  const inFlight = compactConversation({ provider, turns: session.turns, ... })
  inFlightRef.current = inFlight
  inFlight.then(result => {
    const summaryTurn = summaryToTurn({ summary: result.summary, replacesTurnIds: result.summarizedTurnIds, model: result.model, usage: result.usage })
    const restoration = buildPostCompactAttachments({ recentFiles: selectFilesFromSession(session), execution: agent.execution, handle: agent.handle })
    await session.appendTurns([summaryTurn, ...restoration.turns])
    setLastInputTokens(result.usage.output + restoration.estimatedTokens)
  }).finally(() => { inFlightRef.current = null })
}
```

Skip reasons: `'disabled'` / `'unknown-window'` / `'invalid-threshold'` / `'invalid-input-tokens'` / `'under-threshold'` / `'already-compacting'`.

The next user prompt **must** await the in-flight promise before calling `agent.run()` — otherwise the new run races the `appendTurns(summaryTurn, …restored)` and picks up the pre-compaction history mid-write. The TUI also exposes a manual `k` shortcut on the session-details modal that calls the same primitives.

## Context-usage breakdown

A categorized view of what's consuming the context window — system prompt, rules (AGENTS.md/CLAUDE.md), skills catalog, MCP instructions, subagent definitions, tool definitions, MCP tools, conversation, plus deferred (lazy) tools, the autocompact buffer, and free space. Surfaced as the TUI `ctrl+g` panel and the GUI context popover; both consume the same data.

- **`agent.getContextBreakdown(opts?)`** (on the core `Agent`, see `docs/ARCHITECTURE.md`) returns a `ContextBreakdown | null`. It snapshots the assembled prompt sections at run time (and a skills-only **pre-run** assembly before the first turn, so the panel works the moment the chat screen opens), then shapes them via `buildContextBreakdown`. The snapshot is cleared on `reset()` / `destroy()`, so it never leaks stale data across sessions.
- **`buildContextBreakdown(snapshot)`** (`zidane/chat` + `zidane/chat/pure`, node-free) is the pure shaper. Live categories always reconcile to the real last-turn total (`conversation = used − Σ(other live)`), so the bar can never contradict the footer's `ctx` gauge. Free space and the autocompact buffer are exact; per-section counts are heuristic (`estimateTokens`) unless the provider supplies exact counts.
- **Exact counts (optional).** When a provider implements `Provider.countTokens(payload)` (Anthropic `messages.countTokens`, OpenAI `responses.input_tokens`), `getContextBreakdown` differences a system-only and a system+tools probe to attribute the System prompt + Tool definitions buckets exactly; categories with no exact source render with a `~` prefix. `countTokens` returns `null` when unavailable (no endpoint / wrong auth) and the heuristic fills in — it can never break the breakdown.
- **Autocompact zone.** Pass `opts.compactThreshold` (the `Settings.autoCompactThreshold` fraction) and the "Autocompact buffer" bucket becomes the whole tail from the threshold to the end of the window, so the bar shows exactly where compaction will fire.
- **Rules attribution.** The agent can't isolate the rules block from the opaque host-composed system string, so the host passes `AgentRunOptions.contextSections = { rulesBlock, rulesFiles }` (the rendered AGENTS.md block + its source files) for a dedicated, per-file "Rules" row. The TUI and GUI both discover rules via `discoverAgentsMd` and thread this through.

`ContextBreakdown`, `ContextBreakdownOptions`, `ContextSnapshot`, `ContextCategory`, `ContextItem`, `ContextUsageSplit`, `ContextExactCounts`, `ContextMcpGroup`, and `buildContextBreakdown` are all exported from both `zidane/chat` and `zidane/chat/pure`.

## Edit-diff rendering

Renderer-agnostic helpers for turning `edit` / `multi_edit` / `write_file` tool calls into a unified diff. Two tiers:

- **Header-only preview** — `extractEditPayload` + `buildUnifiedDiff`. Synthetic `@@ -1,N +1,M @@` chunk headers (no real file positions). Cheap, no priorContent walk; suitable for inline transcript diffs.
- **Renderer-faithful preview** — `previewEditPayload`. Routes each hunk through the same lenient resolver the tool body uses (curly-quote recovery, line-number-prefix stripping, model-side `<n>`→`<name>` desanitize) and emits real file line numbers via `buildContextualDiff`. Returns per-hunk `HunkResolution` metadata so a GUI can badge unresolvable hunks instead of showing a misleading blank diff. The TUI's approval modal uses this so what the user previews matches what the tool would actually apply.

```ts
const payload: EditPayload | undefined = extractEditPayload(name, input, priorContent)
// payload.tool:     'edit' | 'multi_edit' | 'write_file'
// payload.path:     string
// payload.hunks:    readonly EditHunk[]      ({ oldString, newString, replaceAll? })
// payload.outcomes: readonly EditOutcome[] | undefined   ← post-gate per-hunk state

if (payload) {
  const unified = buildUnifiedDiff(payload)              // synthetic headers, hunk-only
  const filetype = filetypeFromPath(payload.path)        // 'typescript' | 'python' | ... | undefined
}

// Lower-level granularity:
const lines: DiffLine[] = computeLineDiff(oldString, newString)            // { op: 'context'|'add'|'remove', text }
const inline: InlineDiff = computeInlineDiff(oldLine, newLine)             // word-level segments for paired (-,+) rows

// Whole-file diff with real line numbers + configurable context:
const ctxDiff = buildContextualDiff(payload, priorContent, 3)              // 3 = surrounding context lines

// Model-faithful preview — mirrors the tool body's resolver:
const preview: PreviewResult = previewEditPayload(payload, priorContent, 4)
// preview.diffText        : full unified diff of resolvable hunks
// preview.resolution      : HunkResolution[]  (1:1 with payload.hunks)
// preview.perHunkDiff     : string[]          (each hunk rendered against its predecessor's running state)
// preview.resolvedPayload : EditPayload       (oldString rewritten to haystack bytes, newString restyled)
```

```ts
type ResolvedVia
  = | 'exact'
    | 'quotes'             // curly-quote recovery
    | 'desanitize'         // model-side `<n>` → `<name>`
    | 'quotes+desanitize'
    | 'line-numbers'       // stripped read_file `<N>\t…` / `<N>|…` prefixes
    | 'quotes+line-numbers'

interface HunkResolution {
  /** True when the tool body would find this hunk and apply it without ambiguity. */
  resolved: boolean
  /** Path the resolver took — `'exact'` when no recovery was needed. */
  via?: ResolvedVia
  /** Match count in the running content for the hunk. */
  occurrences?: number
  /** True when the resolver found >1 match without `replace_all`. */
  ambiguous?: boolean
}
```

`applyEditPayload(payload, prior)` mirrors the tool-side semantics (`replaceAll === true` → `String.replaceAll`, otherwise first-occurrence) so a host can stage the post-apply file body without spawning the agent. It's the lower-level primitive `buildContextualDiff` is built on; hosts assembling a custom preview pipeline call them in tandem.

Wire:

- `tool:before` hook → read pre-write content for `write_file` (other edit tools carry it in the input) → call `extractEditPayload` → attach the result as `StreamEvent.edit` on the `'tool'` event. The host skips this default emit when the gate already painted a synthetic event for a partial / fully-denied call (otherwise the transcript would show one row with full hunks + a second with the reduced subset).
- Renderer reads `event.edit` and renders accordingly. When `payload.outcomes` is populated, each hunk row carries an applied / denied / skipped / failed badge; pass the array through `summarizeOutcomes` for the header tally.
- Suppress the paired success `tool-result` (`isEditErrorResult` is the gate). Success means "every hunk applied" — the result body is the legacy `Edited <path>: applied N edits (M replacements).` summary alone, no annotation block. Any presence of `<edit-outcomes>` (mixed / denied / failed) or a `[fully denied]` body keeps the result visible alongside the diff so the user reads the per-hunk reasons.
- Historical replay (from `eventsFromTurns`) 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 to the `'tool'` event, so a reloaded transcript shows the same per-hunk badges live capture displayed.

`theme.surfaces.diff` carries the row colors (`addBg`, `removeBg`, optional `*ContentBg`, `addFg`, `removeFg`); built-in themes pre-mix translucent reds/greens so terminals without alpha-blend get a legible effect, and CSS hosts can use the same hex values as `background-color`.

## Per-edit approval

Edit-family tools (`edit`, `multi_edit`, `write_file`) can accept or reject **individual hunks** instead of all-or-nothing. The user-side decision lives in `ApprovalDecision`'s `{ kind: 'partial', mask }` shape. Two layers cooperate:

- **`multi_edit` body** is best-effort: a per-step failure (`old_string` not found, ambiguous, identical strings) is recorded against that step alone — siblings still run. The body emits an `<edit-outcomes>` block in its result whenever any step failed, keyed against the approved SUBSET it actually saw.
- **The host** rebinds `ctx.input.edits` to the approved subset at `tool:gate` and (in `tool:transform`) merges the body's subset-keyed outcomes with its 1:1 approval-side decisions via `mergeApprovalAndBodyOutcomes`, then re-appends the canonical annotation block.

Net effect: the canonical wire-format `<edit-outcomes>` block carries `applied` / `denied` / `skipped` / `failed` 1:1 with the model's ORIGINAL edits, for live + replay capture alike.

```ts
type EditOutcomeKind = 'applied' | 'denied' | 'skipped' | 'failed' | 'pending'

interface EditOutcome {
  kind: EditOutcomeKind
  /** Short human-readable explanation surfaced in the transcript badge. */
  reason?: string
}

interface EditPayload {
  // …existing fields…
  /** 1:1 with `hunks`. Absent on a fresh tool call before the gate decides — renderer treats absence as "all applied". */
  outcomes?: readonly EditOutcome[]
}
```

### Helpers

```ts
import {
  resolveApprovalForPayload,
  maskToOutcomeKinds,
  buildEditOutcomesAnnotation,
  parseEditOutcomesFromResult,
  mergeApprovalAndBodyOutcomes,
  stripEditOutcomesAnnotation,
  summarizeOutcomes,
  type ResolvedApproval,
} from 'zidane/chat'
```

| Helper | Purpose |
|---|---|
| `resolveApprovalForPayload(decision, payload)` | Turn an `ApprovalDecision` into `{ outcomes, shouldBlock, syntheticEvent }`. Pure — does not mutate `input` / `payload`. |
| `maskToOutcomeKinds(mask, fallbackLength, deniedReason?)` | Convert a boolean mask to a 1:1 `EditOutcome[]`. Missing entries default to `applied` (no-decision = keep). |
| `buildEditOutcomesAnnotation(outcomes)` | Render an `EditOutcome[]` as the wire-format `<edit-outcomes>…</edit-outcomes>` block — body APPENDed to a tool result (joined with `\n\n`). Idempotent on missing reasons. |
| `parseEditOutcomesFromResult(text)` | Re-parse the annotation block back into outcomes. Used by `eventsFromTurns` so replay shows the same badges as live capture. Returns `null` when the block is missing / malformed. |
| `mergeApprovalAndBodyOutcomes(approval, body)` | Fold a body-side subset-keyed outcome list (e.g. from `multi_edit`'s best-effort body) into the approval-side 1:1 outcomes — each `applied` placeholder is replaced by the body's next outcome in subset order. Returns a fresh array. |
| `stripEditOutcomesAnnotation(text)` | Peel the first `<edit-outcomes>` block (and one leading `\n\n` separator) out of a body. Used by `tool:transform` to remove a body-emitted block before re-appending the merged version — the parser is anchored on the FIRST block. |
| `rewriteMultiEditHeader(text, merged, path)` | Rewrite a `multi_edit` body header so its `N of M edits` / `N attempted` counts reflect the merged outcomes (= model's original edit-list total), not the subset the body actually saw after gate rebinding. Preserves the body-side replacements count and pluralization. |
| `summarizeOutcomes(outcomes)` | `{ applied, denied, skipped, failed, pending, total }` tally for the header badge. |

```ts
interface ResolvedApproval {
  /** Final state of every hunk after the decision (1:1 with payload.hunks). */
  outcomes: EditOutcome[]
  /** True when no hunk will be applied — gate should `block` the call. */
  shouldBlock: boolean
  /** Synthetic `EditPayload` for the transcript on a deny/partial. `null` on accept-all. */
  syntheticEvent: EditPayload | null
}
```

### Wire format

Canonical annotation block — emitted ONLY when at least one hunk is NOT applied. All-applied calls fall through to the legacy `Edited <path>: applied N edits (M replacements).` summary alone.

```
Edited src/foo.ts: applied 2 edits (3 replacements).

<edit-outcomes>
#1 applied
#2 denied: denied by user
#3 applied
</edit-outcomes>
```

- Opening + closing tags each on their own line.
- One line per hunk: `#<1-based-index> <kind>[: <reason>]`.
- `kind ∈ {applied | denied | skipped | failed}`.
- Indexes are 1-based against the **model's original** `edits` array — `parseEditOutcomesFromResult` re-keys to a 0-based `EditOutcome[]`.

A fully-denied call (every hunk rejected) skips the substitute path entirely: the harness writes `Blocked: User denied this tool call` to the persisted tool_result, and the host emits a synthetic `tool-result` event with body `[fully denied] <edit-outcomes>…</edit-outcomes>` for live display only. `isEditErrorResult` keeps that synthetic body visible alongside the diff.

### Gate handler

End-to-end pattern for a host wiring `tool:gate` against the helpers. The harness stays pure: the handler **rebinds** `ctx.input` (does not mutate the model's `tool_call.input` reference on the persisted assistant turn) and stashes outcomes for a paired `tool:transform` to append on the way out:

```ts
import {
  buildEditOutcomesAnnotation,
  extractEditPayload,
  resolveApprovalForPayload,
  toolCallPreview,
} from 'zidane/chat'

const pendingAnnotations = new Map<string, readonly EditOutcome[]>()

agent.hooks.hook('tool:gate', async (ctx) => {
  if (ctx.block) return                                   // upstream already refused

  const decision = await requestApproval(ctx.name, ctx.input, originator)
  const editPayload = extractEditPayload(ctx.name, ctx.input)
  if (!editPayload) {
    if (decision === 'deny') {
      ctx.block = true
      ctx.reason = 'User denied this tool call'
    }
    return
  }

  const resolved = resolveApprovalForPayload(decision, editPayload)

  if (resolved.shouldBlock) {
    if (resolved.syntheticEvent) {
      stream.appendImmediate({
        kind: 'tool',
        text: toolCallPreview(ctx.name, ctx.input),
        tool: ctx.name,
        input: ctx.input,
        edit: resolved.syntheticEvent,
        ...(ctx.turnId ? { turnId: ctx.turnId } : {}),
      })
      stream.appendImmediate({
        kind: 'tool-result',
        text: `[fully denied] ${buildEditOutcomesAnnotation(resolved.outcomes)}`,
        tool: ctx.name,
        ...(ctx.turnId ? { turnId: ctx.turnId } : {}),
      })
    }
    ctx.block = true
    ctx.reason = 'User denied this tool call'
    return
  }

  if (resolved.syntheticEvent) {
    // Rebind — fresh shallow clone whose `edits` is the approved subset.
    // The model's original `tool_call.input` on the persisted turn stays
    // intact because the rebind produces a new object.
    const reducedEdits = reduceEditsByOutcomes(ctx.input.edits, resolved.outcomes)
    ctx.input = { ...ctx.input, edits: reducedEdits }
    pendingAnnotations.set(ctx.callId, resolved.outcomes)
  }
  // accept-* (or partial that collapsed to all-applied) → fall through.
})

agent.hooks.hook('tool:transform', (ctx) => {
  const outcomes = pendingAnnotations.get(ctx.callId)
  if (!outcomes) return
  pendingAnnotations.delete(ctx.callId)
  const annotation = buildEditOutcomesAnnotation(outcomes)
  ctx.result = typeof ctx.result === 'string'
    ? (ctx.result.length === 0 ? annotation : `${ctx.result}\n\n${annotation}`)
    : [...ctx.result, { type: 'text', text: `\n${annotation}` }]
})

// Same handler registered on `child:tool:transform` so subagent calls
// also get annotated — `BUBBLED_MUTABLE_EVENTS` shares the child ctx.
agent.hooks.hook('child:tool:transform', /* same body */)
```

Wipe `pendingAnnotations` on `agent:done` (covers completed / aborted / error paths) — `tool:transform` doesn't fire when `validation:reject` or a throwing `tool:before` synthesizes the tool_result, so entries can otherwise strand across runs. The TUI also clears the map defensively in its session-teardown path (`pendingAnnotationsRef.current.clear()`).

### `multi_edit` tool body shape

Best-effort, per-hunk — the harness has no notion of approvals, but the body itself records failures per-step instead of bailing on the first. Applies every edit in input order against the file as left by the previous APPLIED step; a per-step rejection (`old_string` not found, ambiguous without `replace_all`, identical, malformed) is recorded against THAT step alone, and siblings still run. The file is written iff at least one step applied.

Result shapes:

- **All applied** → legacy `Edited <path>: applied N edits (M replacements).` summary, NO annotation block. Renderer suppression (`isEditErrorResult`) keeps the diff alone.
- **Mixed** → `Edited <path>: applied N of M edits (R replacements).` + per-failure lines (`edit #K failed: <reason>`) + `<edit-outcomes>` block keyed against the input the body saw.
- **All failed** → `multi_edit error: no edits applied to <path> (M attempted).` + per-failure lines + `<edit-outcomes>` block. The `multi_edit error:` prefix is preserved so existing visibility / log filters keep working.

SDK consumers (CI agents, headless harnesses, pipelines parsing the result) get a self-describing result on success AND on partial failure — re-issuing just the failed steps is a parse-and-resubmit instead of "re-run the whole batch".

When a host rebinds `ctx.input.edits` to the approved subset at the gate (above), the body sees a smaller batch and records outcomes against THAT subset. The host's `tool:transform` hook merges the body's subset-keyed outcomes with the approval-side decisions via `mergeApprovalAndBodyOutcomes` and rewrites the body's header (`rewriteMultiEditHeader`) so its `N of M` counts reflect the model's original total, not the subset. The merged outcomes also flow into the in-flight `'tool'` event via `updateToolEventOutcomes` so the live diff badges catch up — no session reload needed.

### Modal lifecycle

The chat layer makes no assumption about how the host renders the approval surface. Two contracts a GUI must honor:

- **Display the head of `useSafeModeQueue()` only.** The queue is FIFO; parallel tool calls prompt in arrival order. Resolve with `resolveHead(decision)` to pop the head — never resolve other entries directly, since the underlying `Promise` resolvers are owned by the provider.
- **Key the rendered surface on `request.id`.** Back-to-back approvals in the same queue tick must force-remount the modal so per-call UI state (per-hunk mask, focused row, zone, etc.) doesn't carry across. The TUI's `ChatScreen` does this with `<FileEditApprovalModal key={fileEditPending.id} … />` (`src/tui/screens.tsx`).

For hosts that route some decisions through a separate modal stack (the TUI inlines `FileEditApprovalModal` in the transcript slot while non-file-edit gates render as an `ApprovalBlock` below the prompt), push an empty placeholder onto the global modal context for the inline surface so `useModalAwareFocus()` keeps releasing background focus — the focus contract is "any modal-aware surface = background blurred", and an inline modal is still a modal by that definition.

## Tool call display

Three rendering modes for `'tool'` `StreamEvent`s, picked via `Settings.toolCallDisplay`:

| Mode | What renders | When to use |
|---|---|---|
| `'hidden'` | nothing (`isVisible` returns false for the kind) | transcript reads like a pure chat log |
| `'formatted'` (default) | curated per-tool one-liner from `tool-formatters` | balanced default — scannable signal, no JSON noise |
| `'full'` | `↳ <name>` header + entire `input` as syntax-highlighted JSON | debugging exactly what the model emitted |

Every native tool ships a `ToolDisplayMeta` entry in `TOOL_DISPLAY`:

```ts
interface ToolDisplayMeta {
  /** Sentence-case verb (e.g. `Read`, `Shell`, `Multi-edit`). */
  displayName: string
  /**
   * Pull a one-line summary out of the raw model input. Returns null when
   * the shape isn't what the tool expects; renderer falls back to the
   * bare display name then.
   */
  format: (input: Record<string, unknown>) => ToolFormatLine | null
}

interface ToolFormatLine {
  target?: string                  // path / command / pattern / task
  meta?: readonly string[]         // suffix annotations joined with ` · `
}
```

Examples (`displayName <target> · <meta>` shape):

```
Read src/foo.ts · L10–29
Shell git status
Grep /TODO/ · in src/ · *.ts · case-insensitive
Multi-edit src/foo.ts · 3 hunks
Write src/foo.ts · 2.4 KB
Spawn research the foo bar baz
Activate skill design-eng
Ask user 3 questions
Todos 5 items · 2 done · 1 in progress · 2 pending
```

Renderer-agnostic helpers:

- **`displayNameFor(name)`** — curated entry's `displayName`, else a sentence-cased fallback (`mcp_github_create_issue` → `Github create issue` after the `mcp_` prefix is stripped).
- **`formatToolCall(name, input)`** — returns `ToolFormatLine | null`. Defensive: a misshapen input returns `null` (the renderer shows the bare display name).
- **`TOOL_DISPLAY`** — the registry itself. Host apps add their own entries by spreading: `{ ...TOOL_DISPLAY, my_custom_tool: { displayName: 'Foo', format: ... } }`.

Render path expectations on the `'tool'` `StreamEvent`:

- **`tool: string`** — canonical tool name (used as the registry key).
- **`input?: Record<string, unknown>`** — the raw arguments the model emitted. Populated by the `tool:before` + `child:tool:before` hooks (live) and by `eventsFromTurns` (historical replay). Required for both `'formatted'` and `'full'` to render usefully; legacy sessions without `input` fall back to a bare `↳ <name>` line.

## Session reconstruction

`eventsFromTurns(turns, runs)` rebuilds the visible event stream from persisted turns. Pure function; no I/O.

- Walks turns once, maintaining an **ancestry stack** of currently-open child runs derived from `SessionRun.parentRunId`. Diffing the stack against each turn's chain yields the correct `spawn-start` / `spawn-end` order for nested subagents (`parent → A → B → A → parent` emits `🌱A · 🌱B · 🌳B · 🌳A`).
- Tags every child-run event with `{ childId: 'child-N', depth }` where `child-N` is chronologically assigned by `startedAt`. Same labels the live spawn tool emits.
- Strips the redundant `Tokens: …` line from spawn tool-result bodies — only when guarded by the `[sub-agent <id>] …` header, so a body line that legitimately starts with `Tokens:` is preserved.
- Emits a `'compact-summary'` event for every `compact-summary` content block, with `compact` metadata (`replacedCount`, `model`, `compactedAt`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`).
- Detects leading `<task-notification>` text blocks (the wire format `renderTaskNotificationXml` emits) on user turns and synthesizes a `'task-notification'` event with structured `task` metadata (`taskId`, `status`, `exitCode`, `outputPath`, `command`, `durationMs`). The raw text block is suppressed from the `'user-prompt'` stream so the banner doesn't double-paint. Detection is anchored — a stray notification-shaped phrase mid-prompt won't trigger.

`lastContextSizeFromTurns(turns, runs)` returns the most recent **depth-0** assistant turn's input token total (cache-aware: `input + cacheRead + cacheCreation`) — the next prompt feeds the parent, so a session that ended mid-spawn doesn't surface the child's window in the footer.

`selectableTurnIds(events)` returns the ordered list of top-level turn ids the GUI can navigate (skips subagent turns + synthetic events).

`listSessionMeta(store, filter?)` uses `Promise.allSettled` so a single corrupt row doesn't take down the picker. `filter.projectRoot` scopes the list to one project (current TUI default); pass `null` to ask for untagged legacy sessions specifically.

`deriveSessionTitle(turns, metadata)` prefers `metadata.title` (set by `generateSessionTitle`), then `titleFromTurns(turns)` (one-line first user message), then the literal `'untitled'`.

### Transcript view rules

Pure helpers that encode the user-facing filter + spacing semantics so any renderer (TUI / GUI / SDK consumer) honors the same rules:

```ts
import { isVisible, marginTopFor, isEditErrorResult } from 'zidane/chat'

const visible = events.filter(e => isVisible(e, settings))
// Render visible[] with a per-row top margin:
visible.map((e, i) => ({ event: e, marginTop: marginTopFor(e, visible[i - 1]) }))
```

- **`isVisible(event, settings)`** — applies `showThinking`, `toolCallDisplay`, `showToolResults`, `hideSubagentOutput`, and the `showEditDiffs` paired-result suppression in one place. Successful edit/multi_edit/write_file results are dropped (the diff itself is the success signal); error results bypass via `isEditErrorResult`.
- **`marginTopFor(event, previous)`** — returns the row count of breathing room above an event. Defaults per kind, with two context-aware overrides: consecutive `tool`/`tool-result` rows hug, and parent-level rows hug right after a subagent's `🌳` end marker.
- **`isEditErrorResult(text)`** — recognizes `Edit error:` / `multi_edit error:` / `Tool failed:` prefixes. Exposed for hosts that want the same "is this a failed edit?" check elsewhere.

## Streaming buffer

`useStreamBuffer()` is the React-side ring that the agent's `stream:text` / `stream:thinking` / `child:stream:*` hooks write into. Per-owner finalize (`finalizeStreamingMarkdownForOwner`) flips the trailing markdown event from `streaming: true` to `false` when a child completes, so reflowing stops at the right boundary.

```ts
type Owner = 'parent' | string   // string = subagent's childId
```

Every queued delta carries an `Owner`; the buffer keeps per-owner trailing markdown events so a child's stream and the parent's stream can interleave without one leaking into the other's block.

```tsx
const stream = useStreamBuffer(setEvents)

agent.hooks.hook('stream:text', ctx => stream.queueStreamDelta('markdown', ctx.delta, { turnId: ctx.turnId }))
agent.hooks.hook('child:stream:text', ctx => stream.queueStreamDelta('markdown', ctx.delta, { childId: ctx.childId, depth: ctx.depth, turnId: ctx.turnId }))
agent.hooks.hook('turn:after', () => stream.flushAndUpdate(finalizeStreamingMarkdown))
agent.hooks.hook('child:stream:end', ({ childId }) => {
  stream.flushAndUpdate(prev => finalizeStreamingMarkdownForOwner(prev, childId))
})
```

`stream.appendImmediate(event)` is the path for synchronous events (`'tool'` / `'tool-result'` / `'info'` / `'error'` / `'spawn-start'` / `'spawn-end'`). `turnContextSize(usage)` computes the cache-aware total for the footer indicator.

Stamp `callId: ctx.callId` on every emit constructed from a `tool:before` / `tool:after` / `tool:error` / `mcp:tool:after` / `child:tool:before` / `child:tool:after` hook (and on the gate-path's synthetic `'tool'` / `'tool-result'` emits). This is the canonical pairing key for out-of-process consumers. The replay path (`eventsFromTurns`) carries it through from each `tool_call.id` / `tool_result.callId` block verbatim, so live and reloaded transcripts share the same wiring.

## `StreamEvent`

The discriminated union the renderer consumes. `kind` values:

```
'thinking' | 'tool' | 'tool-result' | 'error'
| 'user-prompt' | 'info' | 'separator'
| 'markdown' | 'spawn-start' | 'spawn-end'
| 'compact-summary' | 'task-notification'
```

Notable fields:

- `text: string` — main payload.
- `streaming?: boolean` — true while a markdown event is still receiving deltas.
- `childId?: string` / `depth?: number` — subagent ancestry.
- `tool?: string` — canonical tool name on `'tool'` / `'tool-result'` events (used for tool-aware filters).
- `callId?: string` — matches `tool_use.id` on the assistant turn's `tool_call` block. Canonical pairing key for `'tool'` ↔ `'tool-result'` (and tool-scoped `'error'`) events. Out-of-process consumers (GUI, SDK, logger, replayer) MUST pair by id — positional pairing within a tool-name bucket silently swaps results when two parallel calls share a tool name (common for `read_file`, `edit`, `multi_edit`). Absent on non-tool events and on session/provider-level error events that lack a callId in scope.
- `edit?: EditPayload` — populated on `'tool'` events for edit/multi_edit/write_file. See **Edit-diff rendering**.
- `refs?: { start, end, providerId }[]` — chip-highlight spans on `'user-prompt'` events.
- `turnId?: string` — the `SessionTurn.id` the event was emitted by; drives select-turn highlighting.
- `compact?: { replacedCount, model, compactedAt, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }` — metadata for `'compact-summary'` boundary cards.
- `task?: { taskId, status: 'exited' | 'killed', exitCode, outputPath, command, durationMs }` — metadata for `'task-notification'` banners. Synthesized in `eventsFromTurns` from 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.

`'user-prompt'` carries the **raw** user prompt without a `❯` prefix; renderers add their own chevron. `refs` offsets are aligned to the raw text.

## Completion

Slash-command + reference-aware prompt autocompletion. Renderer-agnostic.

```ts
import { createSkillsCompletionProvider, createFilesCompletionProvider, useCompletion } from 'zidane/chat'

const skillsProvider = createSkillsCompletionProvider({
  getCatalog: () => skillsCatalog,                     // live — re-read per call
  getEnabled: () => settings.enabledSkills,            // optional allowlist
})
const filesProvider = createFilesCompletionProvider({
  getCatalog: () => filesCatalog,
})

// In the host component:
const completion = useCompletion(
  { text: bufferText, cursor: bufferCursor },
  [skillsProvider, filesProvider],
)
// → { active, items, selectedIndex, selectNext, selectPrev, commit, dismiss, references }
```

A **provider** owns one trigger character + two operations:

```ts
interface CompletionProvider<TItem> {
  id: string                                  // 'skills' | 'files' | 'tags' | ...
  trigger: string                             // '/', '@', '#'
  label: string                               // display only
  suggest(query, ctx, signal): CompletionItem<TItem>[] | Promise<…>
  parseReferences(text, ctx): CompletionReference<TItem>[]
}
```

`suggest` returns items for the live query (after the trigger, up to the cursor). `parseReferences` re-scans the whole buffer on every keystroke for finalized references — these drive highlighting in the rendered prompt + submit-time side effects (`uniqueSkillNamesFromReferences(refs)` → `agent.activateSkill(name)`). No chip-tracking state; if the user edits the reference text away the highlight disappears automatically on the next reparse.

Trigger rules (enforced by `findActiveTrigger`):

- Trigger char at buffer start or after whitespace.
- Cursor on the same contiguous token (no whitespace between trigger and cursor).
- Query bounded by `maxQueryLength` (default 64).

Built-in providers:

- **`createSkillsCompletionProvider`** — trigger `/`. Filters by the user's allowlist. Matching is case-insensitive; name prefix outranks name substring outranks description substring, with an fzf-scored name fuzzy fallback so `reseach` still finds `research`. Submission helper: `uniqueSkillNamesFromReferences`.
- **`createFilesCompletionProvider`** — trigger `@`. Suggests files from the cwd, gitignored paths excluded. Matching is case-insensitive across name + path, with an fzf-scored basename fuzzy fallback so `lop.ts` still finds `loop.ts`; exact / prefix / substring hits always outrank fuzzy ones. Submission helper: `uniqueFilesFromReferences`. Optional `formatPath(entry)` hook rewrites the catalog's project-root-relative path into the form inserted into the prompt; pair with `formatPathForCwd` so the emitted reference resolves under the agent's CWD even when the TUI launches from a project subdirectory (default: identity).

```ts
import { createFilesCompletionProvider, formatPathForCwd } from 'zidane/chat'

const filesProvider = createFilesCompletionProvider({
  getCatalog: () => filesCatalog,
  ensureCatalog: () => ensureFilesCatalog(),
  formatPath: entry => formatPathForCwd(entry.path, projectDir, process.cwd()),
})
```

`formatPathForCwd(projectRel, projectRoot, cwd)` is pure and deterministic. When the target sits inside `cwd`, it returns a forward-slashed CWD-relative path (no `..` traversal); when it sits above, it returns the absolute path. A `..`-laden form would be valid but unreadable and surprises the user, whose mental model of `@` is "pick a file from the project view". The bug it closes: discovery walks from the git root, so `entry.path` is repo-root-relative; the agent's tools resolve relative to CWD, so inserting raw `entry.path` from a subdirectory resolved to `<cwd>/<rel>` and missed.

References are highlighted in two surfaces — every provider's spans flow into `event.refs` (with `kind = providerId`) and the renderer paints each chip with the matching pair from `surfaces.chips[providerId] ?? surfaces.chips.default`. Built-in themes ship distinct `skills` + `files` tones.

For chip rendering on submitted prompts, use `splitPromptSegments(text, refs)` to get a `PromptSegment[]` of `'plain'` runs + `'chip'` atoms — map each segment to an inline-block pill.

## Discovery context

Live catalogs (project files, skills, MCPs) flow through a React context so modals opened mid-session see fresh state — not a snapshot frozen at modal-open time. The modal layer keeps its open node in `useState`, and React skips re-rendering a subtree when the element reference is stable; passing `skillsCatalog` as a prop would bake the snapshot at open time. Context dodges that.

```ts
interface DiscoveryContextValue {
  skillsCatalog: readonly SkillConfig[]
  mcpsCatalog: readonly DiscoveredMcp[]
  mcpsErrors: readonly DiscoveryError[]
  filesCatalog: readonly FileEntry[]

  refreshSkills(): Promise<void>     // bypass SWR throttle
  refreshMcps(): Promise<void>
  refreshFiles(): Promise<void>

  ensureSkills(): Promise<readonly SkillConfig[]>   // idempotent first-load
  ensureFiles(): Promise<readonly FileEntry[]>
}

const ctx = useDiscovery()                          // throws outside a provider
const optional = useDiscoveryOptional()             // returns null instead
```

`createDiscoverySlot({ walk, commit? })` is the stale-while-revalidate primitive backing each catalog — wire one slot per kind into your shell, then bridge them onto `<DiscoveryProvider value={...}>`. `DiscoveryShell` (`src/tui/discovery-shell.tsx`) is the canonical TUI composition. Mount the provider **above** `<ModalRoot>` so a refresh mid-modal propagates into the open modal. Standalone embedders rendering individual modals can skip it — `useDiscoveryOptional()` returns `null` and the modal falls back to its catalog props.

## Project skills + MCP servers

Both autoload from a fixed search order — project beats user, first-found wins on name collision:

```
{cwd}/.agents/skills/<name>/SKILL.md        (project, agnostic convention)
{cwd}/.{prefix}/skills/<name>/SKILL.md      (project, zidane — `.zidane` by default)
~/.agents/skills/<name>/SKILL.md            (user, agnostic)
~/.{prefix}/skills/<name>/SKILL.md          (user, zidane)
```

```
{cwd}/.agents/mcps.json    {cwd}/.agents/mcp.json
{cwd}/.{prefix}/mcps.json  {cwd}/.{prefix}/mcp.json
~/.agents/mcps.json        ~/.agents/mcp.json
~/.{prefix}/mcps.json      ~/.{prefix}/mcp.json
```

Both filenames are picked up from every search root. `mcps.json` is zidane's canonical name; `mcp.json` is the Cursor / common ecosystem alias and gets merged alongside (`mcps.json` wins on same-name collisions within a directory; non-overlapping entries from `mcp.json` are kept). `projectUserPaths({ subPath, cwd, home, prefix })` is the shared builder — extend any new discovery surface with the same convention.

`mcps.json` / `mcp.json` accepts three shapes:

```json
[
  { "name": "filesystem", "transport": "stdio", "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
]
```

```json
{
  "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
  "github":     { "url": "http://localhost:3002/mcp" }
}
```

```json
{
  "mcpServers": {
    "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
    "github":     { "url": "http://localhost:3002/mcp" }
  }
}
```

The first is canonical zidane; the second is a name-keyed map; the third is the Claude Desktop / Claude Code / Cursor wrapper, accepted so you can paste a `.mcp.json` / `~/.cursor/mcp.json` verbatim. All three feed through `normalizeMcpServers` so they end up shaped exactly like the agent's `mcpServers` option. Inline `"enabled": false` on an entry (Cursor runtime state) drops it before discovery.

`parseMcpsFile(text)` is tolerant to NBSP / BOM / zero-width chars outside string literals (common copy-paste artifacts).

`discoverProjectMcps({ cwd, prefix })` returns `{ servers, errors }` — per-file parse failures land in `errors: DiscoveryError[]` so a GUI can render a "non-fatal parse error" preamble instead of silently dropping the entry. `buildMcpServers({ discovered, enabled })` applies the `enabledMcps` filter to produce the array consumed by `createAgent({ mcpServers })`.

`settings.enabledSkills` / `settings.enabledMcps` are `undefined` until first toggle (every discovered entry is enabled). Once written, the persisted shape is a concrete array — newly-added skills don't silently flip on, the user opts them in.

Toggling an enable/disable flag does NOT tear down a stream in flight — change takes effect on the next session activation.

## OAuth (two surfaces)

Two distinct OAuth flows, both ending in stored credentials and a working session:

### AI-provider OAuth

Used by Anthropic (Claude Pro/Max) and OpenAI Codex. Wired through `descriptor.oauthProvider` (a pi-ai `OAuthProviderInterface`).

```ts
import { oauthUsesManualCodePaste, runOAuthLogin, setProviderCredential, supportsOAuth } from 'zidane/chat'

if (supportsOAuth(descriptor)) {
  const credentials = await runOAuthLogin(descriptor, {
    onUrl: (url, instructions) => { /* render to user */ },
    // Required for providers with `usesCallbackServer: false` (Anthropic Claude
    // Pro/Max). pi-ai invokes `onPrompt` with `{ message, placeholder?, allowEmpty? }`
    // when it needs the user to paste a code from the browser.
    onPrompt: async prompt => askUser(prompt.message, prompt.placeholder),
    onProgress: msg => { /* show progress */ },
    signal: abortController.signal,
  })
  setProviderCredential(paths.userDir, descriptor, { kind: 'oauth', ...credentials })
}
```

`runOAuthLogin` calls `tryOpenBrowser(url)` automatically; the user can also click the URL surfaced via `onUrl`. `oauthUsesManualCodePaste(descriptor)` is a convenience that returns `true` when the underlying pi-ai provider has `usesCallbackServer: false` — UIs can use it to swap their copy from "waiting for browser callback" to "paste the code from your browser below" before the prompt actually arrives.

**SSH paste-back.** When the user runs zidane over SSH (or behind a firewall that blocks loopback), the browser-side redirect to `http://127.0.0.1:<port>/callback?...` can't reach the remote callback server. `fetchOAuthRedirect(pastedUrl)` is the escape hatch: hand it the URL the browser ended up at and it fires a GET from inside the agent process. The request hits pi-ai's still-running server through the same handler a real browser would have, `waitForCode` resolves, and `runOAuthLogin` continues uninterrupted — no `onPrompt` call needed. The TUI wires this into a paste input next to the auth URL on every OAuth surface (AI providers + MCP).

### MCP-server OAuth

Streamable-HTTP and SSE MCP servers can require OAuth 2.1 (Linear, Sentry, the Anthropic-hosted catalog). Wired through the SDK's `OAuthClientProvider` interface — `zidane/chat` provides the persistence half (`McpCredentialStore` + `createFileMcpCredentialStore`), `zidane/mcp` provides the SDK-protocol half (`McpOAuthProvider`).

Three promotion paths to OAuth:

1. **Explicit** — `auth: 'oauth'` on the server config.
2. **Cursor alias** — `authMethod: 'mcpOAuth'` (the runtime-state field Cursor writes) is normalized to `auth: 'oauth'` at parse time, so a pasted `~/.cursor/mcp.json` Just Works.
3. **Auto-detect** — a server returning `UnauthorizedError` (i.e. 401 + RFC 9728 metadata) is auto-promoted, **but only when** the user has NOT supplied a static `Authorization` header.

Bootstrap behavior with `auth: 'oauth'`:

- **Tokens stored** → `McpOAuthProvider` is built with `redirectUri: undefined` (non-interactive). The SDK uses stored tokens, refreshes on expiry. Normal connect.
- **No tokens** → `client.connect()` throws `UnauthorizedError`. The bootstrap converts this into a `mcp:auth:required` event and skips the server (other servers continue). The GUI surfaces a `needs login` badge.

Interactive login lives in `loginMcpServer` (from `zidane/mcp`):

1. Spawn a loopback callback server (`startOAuthCallback`) on a random `127.0.0.1` port.
2. Build a fresh `McpOAuthProvider` with `redirectUri` pointing at the loopback.
3. `client.connect()` triggers the SDK to call `redirectToAuthorization(url)` — opens the user's browser via `tryOpenBrowser`, fires the `mcp:auth:url` hook so the modal can render the URL.
4. SDK throws `UnauthorizedError`. We wait on the callback for the `?code=...` redirect.
5. `transport.finishAuth(code)` exchanges the code for tokens; `provider.saveTokens` persists.
6. Reconnect with the new tokens; `client.listTools()` returns the catalog.
7. The host rebuilds the active agent so the now-authenticated server connects on the next run.

### MCP OAuth state

Pure reducer + React context so the GUI's badge updates live as the bootstrap + login flows emit events.

```ts
type McpAuthStatus
  = | { kind: 'idle' }
    | { kind: 'needs-auth', reason: 'no-tokens' | 'auto-promoted' }
    | { kind: 'authorizing', url?: string }
    | { kind: 'authed' }
    | { kind: 'error', error: string }
```

`McpAuthEvent` mirrors the agent hooks (`auth-required`, `auth-url`, `auth-success`, `auth-error`, `connected`) plus UI-only verbs (`login:start`, `login:cancel`, `logout`, `reset`).

```tsx
const state = useMcpAuthState()
const dispatch = useMcpAuthDispatch()

agent.hooks.hook('mcp:auth:required', ({ name, reason }) => dispatch({ type: 'auth-required', name, reason }))
agent.hooks.hook('mcp:auth:url',      ({ name, url })     => dispatch({ type: 'auth-url', name, url }))
agent.hooks.hook('mcp:auth:success',  ({ name })          => dispatch({ type: 'auth-success', name }))
agent.hooks.hook('mcp:auth:error',    ({ name, error })   => dispatch({ type: 'auth-error', name, error: error.message }))
agent.hooks.hook('mcp:connect',       ({ name }) => {
  if (mcpCredentialStore.load(name)?.tokens) dispatch({ type: 'auth-success', name })
  else                                       dispatch({ type: 'connected', name })
})
```

Two contexts split (`useMcpAuthState` reads; `useMcpAuthDispatch` writes) so dispatch consumers don't re-render on state churn. Mount `<McpAuthProvider>` **above** the modal layer so live updates propagate into open modals — the snapshot-at-open-time problem otherwise leaves badges stale.

`getMcpAuthStatus(state, name)` treats absent entries as `idle` so the map stays sparse.

## Cross-provider model catalog

`buildModelCatalog`, `filterModelCatalog`, and `indexOfEntry` assemble a unified picker that spans every authed provider.

```ts
const catalog: CatalogEntry[] = buildModelCatalog(availableProviders, modelsFor, currentPick)
const filtered = filterModelCatalog(catalog, query)
const cursor = indexOfEntry(filtered, currentPick)
```

Each `CatalogEntry` carries the provider key, model id, display name, capability flags (`reasoning`, `input`), context window, and a search corpus.

For an effort picker on top, gate visibility on `modelSupportsReasoning(descriptor, modelId)` and offer the `ThinkingLevel` values: `'off' | 'minimal' | 'low' | 'medium' | 'high' | 'adaptive'` (adaptive is Anthropic-only). Persist the user's choice keyed by **model id** (not provider) under `TuiState.lastEffortByModel` so switching the same model across providers preserves intent.

## Effective context window

```ts
import { getContextWindow, effectiveContextWindow, OUTPUT_RESERVE_TOKENS } from 'zidane/chat'

const raw = getContextWindow(descriptor, model)     // 200_000 (Claude Sonnet, e.g.)
const eff = effectiveContextWindow(raw)             // 180_000 = raw - OUTPUT_RESERVE_TOKENS
```

`OUTPUT_RESERVE_TOKENS = 20_000` matches Claude Code's `COMPACT_MAX_OUTPUT_TOKENS` — the reserved output budget every turn keeps in reserve. The footer indicator and the auto-compact trigger both key off `eff` so the two surfaces agree on "how full are we, really". `effectiveContextWindow(null) === null` — callers can pipe `getContextWindow` through unchanged.

## Tool-result persistence

The agent loop can replace oversize `tool_result` outputs with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB head preview. Imports from `zidane`:

- `resolvePersistDir({ userDir, sessionId })` → `<userDir>/persisted-tool-results/<sessionId>/`. Always pin to the user dir.
- `buildPersistedStub({ tool, callId, persistDir, content })` → the stub string.
- `maybePersistToolResult(input)` → returns the stub when the input crosses the threshold, otherwise `null`.
- `PERSISTED_STUB_PREFIX` (`'<persisted-output tool="'`) — recognize a stub for tail compaction / future stale-output elision.
- `PERSISTENCE_PREVIEW_BYTES = 2048` — bytes of head content included in the inline preview.
- `cleanupPersistedSession(persistDir)` — wipe on session delete.

Toggle off via `behavior.persistThreshold = 0` (the loop's persistence branch short-circuits). `behavior.persistExcludeTools` (defaults to `DEFAULT_PERSIST_EXCLUDE_TOOLS`) lists tools that are never persisted regardless of size.

Persisted blobs already on disk stay readable via `read_file` on the stub's `path` attribute even after the toggle flips off — only **future** tool results are affected.

## Compaction primitives

`zidane` re-exports the full `zidane/compact` surface. A GUI's `/compact` action calls:

```ts
import { compactConversation, summaryToTurn, buildPostCompactAttachments, selectFilesFromSession } from 'zidane'

const result = await compactConversation({ provider, model, turns: session.turns, scope: 'tail', signal })
// → { summary, summarizedTurnIds, droppedDueToPtl, model, usage }

const summaryTurn = summaryToTurn({
  summary: result.summary,
  replacesTurnIds: result.summarizedTurnIds,
  model: result.model,
  usage: result.usage,
})

const restoration = await buildPostCompactAttachments({
  recentFiles: selectFilesFromSession(session),
  execution: agent.execution,
  handle: agent.handle,
})

await session.appendTurns([summaryTurn, ...restoration.turns])
```

The marker turn carries a `compact-summary` content block; the agent loop scans for it during resume + before each provider call and elides every turn whose id appears in `replacesTurnIds` from the wire-level message list, replacing them with a single user-message containing the summary text. **Browsable history on disk stays intact** — the user can scroll above the boundary to see the original turns.

Errors: `CompactInvalidInputError`, `CompactPromptTooLongError`. Helpers: `truncateHeadForPtlRetry`, `stripImagesFromTurns`, `sliceForCompaction`, `anchorPreviewFor`, `ANCHOR_PREVIEW_MAX_CHARS`. Prompt builders: `buildCompactPrompt`, `buildFromCompactPrompt`, `buildFullCompactPrompt`, `buildTailCompactPrompt`, `buildUpToCompactPrompt`, `BASE_INSTRUCTIONS`, `NO_TOOLS_PREAMBLE`, `TRAILER`. Restoration: `selectRecentFiles`, `selectFilesFromReadState`, `BYTES_PER_TOKEN`, `estimateTokens`, `utf8ByteLength`.

## Session export

```ts
import { renderSession, writeSessionExport, resolveSessionExportTarget } from 'zidane/chat'

// Render only (clipboard / network / S3):
const text = renderSession(sessionData, { format: 'markdown' })   // or 'json'

// Render + write under `.{prefix}/sessions/`:
const target = await writeSessionExport({ session: sessionData, format: 'markdown', cwd, prefix })
// → { filepath, dir, anchor: 'project' | 'home' }
```

Anchor walks upward from `cwd` looking for `.git`; the first hit anchors to that repo's root (`project`). Outside a repo, the destination anchors to `home` (`~/.{prefix}/sessions/`). Use `resolveSessionExportTarget` to compute the path without writing.

## Title generation

```ts
import { generateSessionTitle, cleanTitle } from 'zidane/chat'

const title = await generateSessionTitle({ session: sessionData, provider, model, signal })
await store.save({ ...sessionData, metadata: { ...sessionData.metadata, title: cleanTitle(title) } })
```

Use `cleanTitle` to strip quotes / trailing punctuation / non-printable noise the model occasionally emits.

## Turn operations

```ts
import { deleteTurnSafely, truncateTurnsAt, turnAsText, countNeighbors } from 'zidane/chat'

// Delete + clean up orphan tool blocks in neighbors:
const next = deleteTurnSafely(session.turns, turnId)
await store.save({ ...sessionData, turns: next, updatedAt: Date.now() })

// Fork: truncate at and including turnId:
const truncated = truncateTurnsAt(session.turns, turnId)
const forked = await store.create({ projectRoot, turns: truncated, ... })

// Render a turn's text for copy / preview:
const text = turnAsText(turn)
```

`deleteTurnSafely` is conservative about orphan `tool_use` / `tool_result` pairs — removing the assistant turn drops the matching `tool_result` from the next user turn, and vice versa, so the persisted history stays protocol-valid.

## Patterns

**Read the active theme from a component** — `const { brand, model } = useColors()`. Component renders against the current palette and re-renders on theme switch automatically.

**Add a settings row** — append to `SETTINGS_TOGGLES` (boolean) or `SETTINGS_CHOICES` (string/number-valued) and set `category: 'agent' | 'ui'`. Renderers built off the live tables pick it up without code changes.

**Add a completion provider** — implement `CompletionProvider<TItem>`, pass the provider list to `useCompletion(state, providers)`. The popup picks it up automatically. Extend `theme.surfaces.chips[providerId]` for a per-kind pill color; otherwise the provider inherits `chips.default`.

**Compose a custom agent profile** — assemble the system prompt from `agent-prompt` fragments (or write your own), bundle a `Preset` via `definePreset`, register under your own id in the `agents` map. Hosts mixing built-ins with their own typically spread `agents: { ...BUILTIN_AGENTS, debug: myDebugProfile }`.

**Wire the auto-compact trigger** — after each `agent.run()`, call `shouldAutoCompact(...)`. On `'fire'`, run `compactConversation` in the background, store the promise on a ref, await it before the next `agent.run()`. Cancel via `AbortController` on session teardown.

**Wire interactions** — mount `<InteractionsProvider>`, build tools via `createInteractionTools({ requestInteraction: makeRequestInteraction(useInteractionsActions()) })`, render the head of the queue. On session activation call `pendingInteractionsFromTurns` and collect responses into a single `Map`; once full call `buildResumedToolResultsTurn`. See `docs/INTERACTIONS.md`.

**Render an edit diff** — call `extractEditPayload` in `tool:before` (passing pre-write content for `write_file`), persist on the `'tool'` `StreamEvent.edit` field, render via `buildUnifiedDiff(payload)` + `filetypeFromPath(payload.path)` for syntax highlighting. Theme via `theme.surfaces.diff`. For a renderer-faithful preview (matches the tool body's lenient resolver, real file line numbers, per-hunk resolvability metadata), use `previewEditPayload(payload, priorContent, contextLines?)` and pass `result.diffText` to the diff renderable; consult `result.resolution[i].resolved` to decide whether to badge the hunk as unresolvable.

**Wire per-edit approval** — in the `tool:gate` handler, call `extractEditPayload(name, input)`. If it returns a payload, route the decision through `resolveApprovalForPayload(decision, payload)`: on `shouldBlock`, emit `resolved.syntheticEvent` as a `'tool'` event (so the transcript shows the intended diff with denied badges), emit a paired `'tool-result'` event with body `[fully denied] ${buildEditOutcomesAnnotation(resolved.outcomes)}` for live display, and set `ctx.block = true`; on partial-accept, **rebind** `ctx.input` to a fresh shallow-clone whose `edits` is the approved subset and stash `resolved.outcomes` in a per-callId pending-annotation map. A paired `tool:transform` handler reads back the map and appends `buildEditOutcomesAnnotation(outcomes)` to `ctx.result`, so the wire / persisted history carries the per-hunk decisions for replay. Register the same handler on `child:tool:transform` for subagent-issued calls. Wipe the map on `agent:done` to catch paths where `tool:transform` never fires (validation reject, throwing `tool:before`). See **Per-edit approval** for the full gate handler skeleton.

**Drive a model picker** — `buildModelCatalog(providers, modelsFor, currentPick)` + `filterModelCatalog(catalog, query)` + `indexOfEntry`. Gate an effort sub-picker on `modelSupportsReasoning(descriptor, modelId)`.

**Detect resume credentials** — `detectAuth(dataDir, providers).filter(p => p.available)` returns providers the user can log into without re-running the wizard.

**Render a transcript from a loaded session** — `eventsFromTurns(session.turns, session.runs)` + your own renderer iterating the events. The TUI ships `<Transcript events settings />` as one reference implementation.

**Track context-window usage** — read `lastContextSizeFromTurns(turns, runs)` for the persisted last value; subscribe to `turn:after` to update live (`turnContextSize(usage)` for the cache-aware number). Display as a percentage of `effectiveContextWindow(getContextWindow(descriptor, model))`.

**Render chip pills on submitted prompts** — call `splitPromptSegments(text, refs)` and map each `'chip'` segment to your platform's pill (HTML `<span>`, React component, etc.). Pull colors from `theme.surfaces.chips` via `resolveChipColor`.

**Persist + restore the user's last picks** — `stateStore.save({ ...stateStore.load(), lastProvider, lastSessionId, lastModelByProvider, lastEffortByModel, lastAgent, settings })`. Resume is automatic via `resolveConfig`'s `resumeProvider` + `initialPicked`.

See `src/tui/app.tsx` for the canonical wiring across every pattern above.

## Related

- `docs/TUI.md` — terminal shell built on top of this surface.
- `docs/SKILL.md` — agent core (providers, presets, hooks, skills, MCP, sessions).
- `docs/ARCHITECTURE.md` — agent loop, hook firing order, gate precedence.
- `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol, resumed-flow contract.
