@f5xc-salesdemos/xcsh 18.8.2 → 18.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.8.2",
4
+ "version": "18.10.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "dependencies": {
48
48
  "@agentclientprotocol/sdk": "0.16.1",
49
49
  "@mozilla/readability": "^0.6",
50
- "@f5xc-salesdemos/xcsh-stats": "18.8.2",
51
- "@f5xc-salesdemos/pi-agent-core": "18.8.2",
52
- "@f5xc-salesdemos/pi-ai": "18.8.2",
53
- "@f5xc-salesdemos/pi-natives": "18.8.2",
54
- "@f5xc-salesdemos/pi-tui": "18.8.2",
55
- "@f5xc-salesdemos/pi-utils": "18.8.2",
50
+ "@f5xc-salesdemos/xcsh-stats": "18.10.0",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.10.0",
52
+ "@f5xc-salesdemos/pi-ai": "18.10.0",
53
+ "@f5xc-salesdemos/pi-natives": "18.10.0",
54
+ "@f5xc-salesdemos/pi-tui": "18.10.0",
55
+ "@f5xc-salesdemos/pi-utils": "18.10.0",
56
56
  "@sinclair/typebox": "^0.34",
57
57
  "@xterm/headless": "^6.0",
58
58
  "ajv": "^8.18",
@@ -657,6 +657,17 @@ export const SETTINGS_SCHEMA = {
657
657
  },
658
658
  },
659
659
 
660
+ "keybindings.chordTimeout": {
661
+ type: "number",
662
+ default: 1000,
663
+ ui: {
664
+ tab: "interaction",
665
+ label: "Chord Timeout",
666
+ description: "Milliseconds to wait for the second key of a chord binding before abandoning it.",
667
+ submenu: true,
668
+ },
669
+ },
670
+
660
671
  "startup.quiet": {
661
672
  type: "boolean",
662
673
  default: false,
@@ -356,6 +356,17 @@ export class Settings {
356
356
  return this.get("bashInterceptor.patterns");
357
357
  }
358
358
 
359
+ /**
360
+ * Get the chord-binding timeout (milliseconds) clamped to the supported range.
361
+ * Values outside [200, 5000] are clamped so a malformed config cannot break the
362
+ * chord dispatcher (e.g. 0 => never abandon, huge value => leaked state).
363
+ */
364
+ getChordTimeoutMs(): number {
365
+ const raw = this.get("keybindings.chordTimeout");
366
+ const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 1000;
367
+ return Math.min(5000, Math.max(200, Math.round(value)));
368
+ }
369
+
359
370
  /**
360
371
  * Set a model role (helper for modelRoles record).
361
372
  */
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.8.2",
21
- "commit": "b67646bd5096b169019d794fd0f894f2969696d4",
22
- "shortCommit": "b67646b",
20
+ "version": "18.10.0",
21
+ "commit": "3b8dbd2bf6b06aab98bbe3bacfd48cd341ab8c06",
22
+ "shortCommit": "3b8dbd2",
23
23
  "branch": "main",
24
- "tag": "v18.8.2",
25
- "commitDate": "2026-04-22T19:12:35Z",
26
- "buildDate": "2026-04-22T19:48:35.804Z",
24
+ "tag": "v18.10.0",
25
+ "commitDate": "2026-04-22T21:59:55Z",
26
+ "buildDate": "2026-04-22T22:25:06.694Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/b67646bd5096b169019d794fd0f894f2969696d4",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.8.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/3b8dbd2bf6b06aab98bbe3bacfd48cd341ab8c06",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.10.0"
33
33
  };
@@ -51,7 +51,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
51
51
  "sessions/session-tree-plan.md": "---\ntitle: Session Tree Architecture\nsidebar:\n order: 2\n label: Tree architecture\n---\n\n# Session tree architecture (current)\n\nReference: [session.md](./session.md)\n\nThis document describes how session tree navigation works today: in-memory tree model, leaf movement rules, branching behavior, and extension/event integration.\n\n## What this subsystem is\n\nThe session is stored as an append-only entry log, but runtime behavior is tree-based:\n\n- Every non-header entry has `id` and `parentId`.\n- The active position is `leafId` in `SessionManager`.\n- Appending an entry always creates a child of the current leaf.\n- Branching does **not** rewrite history; it only changes where the leaf points before the next append.\n\nKey files:\n\n- `src/session/session-manager.ts` — tree data model, traversal, leaf movement, branch/session extraction\n- `src/session/agent-session.ts` — `/tree` navigation flow, summarization, hook/event emission\n- `src/modes/components/tree-selector.ts` — interactive tree UI behavior and filtering\n- `src/modes/controllers/selector-controller.ts` — selector orchestration for `/tree` and `/branch`\n- `src/modes/controllers/input-controller.ts` — command routing (`/tree`, `/branch`, double-escape behavior)\n- `src/session/messages.ts` — conversion of `branch_summary`, `compaction`, and `custom_message` entries into LLM context messages\n\n## Tree data model in `SessionManager`\n\nRuntime indices:\n\n- `#byId: Map<string, SessionEntry>` — fast lookup for any entry\n- `#leafId: string | null` — current position in the tree\n- `#labelsById: Map<string, string>` — resolved labels by target entry id\n\nTree APIs:\n\n- `getBranch(fromId?)` walks parent links to root and returns root→node path\n- `getTree()` returns `SessionTreeNode[]` (`entry`, `children`, `label`)\n - parent links become children arrays\n - entries with missing parents are treated as roots\n - children are sorted oldest→newest by timestamp\n- `getChildren(parentId)` returns direct children\n- `getLabel(id)` resolves current label from `labelsById`\n\n`getTree()` is a runtime projection; persistence remains append-only JSONL entries.\n\n## Leaf movement semantics\n\nThere are three leaf movement primitives:\n\n1. `branch(entryId)`\n - Validates entry exists\n - Sets `leafId = entryId`\n - No new entry is written\n\n2. `resetLeaf()`\n - Sets `leafId = null`\n - Next append creates a new root entry (`parentId = null`)\n\n3. `branchWithSummary(branchFromId, summary, details?, fromExtension?)`\n - Accepts `branchFromId: string | null`\n - Sets `leafId = branchFromId`\n - Appends a `branch_summary` entry as child of that leaf\n - When `branchFromId` is `null`, `fromId` is persisted as `\"root\"`\n\n## `/tree` navigation behavior (same session file)\n\n`AgentSession.navigateTree()` is navigation, not file forking.\n\nFlow:\n\n1. Validate target and compute abandoned path (`collectEntriesForBranchSummary`)\n2. Emit `session_before_tree` with `TreePreparation`\n3. Optionally summarize abandoned entries (hook-provided summary or built-in summarizer)\n4. Compute new leaf target:\n - selecting a **user** message: leaf moves to its parent, and message text is returned for editor prefill\n - selecting a **custom_message**: same rule as user message (leaf = parent, text prefills editor)\n - selecting any other entry: leaf = selected entry id\n5. Apply leaf move:\n - with summary: `branchWithSummary(newLeafId, ...)`\n - without summary and `newLeafId === null`: `resetLeaf()`\n - otherwise: `branch(newLeafId)`\n6. Rebuild agent context from new leaf and emit `session_tree`\n\nImportant: summary entries are attached at the **new navigation position**, not on the abandoned branch tail.\n\n## `/branch` behavior (new session file)\n\n`/branch` and `/tree` are intentionally different:\n\n- `/tree` navigates within the current session file.\n- `/branch` creates a new session branch file (or in-memory replacement for non-persistent mode).\n\nUser-facing `/branch` flow (`SelectorController.showUserMessageSelector` → `AgentSession.branch`):\n\n- Branch source must be a **user message**.\n- Selected user text is extracted for editor prefill.\n- If selected user message is root (`parentId === null`): start a new session via `newSession({ parentSession: previousSessionFile })`.\n- Otherwise: `createBranchedSession(selectedEntry.parentId)` to fork history up to the selected prompt boundary.\n\n`SessionManager.createBranchedSession(leafId)` specifics:\n\n- Builds root→leaf path via `getBranch(leafId)`; throws if missing.\n- Excludes existing `label` entries from copied path.\n- Rebuilds fresh label entries from resolved `labelsById` for entries that remain in path.\n- Persistent mode: writes new JSONL file and switches manager to it; returns new file path.\n- In-memory mode: replaces in-memory entries; returns `undefined`.\n\n## Context reconstruction and summary/custom integration\n\n`buildSessionContext()` (in `session-manager.ts`) resolves the active root→leaf path and builds effective LLM context state:\n\n- Tracks latest thinking/model/mode/ttsr state on path.\n- Handles latest compaction on path:\n - emits compaction summary first\n - replays kept messages from `firstKeptEntryId` to compaction point\n - then replays post-compaction messages\n- Includes `branch_summary` and `custom_message` entries as `AgentMessage` objects.\n\n`session/messages.ts` then maps these message types for model input:\n\n- `branchSummary` and `compactionSummary` become user-role templated context messages\n- `custom`/`hookMessage` become user-role content messages\n\nSo tree movement changes context by changing the active leaf path, not by mutating old entries.\n\n## Labels and tree UI behavior\n\nLabel persistence:\n\n- `appendLabelChange(targetId, label?)` writes `label` entries on the current leaf chain.\n- `labelsById` is updated immediately (set or delete).\n- `getTree()` resolves current label onto each returned node.\n\nTree selector behavior (`tree-selector.ts`):\n\n- Flattens tree for navigation, keeps active-path highlighting, and prioritizes displaying the active branch first.\n- Supports filter modes: `default`, `no-tools`, `user-only`, `labeled-only`, `all`.\n- Supports free-text search over rendered semantic content.\n- `Shift+L` opens inline label editing and writes via `appendLabelChange`.\n\nCommand routing:\n\n- `/tree` always opens tree selector.\n- `/branch` opens user-message selector unless `doubleEscapeAction=tree`, in which case it also uses tree selector UX.\n\n## Extension and hook touchpoints for tree operations\n\nCommand-time extension API (`ExtensionCommandContext`):\n\n- `branch(entryId)` — create branched session file\n- `navigateTree(targetId, { summarize? })` — move within current tree/file\n\nEvents around tree navigation:\n\n- `session_before_tree`\n - receives `TreePreparation`:\n - `targetId`\n - `oldLeafId`\n - `commonAncestorId`\n - `entriesToSummarize`\n - `userWantsSummary`\n - may cancel navigation\n - may provide summary payload used instead of built-in summarizer\n - receives abort `signal` (Escape cancellation path)\n- `session_tree`\n - emits `newLeafId`, `oldLeafId`\n - includes `summaryEntry` when a summary was created\n - `fromExtension` indicates summary origin\n\nAdjacent but related lifecycle hooks:\n\n- `session_before_branch` / `session_branch` for `/branch` flow\n- `session_before_compact`, `session.compacting`, `session_compact` for compaction entries that later affect tree-context reconstruction\n\n## Real constraints and edge conditions\n\n- `branch()` cannot target `null`; use `resetLeaf()` for root-before-first-entry state.\n- `branchWithSummary()` supports `null` target and records `fromId: \"root\"`.\n- Selecting current leaf in tree selector is a no-op.\n- Summarization requires an active model; if absent, summarize navigation fails fast.\n- If summarization is aborted, navigation is cancelled and leaf is unchanged.\n- In-memory sessions never return a branch file path from `createBranchedSession`.\n\n## Legacy compatibility still present\n\nSession migrations still run on load:\n\n- v1→v2 adds `id`/`parentId` and converts compaction index anchor to id anchor\n- v2→v3 migrates legacy `hookMessage` role to `custom`\n\nCurrent runtime behavior is version-3 tree semantics after migration.\n",
52
52
  "sessions/session.md": "---\ntitle: Session Storage and Entry Model\nsidebar:\n order: 1\n label: Storage & entry model\n---\n\n# Session Storage and Entry Model\n\nThis document is the source of truth for how coding-agent sessions are represented, persisted, migrated, and reconstructed at runtime.\n\n## Scope\n\nCovers:\n\n- Session JSONL format and versioning\n- Entry taxonomy and tree semantics (`id`/`parentId` + leaf pointer)\n- Migration/compatibility behavior when loading old or malformed files\n- Context reconstruction (`buildSessionContext`)\n- Persistence guarantees, failure behavior, truncation/blob externalization\n- Storage abstractions (`FileSessionStorage`, `MemorySessionStorage`) and related utilities\n\nDoes not cover `/tree` UI rendering behavior beyond semantics that affect session data.\n\n## Implementation Files\n\n- [`src/session/session-manager.ts`](../../packages/coding-agent/src/session/session-manager.ts)\n- [`src/session/messages.ts`](../../packages/coding-agent/src/session/messages.ts)\n- [`src/session/session-storage.ts`](../../packages/coding-agent/src/session/session-storage.ts)\n- [`src/session/history-storage.ts`](../../packages/coding-agent/src/session/history-storage.ts)\n- [`src/session/blob-store.ts`](../../packages/coding-agent/src/session/blob-store.ts)\n\n## On-Disk Layout\n\nDefault session file location:\n\n```text\n~/.xcsh/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl\n```\n\n`<cwd-encoded>` is derived from the working directory by stripping leading slash and replacing `/`, `\\\\`, and `:` with `-`.\n\nBlob store location:\n\n```text\n~/.xcsh/agent/blobs/<sha256>\n```\n\nTerminal breadcrumb files are written under:\n\n```text\n~/.xcsh/agent/terminal-sessions/<terminal-id>\n```\n\nBreadcrumb content is two lines: original cwd, then session file path. `continueRecent()` prefers this terminal-scoped pointer before scanning most-recent mtime.\n\n## File Format\n\nSession files are JSONL: one JSON object per line.\n\n- Line 1 is always the session header (`type: \"session\"`).\n- Remaining lines are `SessionEntry` values.\n- Entries are append-only at runtime; branch navigation moves a pointer (`leafId`) rather than mutating existing entries.\n\n### Header (`SessionHeader`)\n\n```json\n{\n \"type\": \"session\",\n \"version\": 3,\n \"id\": \"1f9d2a6b9c0d1234\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\",\n \"cwd\": \"/work/pi\",\n \"title\": \"optional session title\",\n \"parentSession\": \"optional lineage marker\"\n}\n```\n\nNotes:\n\n- `version` is optional in v1 files; absence means v1.\n- `parentSession` is an opaque lineage string. Current code writes either a session id or a session path depending on flow (`fork`, `forkFrom`, `createBranchedSession`, or explicit `newSession({ parentSession })`). Treat as metadata, not a typed foreign key.\n\n### Entry Base (`SessionEntryBase`)\n\nAll non-header entries include:\n\n```json\n{\n \"type\": \"...\",\n \"id\": \"8-char-id\",\n \"parentId\": \"previous-or-branch-parent\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\"\n}\n```\n\n`parentId` can be `null` for a root entry (first append, or after `resetLeaf()`).\n\n## Entry Taxonomy\n\n`SessionEntry` is the union of:\n\n- `message`\n- `thinking_level_change`\n- `model_change`\n- `compaction`\n- `branch_summary`\n- `custom`\n- `custom_message`\n- `label`\n- `ttsr_injection`\n- `session_init`\n- `mode_change`\n\n### `message`\n\nStores an `AgentMessage` directly.\n\n```json\n{\n \"type\": \"message\",\n \"id\": \"a1b2c3d4\",\n \"parentId\": null,\n \"timestamp\": \"2026-02-16T10:21:00.000Z\",\n \"message\": {\n \"role\": \"assistant\",\n \"provider\": \"anthropic\",\n \"model\": \"claude-sonnet-4-5\",\n \"content\": [{ \"type\": \"text\", \"text\": \"Done.\" }],\n \"usage\": { \"input\": 100, \"output\": 20, \"cacheRead\": 0, \"cacheWrite\": 0, \"cost\": { \"input\": 0, \"output\": 0, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0 } },\n \"timestamp\": 1760000000000\n }\n}\n```\n\n### `model_change`\n\n```json\n{\n \"type\": \"model_change\",\n \"id\": \"b1c2d3e4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:21:30.000Z\",\n \"model\": \"openai/gpt-4o\",\n \"role\": \"default\"\n}\n```\n\n`role` is optional; missing is treated as `default` in context reconstruction.\n\n### `thinking_level_change`\n\n```json\n{\n \"type\": \"thinking_level_change\",\n \"id\": \"c1d2e3f4\",\n \"parentId\": \"b1c2d3e4\",\n \"timestamp\": \"2026-02-16T10:22:00.000Z\",\n \"thinkingLevel\": \"high\"\n}\n```\n\n### `compaction`\n\n```json\n{\n \"type\": \"compaction\",\n \"id\": \"d1e2f3a4\",\n \"parentId\": \"c1d2e3f4\",\n \"timestamp\": \"2026-02-16T10:23:00.000Z\",\n \"summary\": \"Conversation summary\",\n \"shortSummary\": \"Short recap\",\n \"firstKeptEntryId\": \"a1b2c3d4\",\n \"tokensBefore\": 42000,\n \"details\": { \"readFiles\": [\"src/a.ts\"] },\n \"preserveData\": { \"hookState\": true },\n \"fromExtension\": false\n}\n```\n\n### `branch_summary`\n\n```json\n{\n \"type\": \"branch_summary\",\n \"id\": \"e1f2a3b4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:24:00.000Z\",\n \"fromId\": \"a1b2c3d4\",\n \"summary\": \"Summary of abandoned path\",\n \"details\": { \"note\": \"optional\" },\n \"fromExtension\": true\n}\n```\n\nIf branching from root (`branchFromId === null`), `fromId` is the literal string `\"root\"`.\n\n### `custom`\n\nExtension state persistence; ignored by `buildSessionContext`.\n\n```json\n{\n \"type\": \"custom\",\n \"id\": \"f1a2b3c4\",\n \"parentId\": \"e1f2a3b4\",\n \"timestamp\": \"2026-02-16T10:25:00.000Z\",\n \"customType\": \"my-extension\",\n \"data\": { \"state\": 1 }\n}\n```\n\n### `custom_message`\n\nExtension-provided message that does participate in LLM context.\n\n```json\n{\n \"type\": \"custom_message\",\n \"id\": \"a2b3c4d5\",\n \"parentId\": \"f1a2b3c4\",\n \"timestamp\": \"2026-02-16T10:26:00.000Z\",\n \"customType\": \"my-extension\",\n \"content\": \"Injected context\",\n \"display\": true,\n \"details\": { \"debug\": false }\n}\n```\n\n### `label`\n\n```json\n{\n \"type\": \"label\",\n \"id\": \"b2c3d4e5\",\n \"parentId\": \"a2b3c4d5\",\n \"timestamp\": \"2026-02-16T10:27:00.000Z\",\n \"targetId\": \"a1b2c3d4\",\n \"label\": \"checkpoint\"\n}\n```\n\n`label: undefined` clears a label for `targetId`.\n\n### `ttsr_injection`\n\n```json\n{\n \"type\": \"ttsr_injection\",\n \"id\": \"c2d3e4f5\",\n \"parentId\": \"b2c3d4e5\",\n \"timestamp\": \"2026-02-16T10:28:00.000Z\",\n \"injectedRules\": [\"ruleA\", \"ruleB\"]\n}\n```\n\n### `session_init`\n\n```json\n{\n \"type\": \"session_init\",\n \"id\": \"d2e3f4a5\",\n \"parentId\": \"c2d3e4f5\",\n \"timestamp\": \"2026-02-16T10:29:00.000Z\",\n \"systemPrompt\": \"...\",\n \"task\": \"...\",\n \"tools\": [\"read\", \"edit\"],\n \"outputSchema\": { \"type\": \"object\" }\n}\n```\n\n### `mode_change`\n\n```json\n{\n \"type\": \"mode_change\",\n \"id\": \"e2f3a4b5\",\n \"parentId\": \"d2e3f4a5\",\n \"timestamp\": \"2026-02-16T10:30:00.000Z\",\n \"mode\": \"plan\",\n \"data\": { \"planFile\": \"/tmp/plan.md\" }\n}\n```\n\n## Versioning and Migration\n\nCurrent session version: `3`.\n\n### v1 -> v2\n\nApplied when header `version` is missing or `< 2`:\n\n- Adds `id` and `parentId` to each non-header entry.\n- Reconstructs a linear parent chain using file order.\n- Migrates compaction field `firstKeptEntryIndex` -> `firstKeptEntryId` when present.\n- Sets header `version = 2`.\n\n### v2 -> v3\n\nApplied when header `version < 3`:\n\n- For `message` entries: rewrites legacy `message.role === \"hookMessage\"` to `\"custom\"`.\n- Sets header `version = 3`.\n\n### Migration Trigger and Persistence\n\n- Migrations run during session load (`setSessionFile`).\n- If any migration ran, the entire file is rewritten to disk immediately.\n- Migration mutates in-memory entries first, then persists rewritten JSONL.\n\n## Load and Compatibility Behavior\n\n`loadEntriesFromFile(path)` behavior:\n\n- Missing file (`ENOENT`) -> returns `[]`.\n- Non-parseable lines are handled by lenient JSONL parser (`parseJsonlLenient`).\n- If first parsed entry is not a valid session header (`type !== \"session\"` or missing string `id`) -> returns `[]`.\n\n`SessionManager.setSessionFile()` behavior:\n\n- `[]` from loader is treated as empty/nonexistent session and replaced with a new initialized session file at that path.\n- Valid files are loaded, migrated if needed, blob refs resolved, then indexed.\n\n## Tree and Leaf Semantics\n\nThe underlying model is append-only tree + mutable leaf pointer:\n\n- Every append method creates exactly one new entry whose `parentId` is current `leafId`.\n- The new entry becomes the new `leafId`.\n- `branch(entryId)` moves only `leafId`; existing entries remain unchanged.\n- `resetLeaf()` sets `leafId = null`; next append creates a new root entry (`parentId: null`).\n- `branchWithSummary()` sets leaf to branch target and appends a `branch_summary` entry.\n\n`getEntries()` returns all non-header entries in insertion order. Existing entries are not deleted in normal operation; rewrites preserve logical history while updating representation (migrations, move, targeted rewrite helpers).\n\n## Context Reconstruction (`buildSessionContext`)\n\n`buildSessionContext(entries, leafId, byId?)` resolves what is sent to the model.\n\nAlgorithm:\n\n1. Determine leaf:\n - `leafId === null` -> return empty context.\n - explicit `leafId` -> use that entry if found.\n - otherwise fallback to last entry.\n2. Walk `parentId` chain from leaf to root and reverse to root->leaf path.\n3. Derive runtime state across path:\n - `thinkingLevel` from latest `thinking_level_change` (default `\"off\"`)\n - model map from `model_change` entries (`role ?? \"default\"`)\n - fallback `models.default` from assistant message provider/model if no explicit model change\n - deduplicated `injectedTtsrRules` from all `ttsr_injection` entries\n - mode/modeData from latest `mode_change` (default mode `\"none\"`)\n4. Build message list:\n - `message` entries pass through\n - `custom_message` entries become `custom` AgentMessages via `createCustomMessage`\n - `branch_summary` entries become `branchSummary` AgentMessages via `createBranchSummaryMessage`\n - if a `compaction` exists on path:\n - emit compaction summary first (`createCompactionSummaryMessage`)\n - emit path entries starting at `firstKeptEntryId` up to the compaction boundary\n - emit entries after the compaction boundary\n\n`custom` and `session_init` entries do not inject model context directly.\n\n## Persistence Guarantees and Failure Model\n\n### Persist vs in-memory\n\n- `SessionManager.create/open/continueRecent/forkFrom` -> persistent mode (`persist = true`).\n- `SessionManager.inMemory` -> non-persistent mode (`persist = false`) with `MemorySessionStorage`.\n\n### Write pipeline\n\nWrites are serialized through an internal promise chain (`#persistChain`) and `NdjsonFileWriter`.\n\n- `append*` updates in-memory state immediately.\n- Persistence is deferred until at least one assistant message exists.\n - Before first assistant: entries are retained in memory; no file append occurs.\n - When first assistant exists: full in-memory session is flushed to file.\n - Afterwards: new entries append incrementally.\n\nRationale in code: avoid persisting sessions that never produced an assistant response.\n\n### Durability operations\n\n- `flush()` flushes writer and calls `fsync()`.\n- Atomic full rewrites (`#rewriteFile`) write to temp file, flush+fsync, close, then rename over target.\n- Used for migrations, `setSessionName`, `rewriteEntries`, move operations, and tool-call arg rewrites.\n\n### Error behavior\n\n- Persistence errors are latched (`#persistError`) and rethrown on subsequent operations.\n- First error is logged once with session file context.\n- Writer close is best-effort but propagates the first meaningful error.\n\n## Data Size Controls and Blob Externalization\n\nBefore persisting entries:\n\n- Large strings are truncated to `MAX_PERSIST_CHARS` (500,000 chars) with notice:\n - `\"[Session persistence truncated large content]\"`\n- Transient fields `partialJson` and `jsonlEvents` are removed.\n- If object has both `content` and `lineCount`, line count is recomputed after truncation.\n- Image blocks in `content` arrays with base64 length >= 1024 are externalized to blob refs:\n - stored as `blob:sha256:<hash>`\n - raw bytes written to blob store (`BlobStore.put`)\n\nOn load, blob refs are resolved back to base64 for message/custom_message image blocks.\n\n## Storage Abstractions\n\n`SessionStorage` interface provides all filesystem operations used by `SessionManager`:\n\n- sync: `ensureDirSync`, `existsSync`, `writeTextSync`, `statSync`, `listFilesSync`\n- async: `exists`, `readText`, `readTextPrefix`, `writeText`, `rename`, `unlink`, `openWriter`\n\nImplementations:\n\n- `FileSessionStorage`: real filesystem (Bun + node fs)\n- `MemorySessionStorage`: map-backed in-memory implementation for tests/non-persistent sessions\n\n`SessionStorageWriter` exposes `writeLine`, `flush`, `fsync`, `close`, `getError`.\n\n## Session Discovery Utilities\n\nDefined in `session-manager.ts`:\n\n- `getRecentSessions(sessionDir, limit)` -> lightweight metadata for UI/session picker\n- `findMostRecentSession(sessionDir)` -> newest by mtime\n- `list(cwd, sessionDir?)` -> sessions in one project scope\n- `listAll()` -> sessions across all project scopes under `~/.xcsh/agent/sessions`\n\nMetadata extraction reads only a prefix (`readTextPrefix(..., 4096)`) where possible.\n\n## Related but Distinct: Prompt History Storage\n\n`HistoryStorage` (`history-storage.ts`) is a separate SQLite subsystem for prompt recall/search, not session replay.\n\n- DB: `~/.xcsh/agent/history.db`\n- Table: `history(id, prompt, created_at, cwd)`\n- FTS5 index: `history_fts` with trigger-maintained sync\n- Deduplicates consecutive identical prompts using in-memory last-prompt cache\n- Async insertion (`setImmediate`) so prompt capture does not block turn execution\n\nUse session files for conversation graph/state replay; use `HistoryStorage` for prompt history UX.\n",
53
53
  "sessions/ttsr-injection-lifecycle.md": "---\ntitle: TTSR Injection Lifecycle\nsidebar:\n order: 9\n label: TTSR injection\n---\n\n# TTSR Injection Lifecycle\n\nThis document covers the current Time Traveling Stream Rules (TTSR) runtime path from rule discovery to stream interruption, retry injection, extension notifications, and session-state handling.\n\n## Implementation files\n\n- [`../src/sdk.ts`](../../packages/coding-agent/src/sdk.ts)\n- [`../src/export/ttsr.ts`](../../packages/coding-agent/src/export/ttsr.ts)\n- [`../src/session/agent-session.ts`](../../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/session/session-manager.ts`](../../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/prompts/system/ttsr-interrupt.md`](../../packages/coding-agent/src/prompts/system/ttsr-interrupt.md)\n- [`../src/capability/index.ts`](../../packages/coding-agent/src/capability/index.ts)\n- [`../src/extensibility/extensions/types.ts`](../../packages/coding-agent/src/extensibility/extensions/types.ts)\n- [`../src/extensibility/hooks/types.ts`](../../packages/coding-agent/src/extensibility/hooks/types.ts)\n- [`../src/extensibility/custom-tools/types.ts`](../../packages/coding-agent/src/extensibility/custom-tools/types.ts)\n- [`../src/modes/controllers/event-controller.ts`](../../packages/coding-agent/src/modes/controllers/event-controller.ts)\n\n## 1. Discovery feed and rule registration\n\nAt session creation, `createAgentSession()` loads all discovered rules and constructs a `TtsrManager`:\n\n```ts\nconst ttsrSettings = settings.getGroup(\"ttsr\");\nconst ttsrManager = new TtsrManager(ttsrSettings);\nconst rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });\nfor (const rule of rulesResult.items) {\n if (rule.ttsrTrigger) ttsrManager.addRule(rule);\n}\n```\n\n### Pre-registration dedupe behavior\n\n`loadCapability(\"rules\")` deduplicates by `rule.name` with first-wins semantics (higher provider priority first). Shadowed duplicates are removed before TTSR registration.\n\n### `TtsrManager.addRule()` behavior\n\nRegistration is skipped when:\n\n- `rule.ttsrTrigger` is absent\n- a rule with the same `rule.name` was already registered in this manager\n- the regex fails to compile (`new RegExp(rule.ttsrTrigger)` throws)\n\nInvalid regex triggers are logged as warnings and ignored; session startup continues.\n\n### Setting caveat\n\n`TtsrSettings.enabled` is loaded into the manager but is not currently checked in runtime gating. If rules exist, matching still runs.\n\n## 2. Streaming monitor lifecycle\n\nTTSR detection runs inside `AgentSession.#handleAgentEvent`.\n\n### Turn start\n\nOn `turn_start`, the stream buffer is reset:\n\n- `ttsrManager.resetBuffer()`\n\n### During stream (`message_update`)\n\nWhen assistant updates arrive and rules exist:\n\n- monitor `text_delta` and `toolcall_delta`\n- append delta into manager buffer\n- call `check(buffer)`\n\n`check()` iterates registered rules and returns all matching rules that pass repeat policy (`#canTrigger`).\n\n## 3. Trigger decision and immediate abort path\n\nWhen one or more rules match:\n\n1. `markInjected(matches)` records rule names in manager injection state.\n2. matched rules are queued in `#pendingTtsrInjections`.\n3. `#ttsrAbortPending = true`.\n4. `agent.abort()` is called immediately.\n5. `ttsr_triggered` event is emitted asynchronously (fire-and-forget).\n6. retry work is scheduled via `setTimeout(..., 50)`.\n\nAbort is not blocked on extension callbacks.\n\n## 4. Retry scheduling, context mode, and reminder injection\n\nAfter the 50ms timeout:\n\n1. `#ttsrAbortPending = false`\n2. read `ttsrManager.getSettings().contextMode`\n3. if `contextMode === \"discard\"`, drop partial assistant output with `agent.popMessage()`\n4. build injection content from pending rules using `ttsr-interrupt.md` template\n5. append a synthetic user message containing one `<system-interrupt ...>` block per rule\n6. call `agent.continue()` to retry generation\n\nTemplate payload is:\n\n```xml\n<system-interrupt reason=\"rule_violation\" rule=\"{{name}}\" path=\"{{path}}\">\n...\n{{content}}\n</system-interrupt>\n```\n\nPending injections are cleared after content generation.\n\n### `contextMode` behavior on partial output\n\n- `discard`: partial/aborted assistant message is removed before retry.\n- `keep`: partial assistant output remains in conversation state; reminder is appended after it.\n\n## 5. Repeat policy and gap logic\n\n`TtsrManager` tracks `#messageCount` and per-rule `lastInjectedAt`.\n\n### `repeatMode: \"once\"`\n\nA rule can trigger only once after it has an injection record.\n\n### `repeatMode: \"after-gap\"`\n\nA rule can re-trigger only when:\n\n- `messageCount - lastInjectedAt >= repeatGap`\n\n`messageCount` increments on `turn_end`, so gap is measured in completed turns, not stream chunks.\n\n## 6. Event emission and extension/hook surfaces\n\n### Session event\n\n`AgentSessionEvent` includes:\n\n```ts\n{ type: \"ttsr_triggered\"; rules: Rule[] }\n```\n\n### Extension runner\n\n`#emitSessionEvent()` routes the event to:\n\n- extension listeners (`ExtensionRunner.emit({ type: \"ttsr_triggered\", rules })`)\n- local session subscribers\n\n### Hook and custom-tool typing\n\n- extension API exposes `on(\"ttsr_triggered\", ...)`\n- hook API exposes `on(\"ttsr_triggered\", ...)`\n- custom tools receive `onSession({ reason: \"ttsr_triggered\", rules })`\n\n### Interactive-mode rendering difference\n\nInteractive mode uses `session.isTtsrAbortPending` to suppress showing the aborted assistant stop reason as a visible failure during TTSR interruption, and renders a `TtsrNotificationComponent` when the event arrives.\n\n## 7. Persistence and resume state (current implementation)\n\n`SessionManager` has full schema support for injected-rule persistence:\n\n- entry type: `ttsr_injection`\n- append API: `appendTtsrInjection(ruleNames)`\n- query API: `getInjectedTtsrRules()`\n- context reconstruction includes `SessionContext.injectedTtsrRules`\n\n`TtsrManager` also supports restoration via `restoreInjected(ruleNames)`.\n\n### Current wiring status\n\nIn the current runtime path:\n\n- `AgentSession` does not append `ttsr_injection` entries when TTSR triggers.\n- `createAgentSession()` does not restore `existingSession.injectedTtsrRules` back into `ttsrManager`.\n\nNet effect: injected-rule suppression is enforced in-memory for the live process, but is not currently persisted/restored across session reload/resume by this path.\n\n## 8. Race boundaries and ordering guarantees\n\n### Abort vs retry callback\n\n- abort is synchronous from TTSR handler perspective (`agent.abort()` called immediately)\n- retry is deferred by timer (`50ms`)\n- extension notification is asynchronous and intentionally not awaited before abort/retry scheduling\n\n### Multiple matches in same stream window\n\n`check()` returns all currently matching eligible rules. They are injected as a batch on the next retry message.\n\n### Between abort and continue\n\nDuring the timer window, state can change (user interruption, mode actions, additional events). The retry call is best-effort: `agent.continue().catch(() => {})` swallows follow-up errors.\n\n## 9. Edge cases summary\n\n- Invalid `ttsr_trigger` regex: skipped with warning; other rules continue.\n- Duplicate rule names at capability layer: lower-priority duplicates are shadowed before registration.\n- Duplicate names at manager layer: second registration is ignored.\n- `contextMode: \"keep\"`: partial violating output can remain in context before reminder retry.\n- Repeat-after-gap depends on turn count increments at `turn_end`; mid-turn chunks do not advance gap counters.\n",
54
- "tui/theme.md": "---\ntitle: Theming Reference\nsidebar:\n order: 3\n label: Theming\n---\n\n# Theming Reference\n\nThis document describes how theming works in the coding-agent today: schema, loading, runtime behavior, and failure modes.\n\n## What the theme system controls\n\nThe theme system drives:\n\n- foreground/background color tokens used across the TUI\n- markdown styling adapters (`getMarkdownTheme()`)\n- selector/editor/settings list adapters (`getSelectListTheme()`, `getEditorTheme()`, `getSettingsListTheme()`)\n- symbol preset + symbol overrides (`unicode`, `nerd`, `ascii`)\n- syntax highlighting colors used by native highlighter (`@f5xc-salesdemos/pi-natives`)\n- status line segment colors\n\nPrimary implementation: `src/modes/theme/theme.ts`.\n\n## Theme JSON shape\n\nTheme files are JSON objects validated against the runtime schema in `theme.ts` (`ThemeJsonSchema`) and mirrored by `src/modes/theme/theme-schema.json`.\n\nTop-level fields:\n\n- `name` (required)\n- `colors` (required; all color tokens required)\n- `vars` (optional; reusable color variables)\n- `export` (optional; HTML export colors)\n- `symbols` (optional)\n - `preset` (optional: `unicode | nerd | ascii`)\n - `overrides` (optional: key/value overrides for `SymbolKey`)\n\nColor values accept:\n\n- hex string (`\"#RRGGBB\"`)\n- 256-color index (`0..255`)\n- variable reference string (resolved through `vars`)\n- empty string (`\"\"`) meaning terminal default (`\\x1b[39m` fg, `\\x1b[49m` bg)\n\n## Required color tokens (current)\n\nAll tokens below are required in `colors`.\n\n### Core text and borders (11)\n\n`accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`\n\n### Background blocks (7)\n\n`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`, `statusLineBg`\n\n### Message/tool text (5)\n\n`userMessageText`, `customMessageText`, `customMessageLabel`, `toolTitle`, `toolOutput`\n\n### Markdown (10)\n\n`mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet`\n\n### Tool diff + syntax highlighting (12)\n\n`toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`,\n`syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`\n\n### Mode/thinking borders (8)\n\n`thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `bashMode`, `pythonMode`\n\n### Status line segment colors (14)\n\n`statusLineSep`, `statusLineModel`, `statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`, `statusLineContext`, `statusLineSpend`, `statusLineStaged`, `statusLineDirty`, `statusLineUntracked`, `statusLineOutput`, `statusLineCost`, `statusLineSubagents`\n\n## Optional tokens\n\n### `export` section (optional)\n\nUsed for HTML export theming helpers:\n\n- `export.pageBg`\n- `export.cardBg`\n- `export.infoBg`\n\nIf omitted, export code derives defaults from resolved theme colors.\n\n### `symbols` section (optional)\n\n- `symbols.preset` sets a theme-level default symbol set.\n- `symbols.overrides` can override individual `SymbolKey` values.\n\nRuntime precedence:\n\n1. settings `symbolPreset` override (if set)\n2. theme JSON `symbols.preset`\n3. fallback `\"unicode\"`\n\nInvalid override keys are ignored and logged (`logger.debug`).\n\n## Built-in vs custom theme sources\n\nTheme lookup order (`loadThemeJson`):\n\n1. built-in embedded themes (`defaults/xcsh-dark.json` and `defaults/xcsh-light.json` compiled into `defaultThemes`)\n2. custom theme file: `<customThemesDir>/<name>.json`\n\nCustom themes directory comes from `getCustomThemesDir()`:\n\n- default: `~/.xcsh/agent/themes`\n- overridden by `PI_CODING_AGENT_DIR` (`$PI_CODING_AGENT_DIR/themes`)\n\n`getAvailableThemes()` returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.\n\n## Loading, validation, and resolution\n\nFor custom theme files:\n\n1. read JSON\n2. parse JSON\n3. validate against `ThemeJsonSchema`\n4. resolve `vars` references recursively\n5. convert resolved values to ANSI by terminal capability mode\n\nValidation behavior:\n\n- missing required color tokens: explicit grouped error message\n- bad token types/values: validation errors with JSON path\n- unknown theme file: `Theme not found: <name>`\n\nVar reference behavior:\n\n- supports nested references\n- throws on missing variable reference\n- throws on circular references\n\n## Terminal color mode behavior\n\nColor mode detection (`detectColorMode`):\n\n- `COLORTERM=truecolor|24bit` => truecolor\n- `WT_SESSION` => truecolor\n- `TERM` in `dumb`, `linux`, or empty => 256color\n- otherwise => truecolor\n\nConversion behavior:\n\n- hex -> `Bun.color(..., \"ansi-16m\" | \"ansi-256\")`\n- numeric -> `38;5` / `48;5` ANSI\n- `\"\"` -> default fg/bg reset\n\n## Runtime switching behavior\n\n### Initial theme (`initTheme`)\n\n`main.ts` initializes theme with settings:\n\n- `symbolPreset`\n- `colorBlindMode`\n- `theme.dark`\n- `theme.light`\n\nAuto theme slot selection uses `COLORFGBG` background detection:\n\n- parse background index from `COLORFGBG`\n- `< 8` => dark slot (`theme.dark`)\n- `>= 8` => light slot (`theme.light`)\n- parse failure => dark slot\n\nCurrent defaults from settings schema:\n\n- `theme.dark = \"xcsh-dark\"`\n- `theme.light = \"xcsh-light\"`\n- `symbolPreset = \"unicode\"`\n- `colorBlindMode = false`\n\n### Explicit switching (`setTheme`)\n\n- loads selected theme\n- updates global `theme` singleton\n- optionally starts watcher\n- triggers `onThemeChange` callback\n\nOn failure:\n\n- falls back to built-in `dark`\n- returns `{ success: false, error }`\n\n### Preview switching (`previewTheme`)\n\n- applies temporary preview theme to global `theme`\n- does **not** change persisted settings by itself\n- returns success/error without fallback replacement\n\nSettings UI uses this for live preview and restores prior theme on cancel.\n\n## Watchers and live reload\n\nWhen watcher is enabled (`setTheme(..., true)` / interactive init):\n\n- only watches custom file path `<customThemesDir>/<currentTheme>.json`\n- built-ins are effectively not watched\n- file `change`: attempts reload (debounced)\n- file `rename`/delete: falls back to `dark`, closes watcher\n\nAuto mode also installs a `SIGWINCH` listener and can re-evaluate dark/light slot mapping when terminal state changes.\n\n## Color-blind mode behavior\n\n`colorBlindMode` changes only one token at runtime:\n\n- `toolDiffAdded` is HSV-adjusted (green shifted toward blue)\n- adjustment is applied only when resolved value is a hex string\n\nOther tokens are unchanged.\n\n## Where theme settings are persisted\n\nTheme-related settings are persisted by `Settings` to global config YAML:\n\n- path: `<agentDir>/config.yml`\n- default agent dir: `~/.xcsh/agent`\n- effective default file: `~/.xcsh/agent/config.yml`\n\nPersisted keys:\n\n- `theme.dark`\n- `theme.light`\n- `symbolPreset`\n- `colorBlindMode`\n\nLegacy migration exists: old flat `theme: \"name\"` is migrated to nested `theme.dark` or `theme.light` based on luminance detection.\n\n## Creating a custom theme (practical)\n\n1. Create file in custom themes dir, e.g. `~/.xcsh/agent/themes/my-theme.json`.\n2. Include `name`, optional `vars`, and **all required** `colors` tokens.\n3. Optionally include `symbols` and `export`.\n4. Select the theme in Settings (`Display -> Dark theme` or `Display -> Light theme`) depending on which auto slot you want.\n\nMinimal skeleton:\n\n```json\n{\n \"name\": \"my-theme\",\n \"vars\": {\n \"accent\": \"#7aa2f7\",\n \"muted\": 244\n },\n \"colors\": {\n \"accent\": \"accent\",\n \"border\": \"#4c566a\",\n \"borderAccent\": \"accent\",\n \"borderMuted\": \"muted\",\n \"success\": \"#9ece6a\",\n \"error\": \"#f7768e\",\n \"warning\": \"#e0af68\",\n \"muted\": \"muted\",\n \"dim\": 240,\n \"text\": \"\",\n \"thinkingText\": \"muted\",\n\n \"selectedBg\": \"#2a2f45\",\n \"userMessageBg\": \"#1f2335\",\n \"userMessageText\": \"\",\n \"customMessageBg\": \"#24283b\",\n \"customMessageText\": \"\",\n \"customMessageLabel\": \"accent\",\n \"toolPendingBg\": \"#1f2335\",\n \"toolSuccessBg\": \"#1f2d2a\",\n \"toolErrorBg\": \"#2d1f2a\",\n \"toolTitle\": \"\",\n \"toolOutput\": \"muted\",\n\n \"mdHeading\": \"accent\",\n \"mdLink\": \"accent\",\n \"mdLinkUrl\": \"muted\",\n \"mdCode\": \"#c0caf5\",\n \"mdCodeBlock\": \"#c0caf5\",\n \"mdCodeBlockBorder\": \"muted\",\n \"mdQuote\": \"muted\",\n \"mdQuoteBorder\": \"muted\",\n \"mdHr\": \"muted\",\n \"mdListBullet\": \"accent\",\n\n \"toolDiffAdded\": \"#9ece6a\",\n \"toolDiffRemoved\": \"#f7768e\",\n \"toolDiffContext\": \"muted\",\n\n \"syntaxComment\": \"#565f89\",\n \"syntaxKeyword\": \"#bb9af7\",\n \"syntaxFunction\": \"#7aa2f7\",\n \"syntaxVariable\": \"#c0caf5\",\n \"syntaxString\": \"#9ece6a\",\n \"syntaxNumber\": \"#ff9e64\",\n \"syntaxType\": \"#2ac3de\",\n \"syntaxOperator\": \"#89ddff\",\n \"syntaxPunctuation\": \"#9aa5ce\",\n\n \"thinkingOff\": 240,\n \"thinkingMinimal\": 244,\n \"thinkingLow\": \"#7aa2f7\",\n \"thinkingMedium\": \"#2ac3de\",\n \"thinkingHigh\": \"#bb9af7\",\n \"thinkingXhigh\": \"#f7768e\",\n\n \"bashMode\": \"#2ac3de\",\n \"pythonMode\": \"#bb9af7\",\n\n \"statusLineBg\": \"#16161e\",\n \"statusLineSep\": 240,\n \"statusLineModel\": \"#bb9af7\",\n \"statusLinePath\": \"#7aa2f7\",\n \"statusLineGitClean\": \"#9ece6a\",\n \"statusLineGitDirty\": \"#e0af68\",\n \"statusLineContext\": \"#2ac3de\",\n \"statusLineSpend\": \"#7dcfff\",\n \"statusLineStaged\": \"#9ece6a\",\n \"statusLineDirty\": \"#e0af68\",\n \"statusLineUntracked\": \"#f7768e\",\n \"statusLineOutput\": \"#c0caf5\",\n \"statusLineCost\": \"#ff9e64\",\n \"statusLineSubagents\": \"#bb9af7\"\n }\n}\n```\n\n## Testing custom themes\n\nUse this workflow:\n\n1. Start interactive mode (watcher enabled from startup).\n2. Open settings and preview theme values (live `previewTheme`).\n3. For custom theme files, edit the JSON while running and confirm auto-reload on save.\n4. Exercise critical surfaces:\n - markdown rendering\n - tool blocks (pending/success/error)\n - diff rendering (added/removed/context)\n - status line readability\n - thinking level border changes\n - bash/python mode border colors\n5. Validate both symbol presets if your theme depends on glyph width/appearance.\n\n## Real constraints and caveats\n\n- All `colors` tokens are required for custom themes.\n- `export` and `symbols` are optional.\n- `$schema` in theme JSON is informational; runtime validation is enforced by compiled TypeBox schema in code.\n- `setTheme` failure falls back to `dark`; `previewTheme` failure does not replace current theme.\n- File watcher reload errors keep the current loaded theme until a successful reload or fallback path is triggered.\n",
54
+ "tui/theme.md": "---\ntitle: Theming Reference\nsidebar:\n order: 3\n label: Theming\n---\n\n# Theming Reference\n\nThis document describes how theming works in the coding-agent today: schema, loading, runtime behavior, and failure modes.\n\n## What the theme system controls\n\nThe theme system drives:\n\n- foreground/background color tokens used across the TUI\n- markdown styling adapters (`getMarkdownTheme()`)\n- selector/editor/settings list adapters (`getSelectListTheme()`, `getEditorTheme()`, `getSettingsListTheme()`)\n- symbol preset + symbol overrides (`unicode`, `nerd`, `ascii`)\n- syntax highlighting colors used by native highlighter (`@f5xc-salesdemos/pi-natives`)\n- status line segment colors\n\nPrimary implementation: `src/modes/theme/theme.ts`.\n\n## Theme JSON shape\n\nTheme files are JSON objects validated against the runtime schema in `theme.ts` (`ThemeJsonSchema`) and mirrored by `src/modes/theme/theme-schema.json`.\n\nTop-level fields:\n\n- `name` (required)\n- `colors` (required; all color tokens required)\n- `vars` (optional; reusable color variables)\n- `export` (optional; HTML export colors)\n- `symbols` (optional)\n - `preset` (optional: `unicode | nerd | ascii`)\n - `overrides` (optional: key/value overrides for `SymbolKey`)\n\nColor values accept:\n\n- hex string (`\"#RRGGBB\"`)\n- 256-color index (`0..255`)\n- variable reference string (resolved through `vars`)\n- empty string (`\"\"`) meaning terminal default (`\\x1b[39m` fg, `\\x1b[49m` bg)\n\n## Required color tokens (current)\n\nAll tokens below are required in `colors`.\n\n### Core text and borders (11)\n\n`accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`\n\n### Background blocks (7)\n\n`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`, `statusLineBg`\n\n### Message/tool text (5)\n\n`userMessageText`, `customMessageText`, `customMessageLabel`, `toolTitle`, `toolOutput`\n\n### Markdown (10)\n\n`mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet`\n\n### Tool diff + syntax highlighting (12)\n\n`toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`,\n`syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`\n\n### Mode/thinking borders (8)\n\n`thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `bashMode`, `pythonMode`\n\n### Status line segment colors (14)\n\n`statusLineSep`, `statusLineModel`, `statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`, `statusLineContext`, `statusLineSpend`, `statusLineStaged`, `statusLineDirty`, `statusLineUntracked`, `statusLineOutput`, `statusLineCost`, `statusLineSubagents`\n\n## Optional tokens\n\n### `export` section (optional)\n\nUsed for HTML export theming helpers:\n\n- `export.pageBg`\n- `export.cardBg`\n- `export.infoBg`\n\nIf omitted, export code derives defaults from resolved theme colors.\n\n### `symbols` section (optional)\n\n- `symbols.preset` sets a theme-level default symbol set.\n- `symbols.overrides` can override individual `SymbolKey` values.\n\nRuntime precedence:\n\n1. settings `symbolPreset` override (if set)\n2. theme JSON `symbols.preset`\n3. fallback `\"unicode\"`\n\nInvalid override keys are ignored and logged (`logger.debug`).\n\n## Built-in vs custom theme sources\n\nTheme lookup order (`loadThemeJson`):\n\n1. built-in embedded themes (`defaults/xcsh-dark.json` and `defaults/xcsh-light.json` compiled into `defaultThemes`)\n2. custom theme file: `<customThemesDir>/<name>.json`\n\nCustom themes directory comes from `getCustomThemesDir()`:\n\n- default: `~/.xcsh/agent/themes`\n- overridden by `PI_CODING_AGENT_DIR` (`$PI_CODING_AGENT_DIR/themes`)\n\n`getAvailableThemes()` returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.\n\n## Loading, validation, and resolution\n\nFor custom theme files:\n\n1. read JSON\n2. parse JSON\n3. validate against `ThemeJsonSchema`\n4. resolve `vars` references recursively\n5. convert resolved values to ANSI by terminal capability mode\n\nValidation behavior:\n\n- missing required color tokens: explicit grouped error message\n- bad token types/values: validation errors with JSON path\n- unknown theme file: `Theme not found: <name>`\n\nVar reference behavior:\n\n- supports nested references\n- throws on missing variable reference\n- throws on circular references\n\n## Terminal color mode behavior\n\nColor mode detection (`detectColorMode`):\n\n- `COLORTERM=truecolor|24bit` => truecolor\n- `WT_SESSION` => truecolor\n- `TERM` in `dumb`, `linux`, or empty => 256color\n- otherwise => truecolor\n\nConversion behavior:\n\n- hex -> `Bun.color(..., \"ansi-16m\" | \"ansi-256\")`\n- numeric -> `38;5` / `48;5` ANSI\n- `\"\"` -> default fg/bg reset\n\n## Runtime switching behavior\n\n### Initial theme (`initTheme`)\n\n`main.ts` initializes theme with settings:\n\n- `symbolPreset`\n- `colorBlindMode`\n- `theme.dark`\n- `theme.light`\n\nAuto theme slot selection uses `COLORFGBG` background detection:\n\n- parse background index from `COLORFGBG`\n- `< 8` => dark slot (`theme.dark`)\n- `>= 8` => light slot (`theme.light`)\n- parse failure => dark slot\n\nCurrent defaults from settings schema:\n\n- `theme.dark = \"xcsh-dark\"`\n- `theme.light = \"xcsh-light\"`\n- `symbolPreset = \"unicode\"`\n- `colorBlindMode = false`\n\n### Explicit switching (`setTheme`)\n\n- loads selected theme\n- updates global `theme` singleton\n- optionally starts watcher\n- triggers `onThemeChange` callback\n\nOn failure:\n\n- falls back to built-in `dark`\n- returns `{ success: false, error }`\n\n### Preview switching (`previewTheme`)\n\n- applies temporary preview theme to global `theme`\n- does **not** change persisted settings by itself\n- returns success/error without fallback replacement\n\nSettings UI uses this for live preview and restores prior theme on cancel.\n\n## Watchers and live reload\n\nWhen watcher is enabled (`setTheme(..., true)` / interactive init):\n\n- only watches custom file path `<customThemesDir>/<currentTheme>.json`\n- built-ins are effectively not watched\n- file `change`: attempts reload (debounced)\n- file `rename`/delete: falls back to `dark`, closes watcher\n\nAuto mode also installs a `SIGWINCH` listener and can re-evaluate dark/light slot mapping when terminal state changes.\n\n## Color-blind mode behavior\n\n`colorBlindMode` changes only one token at runtime:\n\n- `toolDiffAdded` is HSV-adjusted (green shifted toward blue)\n- adjustment is applied only when resolved value is a hex string\n\nOther tokens are unchanged.\n\n## Where theme settings are persisted\n\nTheme-related settings are persisted by `Settings` to global config YAML:\n\n- path: `<agentDir>/config.yml`\n- default agent dir: `~/.xcsh/agent`\n- effective default file: `~/.xcsh/agent/config.yml`\n\nPersisted keys:\n\n- `theme.dark`\n- `theme.light`\n- `symbolPreset`\n- `colorBlindMode`\n\nLegacy migration exists: old flat `theme: \"name\"` is migrated to nested `theme.dark` or `theme.light` based on luminance detection.\n\n## Creating a custom theme (practical)\n\n1. Create file in custom themes dir, e.g. `~/.xcsh/agent/themes/my-theme.json`.\n2. Include `name`, optional `vars`, and **all required** `colors` tokens.\n3. Optionally include `symbols` and `export`.\n4. Select the theme in Settings (`Display -> Dark theme` or `Display -> Light theme`) depending on which auto slot you want.\n\nMinimal skeleton. Every key in `colors` is required — the runtime validator\n(`additionalProperties: false`) rejects both missing keys and unknown keys.\nFor the shipped reference implementations see\n[`packages/coding-agent/src/modes/theme/defaults/xcsh-dark.json`](../../packages/coding-agent/src/modes/theme/defaults/xcsh-dark.json)\nand [`xcsh-light.json`](../../packages/coding-agent/src/modes/theme/defaults/xcsh-light.json).\n\nThe status line has two parallel color systems documented in issue #242:\n\n- Hex text colors (`statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`,\n `statusLineStaged`, `statusLineDirty`, `statusLineUntracked`) drive non-powerline\n rendering.\n- 256-color palette indices (`statusLine<Segment>Bg` / `statusLine<Segment>Fg`)\n drive powerline segment fills. They are independent of the hex keys above —\n both must be set.\n\n```json\n{\n \"name\": \"my-theme\",\n \"vars\": {\n \"accent\": \"#7aa2f7\",\n \"muted\": 244\n },\n \"colors\": {\n \"accent\": \"accent\",\n \"chromeAccent\": \"accent\",\n \"spinnerAccent\": \"accent\",\n \"contentAccent\": \"muted\",\n \"border\": \"#4c566a\",\n \"borderAccent\": \"accent\",\n \"borderMuted\": \"muted\",\n \"success\": \"#9ece6a\",\n \"error\": \"#f7768e\",\n \"warning\": \"#e0af68\",\n \"muted\": \"muted\",\n \"dim\": 240,\n \"gutterSuccess\": \"#7dcfff\",\n \"gutterWarning\": \"#e0af68\",\n \"text\": \"\",\n \"thinkingText\": \"muted\",\n\n \"selectedBg\": \"#2a2f45\",\n \"userMessageBg\": \"#1f2335\",\n \"userMessageText\": \"\",\n \"customMessageBg\": \"#24283b\",\n \"customMessageText\": \"\",\n \"customMessageLabel\": \"accent\",\n \"toolPendingBg\": \"#1f2335\",\n \"toolSuccessBg\": \"#1f2d2a\",\n \"toolErrorBg\": \"#2d1f2a\",\n \"toolTitle\": \"\",\n \"toolOutput\": \"muted\",\n\n \"mdHeading\": \"accent\",\n \"mdLink\": \"accent\",\n \"mdLinkUrl\": \"muted\",\n \"mdCode\": \"#c0caf5\",\n \"mdCodeBlock\": \"#c0caf5\",\n \"mdCodeBlockBorder\": \"muted\",\n \"mdQuote\": \"muted\",\n \"mdQuoteBorder\": \"muted\",\n \"mdHr\": \"muted\",\n \"mdListBullet\": \"accent\",\n\n \"toolDiffAdded\": \"#9ece6a\",\n \"toolDiffRemoved\": \"#f7768e\",\n \"toolDiffContext\": \"muted\",\n\n \"syntaxComment\": \"#565f89\",\n \"syntaxKeyword\": \"#bb9af7\",\n \"syntaxFunction\": \"#7aa2f7\",\n \"syntaxVariable\": \"#c0caf5\",\n \"syntaxString\": \"#9ece6a\",\n \"syntaxNumber\": \"#ff9e64\",\n \"syntaxType\": \"#2ac3de\",\n \"syntaxOperator\": \"#89ddff\",\n \"syntaxPunctuation\": \"#9aa5ce\",\n \"syntaxControl\": \"#bb9af7\",\n\n \"thinkingOff\": 240,\n \"thinkingMinimal\": 244,\n \"thinkingLow\": \"#7aa2f7\",\n \"thinkingMedium\": \"#2ac3de\",\n \"thinkingHigh\": \"#bb9af7\",\n \"thinkingXhigh\": \"#f7768e\",\n\n \"bashMode\": \"#2ac3de\",\n \"pythonMode\": \"#bb9af7\",\n\n \"statusLineBg\": \"#16161e\",\n \"statusLineSep\": 240,\n \"statusLineModel\": \"#bb9af7\",\n \"statusLinePath\": \"#7aa2f7\",\n \"statusLineGitClean\": \"#9ece6a\",\n \"statusLineGitDirty\": \"#e0af68\",\n \"statusLineContext\": \"#2ac3de\",\n \"statusLineSpend\": \"#7dcfff\",\n \"statusLineStaged\": \"#9ece6a\",\n \"statusLineDirty\": \"#e0af68\",\n \"statusLineUntracked\": \"#f7768e\",\n \"statusLineOutput\": \"#c0caf5\",\n \"statusLineCost\": \"#ff9e64\",\n \"statusLineSubagents\": \"#bb9af7\",\n\n \"statusLineOsIconBg\": 7,\n \"statusLineOsIconFg\": 232,\n \"statusLinePathBg\": 4,\n \"statusLinePathFg\": 254,\n \"statusLineGitCleanBg\": 2,\n \"statusLineGitCleanFg\": 0,\n \"statusLineGitDirtyBg\": 3,\n \"statusLineGitDirtyFg\": 0,\n \"statusLineGitStagedBg\": 64,\n \"statusLineGitStagedFg\": 0,\n \"statusLineGitUntrackedBg\": 39,\n \"statusLineGitUntrackedFg\": 0,\n \"statusLineGitConflictBg\": 1,\n \"statusLineGitConflictFg\": 7,\n \"statusLinePlanModeBg\": 236,\n \"statusLinePlanModeFg\": 117,\n \"statusLineContextPctBg\": 238,\n \"statusLineContextPctFg\": 255,\n \"statusLineContextPctNormalBg\": 17,\n \"statusLineContextPctWarningBg\": 22,\n \"statusLineContextPctPurpleBg\": 94,\n \"statusLineContextPctErrorBg\": 88,\n \"statusLineProfileF5xcBg\": \"accent\",\n \"statusLineProfileF5xcFg\": 231\n }\n}\n```\n\n## Testing custom themes\n\nUse this workflow:\n\n1. Start interactive mode (watcher enabled from startup).\n2. Open settings and preview theme values (live `previewTheme`).\n3. For custom theme files, edit the JSON while running and confirm auto-reload on save.\n4. Exercise critical surfaces:\n - markdown rendering\n - tool blocks (pending/success/error)\n - diff rendering (added/removed/context)\n - status line readability\n - thinking level border changes\n - bash/python mode border colors\n5. Validate both symbol presets if your theme depends on glyph width/appearance.\n\n## Real constraints and caveats\n\n- All `colors` tokens are required for custom themes.\n- `export` and `symbols` are optional.\n- `$schema` in theme JSON is informational; runtime validation is enforced by compiled TypeBox schema in code.\n- `setTheme` failure falls back to `dark`; `previewTheme` failure does not replace current theme.\n- File watcher reload errors keep the current loaded theme until a successful reload or fallback path is triggered.\n",
55
55
  "tui/tree.md": "---\ntitle: Tree Command Reference\nsidebar:\n order: 4\n label: /tree command\n---\n\n# `/tree` Command Reference\n\n`/tree` opens the interactive **Session Tree** navigator. It lets you jump to any entry in the current session file and continue from that point.\n\nThis is an in-file leaf move, not a new session export.\n\n## What `/tree` does\n\n- Builds a tree from current session entries (`SessionManager.getTree()`)\n- Opens `TreeSelectorComponent` with keyboard navigation, filters, and search\n- On selection, calls `AgentSession.navigateTree(targetId, { summarize, customInstructions })`\n- Rebuilds visible chat from the new leaf path\n- Optionally prefills editor text when selecting a user/custom message\n\nPrimary implementation:\n\n- `src/modes/controllers/input-controller.ts` (`/tree`, keybinding wiring, double-escape behavior)\n- `src/modes/controllers/selector-controller.ts` (tree UI launch + summary prompt flow)\n- `src/modes/components/tree-selector.ts` (navigation, filters, search, labels, rendering)\n- `src/session/agent-session.ts` (`navigateTree` leaf switching + optional summary)\n- `src/session/session-manager.ts` (`getTree`, `branch`, `branchWithSummary`, `resetLeaf`, label persistence)\n\n## How to open it\n\nAny of the following opens the same selector:\n\n- `/tree`\n- configured keybinding action `tree`\n- double-escape on empty editor when `doubleEscapeAction = \"tree\"` (default)\n- `/branch` when `doubleEscapeAction = \"tree\"` (routes to tree selector instead of user-only branch picker)\n\n## Tree UI model\n\nThe tree is rendered from session entry parent pointers (`id` / `parentId`).\n\n- Children are sorted by timestamp ascending (older first, newer lower)\n- Active branch (path from root to current leaf) is marked with a bullet\n- Labels (if present) render as `[label]` before node text\n- If multiple roots exist (orphaned/broken parent chains), they are shown under a virtual branching root\n\n```text\nExample tree view (active path marked with •):\n\n├─ user: \"Start task\"\n│ └─ assistant: \"Plan\"\n│ ├─ • user: \"Try approach A\"\n│ │ └─ • assistant: \"A result\"\n│ │ └─ • [milestone] user: \"Continue A\"\n│ └─ user: \"Try approach B\"\n│ └─ assistant: \"B result\"\n```\n\nThe selector recenters around current selection and shows up to:\n\n- `max(5, floor(terminalHeight / 2))` rows\n\n## Keybindings inside tree selector\n\n- `Up` / `Down`: move selection (wraps)\n- `Left` / `Right`: page up / page down\n- `Enter`: select node\n- `Esc`: clear search if active; otherwise close selector\n- `Ctrl+C`: close selector\n- `Type`: append to search query\n- `Backspace`: delete search character\n- `Shift+L`: edit/clear label on selected entry\n- `Ctrl+O`: cycle filter forward\n- `Shift+Ctrl+O`: cycle filter backward\n- `Alt+D/T/U/L/A`: jump directly to specific filter mode\n\n## Filters and search semantics\n\nFilter modes (`TreeList`):\n\n1. `default`\n2. `no-tools`\n3. `user-only`\n4. `labeled-only`\n5. `all`\n\n### `default`\n\nShows most conversational nodes, but hides bookkeeping entry types:\n\n- `label`\n- `custom`\n- `model_change`\n- `thinking_level_change`\n\n### `no-tools`\n\nSame as `default`, plus hides `toolResult` messages.\n\n### `user-only`\n\nOnly `message` entries where role is `user`.\n\n### `labeled-only`\n\nOnly entries that currently resolve to a label.\n\n### `all`\n\nEverything in the session tree, including bookkeeping/custom entries.\n\n### Tool-only assistant node behavior\n\nAssistant messages that contain **only tool calls** (no text) are hidden by default in all filtered views unless:\n\n- message is error/aborted (`stopReason` not `stop`/`toolUse`), or\n- it is the current leaf (always kept visible)\n\n### Search behavior\n\n- Query is tokenized by spaces\n- Matching is case-insensitive\n- All tokens must match (AND semantics)\n- Searchable text includes label, role, and type-specific content (message text, branch summary text, custom type, tool command snippets, etc.)\n\n## Selection outcomes (important)\n\n`navigateTree` computes new leaf behavior from selected entry type:\n\n### Selecting `user` message\n\n- New leaf becomes selected entry’s `parentId`\n- If parent is `null` (root user message), leaf resets to root (`resetLeaf()`)\n- Selected message text is copied to editor for editing/resubmit\n\n### Selecting `custom_message`\n\n- Same leaf rule as user messages (`parentId`)\n- Text content is extracted and copied to editor\n\n### Selecting non-user node (assistant/tool/summary/compaction/custom bookkeeping/etc.)\n\n- New leaf becomes selected node id\n- Editor is not prefilled\n\n### Selecting current leaf\n\n- No-op; selector closes with “Already at this point”\n\n```text\nSelection decision (simplified):\n\nselected node\n │\n ├─ is current leaf? ── yes ──> close selector (no-op)\n │\n ├─ is user/custom_message? ── yes ──> leaf := parentId (or resetLeaf for root)\n │ + prefill editor text\n │\n └─ otherwise ──> leaf := selected node id\n + no editor prefill\n```\n\n## Summary-on-switch flow\n\nSummary prompt is controlled by `branchSummary.enabled` (default: `false`).\n\nWhen enabled, after picking a node the UI asks:\n\n- `No summary`\n- `Summarize`\n- `Summarize with custom prompt`\n\nFlow details:\n\n- Escape in summary prompt reopens tree selector\n- Custom prompt cancellation returns to summary choice loop\n- During summarization, UI shows loader and binds `Esc` to `abortBranchSummary()`\n- If summarization aborts, tree selector reopens and no move is applied\n\n`navigateTree` internals:\n\n- Collects abandoned-branch entries from old leaf to common ancestor\n- Emits `session_before_tree` (extensions can cancel or inject summary)\n- Uses default summarizer only if requested and needed\n- Applies move with:\n - `branchWithSummary(...)` when summary exists\n - `branch(newLeafId)` for non-root move without summary\n - `resetLeaf()` for root move without summary\n- Replaces agent conversation with rebuilt session context\n- Emits `session_tree`\n\nNote: if user requests summary but there is nothing to summarize, navigation proceeds without creating a summary entry.\n\n## Labels\n\nLabel edits in tree UI call `appendLabelChange(targetId, label)`.\n\n- non-empty label sets/updates resolved label\n- empty label clears it\n- labels are stored as append-only `label` entries\n- tree nodes display resolved label state, not raw label-entry history\n\n## `/tree` vs adjacent operations\n\n| Operation | Scope | Result |\n|---|---|---|\n| `/tree` | Current session file | Moves leaf to selected point (same file) |\n| `/branch` | Usually current session file -> new session file | By default branches from selected **user** message into a new session file; if `doubleEscapeAction = \"tree\"`, `/branch` opens tree navigation UI instead |\n| `/fork` | Whole current session | Duplicates session into a new persisted session file |\n| `/resume` | Session list | Switches to another session file |\n\nKey distinction: `/tree` is a navigation/repositioning tool inside one session file. `/branch`, `/fork`, and `/resume` all change session-file context.\n\n## Operator workflows\n\n### Re-run from an earlier user prompt without losing current branch\n\n1. `/tree`\n2. search/select earlier user message\n3. choose `No summary` (or summarize if needed)\n4. edit prefilled text in editor\n5. submit\n\nEffect: new branch grows from selected point within same session file.\n\n### Leave current branch with context breadcrumb\n\n1. enable `branchSummary.enabled`\n2. `/tree` and select target node\n3. choose `Summarize` (or custom prompt)\n\nEffect: a `branch_summary` entry is appended at the target position before continuing.\n\n### Investigate hidden bookkeeping entries\n\n1. `/tree`\n2. press `Alt+A` (all)\n3. search for `model`, `thinking`, `custom`, or labels\n\nEffect: inspect full internal timeline, not just conversational nodes.\n\n### Bookmark pivot points for later jumps\n\n1. `/tree`\n2. move to entry\n3. `Shift+L` and set label\n4. later use `Alt+L` (`labeled-only`) to jump quickly\n\nEffect: fast navigation among durable branch landmarks.\n",
56
56
  "tui/tui-runtime-internals.md": "---\ntitle: TUI Runtime Internals\nsidebar:\n order: 2\n label: Runtime internals\n---\n\n# TUI runtime internals\n\nThis document maps the non-theme runtime path from terminal input to rendered output in interactive mode. It focuses on behavior in `packages/tui` and its integration from `packages/coding-agent` controllers.\n\n## Runtime layers and ownership\n\n- **`packages/tui` engine**: terminal lifecycle, stdin normalization, focus routing, render scheduling, differential painting, overlay composition, hardware cursor placement.\n- **`packages/coding-agent` interactive mode**: builds component tree, binds editor callbacks and keymaps, reacts to agent/session events, and translates domain state (streaming, tool execution, retries, plan mode) into UI components.\n\nBoundary rule: the TUI engine is message-agnostic. It only knows `Component.render(width)`, `handleInput(data)`, focus, and overlays. Agent semantics stay in interactive controllers.\n\n## Implementation files\n\n- [`../src/modes/interactive-mode.ts`](../../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`../src/modes/controllers/event-controller.ts`](../../packages/coding-agent/src/modes/controllers/event-controller.ts)\n- [`../src/modes/controllers/input-controller.ts`](../../packages/coding-agent/src/modes/controllers/input-controller.ts)\n- [`../src/modes/components/custom-editor.ts`](../../packages/coding-agent/src/modes/components/custom-editor.ts)\n- [`../../tui/src/tui.ts`](../../packages/tui/src/tui.ts)\n- [`../../tui/src/terminal.ts`](../../packages/tui/src/terminal.ts)\n- [`../../tui/src/editor-component.ts`](../../packages/tui/src/editor-component.ts)\n- [`../../tui/src/stdin-buffer.ts`](../../packages/tui/src/stdin-buffer.ts)\n- [`../../tui/src/components/loader.ts`](../../packages/tui/src/components/loader.ts)\n\n## Boot and component tree assembly\n\n`InteractiveMode` constructs `TUI(new ProcessTerminal(), showHardwareCursor)` and creates persistent containers:\n\n- `chatContainer`\n- `pendingMessagesContainer`\n- `statusContainer`\n- `todoContainer`\n- `statusLine`\n- `editorContainer` (holds `CustomEditor`)\n\n`init()` wires the tree in that order, focuses the editor, registers input handlers via `InputController`, starts TUI, and requests a forced render.\n\nA forced render (`requestRender(true)`) resets previous-line caches and cursor bookkeeping before repainting.\n\n## Terminal lifecycle and stdin normalization\n\n`ProcessTerminal.start()`:\n\n1. Enables raw mode and bracketed paste.\n2. Attaches resize handler.\n3. Creates a `StdinBuffer` to split partial escape chunks into complete sequences.\n4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported.\n5. On Windows, attempts VT input enablement via `kernel32` mode flags.\n\n`StdinBuffer` behavior:\n\n- Buffers fragmented escape sequences (CSI/OSC/DCS/APC/SS3).\n- Emits `data` only when a sequence is complete or timeout-flushed.\n- Detects bracketed paste and emits a `paste` event with raw pasted text.\n\nThis prevents partial escape chunks from being misinterpreted as normal keypresses.\n\n## Input routing and focus model\n\nInput path:\n\n`stdin -> ProcessTerminal -> StdinBuffer -> TUI.#handleInput -> focusedComponent.handleInput`\n\nRouting details:\n\n1. TUI runs registered input listeners first (`addInputListener`), allowing consume/transform behavior.\n2. TUI handles global debug shortcut (`shift+ctrl+d`) before component dispatch.\n3. If focused component belongs to an overlay that is now hidden/invisible, TUI reassigns focus to next visible overlay or saved pre-overlay focus.\n4. Key release events are filtered unless focused component sets `wantsKeyRelease = true`.\n5. After dispatch, TUI schedules render.\n\n`setFocus()` also toggles `Focusable.focused`, which controls whether components emit `CURSOR_MARKER` for hardware cursor placement.\n\n## Key handling split: editor vs controller\n\n`CustomEditor` intercepts high-priority combos first (escape, ctrl-c/d/z, ctrl-v, ctrl-p variants, ctrl-t, alt-up, extension custom keys) and delegates the rest to base `Editor` behavior (text editing, history, autocomplete, cursor movement).\n\n`InputController.setupKeyHandlers()` then binds editor callbacks to mode actions:\n\n- cancellation / mode exits on `Escape`\n- shutdown on double `Ctrl+C` or empty-editor `Ctrl+D`\n- suspend/resume on `Ctrl+Z`\n- slash-command and selector hotkeys\n- follow-up/dequeue toggles and expansion toggles\n\nThis keeps key parsing/editor mechanics in `packages/tui` and mode semantics in coding-agent controllers.\n\n## Render loop and diffing strategy\n\n`TUI.requestRender()` is debounced to one render per tick using `process.nextTick`. Multiple state changes in the same turn coalesce.\n\n`#doRender()` pipeline:\n\n1. Render root component tree to `newLines`.\n2. Composite visible overlays (if any).\n3. Extract and strip `CURSOR_MARKER` from visible viewport lines.\n4. Append segment reset suffixes for non-image lines.\n5. Choose full repaint vs differential patch:\n - first frame\n - width change\n - shrink with `clearOnShrink` enabled and no overlays\n - edits above previous viewport\n6. For differential updates, patch only changed line range and clear stale trailing lines when needed.\n7. Reposition hardware cursor for IME support.\n\nRender writes use synchronized output mode (`CSI ? 2026 h/l`) to reduce flicker/tearing.\n\n## Render safety constraints\n\nCritical safety checks in `TUI`:\n\n- Non-image rendered lines must not exceed terminal width; overflow throws and writes crash diagnostics.\n- Overlay compositing includes defensive truncation and post-composite width verification.\n- Width changes force full redraw because wrapping semantics change.\n- Cursor position is clamped before movement.\n\nThese constraints are runtime enforcement, not just conventions.\n\n## Resize handling\n\nResize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.\n\nEffects:\n\n- Any width change triggers full redraw.\n- Viewport/top tracking (`#previousViewportTop`, `#maxLinesRendered`) avoids invalid relative cursor math when content or terminal size changes.\n- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.\n\n## Streaming and incremental UI updates\n\n`EventController` subscribes to `AgentSessionEvent` and updates UI incrementally:\n\n- `agent_start`: starts loader in `statusContainer`.\n- `message_start` assistant: creates `streamingComponent` and mounts it.\n- `message_update`: updates streaming assistant content; creates/updates tool execution components as tool calls appear.\n- `tool_execution_update/end`: updates tool result components and completion state.\n- `message_end`: finalizes assistant stream, handles aborted/error annotations, marks pending tool args complete on normal stop.\n- `agent_end`: stops loaders, clears transient stream state, flushes deferred model switch, issues completion notification if backgrounded.\n\nRead-tool grouping is intentionally stateful (`#lastReadGroup`) to coalesce consecutive read tool calls into one visual block until a non-read break occurs.\n\n## Status and loader orchestration\n\nStatus lane ownership:\n\n- `statusContainer` holds transient loaders (`loadingAnimation`, `autoCompactionLoader`, `retryLoader`).\n- `statusLine` renders persistent status/hooks/plan indicators and drives editor top border updates.\n\nLoader behavior:\n\n- `Loader` updates every 80ms via interval and requests render each frame.\n- Escape handlers are temporarily overridden during auto-compaction and auto-retry to cancel those operations.\n- On end/cancel paths, controllers restore prior escape handlers and stop/clear loader components.\n\n## Mode transitions and backgrounding\n\n### Bash/Python input modes\n\nInput text prefixes toggle editor border mode flags:\n\n- `!` -> bash mode\n- `$` (non-template literal prefix) -> python mode\n\nEscape exits inactive mode by clearing editor text and restoring border color; when execution is active, escape aborts the running task instead.\n\n### Plan mode\n\n`InteractiveMode` tracks plan mode flags, status-line state, active tools, and model switching. Enter/exit updates session mode entries and status/UI state, including deferred model switch if streaming is active.\n\n### Suspend/resume (`Ctrl+Z`)\n\n`InputController.handleCtrlZ()`:\n\n1. Registers one-shot `SIGCONT` handler to restart TUI and force render.\n2. Stops TUI before suspend.\n3. Sends `SIGTSTP` to process group.\n\n### Background mode (`/background` or `/bg`)\n\n`handleBackgroundCommand()`:\n\n- Rejects when idle.\n- Switches tool UI context to non-interactive (`hasUI=false`) so interactive UI tools fail fast.\n- Stops loaders/status line and unsubscribes foreground event handler.\n- Subscribes background event handler (primarily waits for `agent_end`).\n- Stops TUI and sends `SIGTSTP` (POSIX job control path).\n\nOn `agent_end` in background with no queued work, controller sends completion notification and shuts down.\n\n## Cancellation paths\n\nPrimary cancellation inputs:\n\n- `Escape` during active stream loader: restores queued messages to editor and aborts agent.\n- `Escape` during bash/python execution: aborts running command.\n- `Escape` during auto-compaction/retry: invokes dedicated abort methods through temporary escape handlers.\n- `Ctrl+C` single press: clear editor; double press within 500ms: shutdown.\n\nCancellation is state-conditional; same key can mean abort, mode-exit, selector trigger, or no-op depending on runtime state.\n\n## Event-driven vs throttled behavior\n\nEvent-driven updates:\n\n- Agent session events (`EventController`)\n- Key input callbacks (`InputController`)\n- terminal resize callback\n- theme/branch watchers in `InteractiveMode`\n\nThrottled/debounced paths:\n\n- TUI rendering is tick-debounced (`requestRender` coalescing).\n- Loader animation is fixed-interval (80ms), each frame requesting render.\n- Editor autocomplete updates (inside `Editor`) use debounce timers, reducing recompute churn during typing.\n\nThe runtime therefore mixes event-driven state transitions with bounded render cadence to keep interactivity responsive without repaint storms.\n",
57
57
  "tui/tui.md": "---\ntitle: TUI Integration for Extensions and Custom Tools\nsidebar:\n order: 1\n label: Extension integration\n---\n\n# TUI integration for extensions and custom tools\n\nThis document covers the **current** TUI contract used by `packages/coding-agent` and `packages/tui` for extension UI, custom tool UI, and custom renderers.\n\n## What this subsystem is\n\nThe runtime has two layers:\n\n- **Rendering engine (`packages/tui`)**: differential terminal renderer, input dispatch, focus, overlays, cursor placement.\n- **Integration layer (`packages/coding-agent`)**: mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.\n\n## Runtime behavior by mode\n\n| Mode | `ctx.ui.custom(...)` availability | Notes |\n| --- | --- | --- |\n| Interactive TUI | Supported | Component is mounted in the editor area, focused, and must call `done(result)` to resolve. |\n| Background/headless | Not interactive | UI context is no-op (`hasUI === false`). |\n| RPC mode | Not supported | `custom()` returns `Promise<never>` and does not mount TUI components. |\n\nIf your extension/tool can run in non-interactive mode, guard with `ctx.hasUI` / `pi.hasUI`.\n\n## Core component contract (`@f5xc-salesdemos/pi-tui`)\n\n`packages/tui/src/tui.ts` defines:\n\n```ts\nexport interface Component {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}\n```\n\n`Focusable` is separate:\n\n```ts\nexport interface Focusable {\n focused: boolean;\n}\n```\n\nCursor behavior uses `CURSOR_MARKER` (not `getCursorPosition`). Focused components emit the marker in rendered text; `TUI` extracts it and positions the hardware cursor.\n\n## Rendering constraints (terminal safety)\n\nYour `render(width)` output must be terminal-safe:\n\n1. **Never exceed `width` on any line**. The renderer throws if a non-image line overflows.\n2. **Measure visual width**, not string length: use `visibleWidth()`.\n3. **Truncate/wrap ANSI-aware text** with `truncateToWidth()` / `wrapTextWithAnsi()`.\n4. **Sanitize tabs/content** from external sources using `replaceTabs()` (and higher-level sanitizers in coding-agent render paths).\n\nMinimal pattern:\n\n```ts\nimport { replaceTabs, truncateToWidth } from \"@f5xc-salesdemos/pi-tui\";\n\nrender(width: number): string[] {\n return this.lines.map(line => truncateToWidth(replaceTabs(line), width));\n}\n```\n\n## Input handling and keybindings\n\n### Raw key matching\n\nUse `matchesKey(data, \"...\")` for navigation keys and combos.\n\n### Respect user-configured app keybindings\n\nExtension UI factories receive a `KeybindingsManager` (interactive mode) so you can honor mapped actions instead of hardcoding keys:\n\n```ts\nif (keybindings.matches(data, \"interrupt\")) {\n done(undefined);\n return;\n}\n```\n\n### Key release/repeat events\n\nKey release events are filtered unless your component sets:\n\n```ts\nwantsKeyRelease = true;\n```\n\nThen use `isKeyRelease()` / `isKeyRepeat()` if needed.\n\n## Focus, overlays, and cursor\n\n- `TUI.setFocus(component)` routes input to that component.\n- Overlay APIs exist in `TUI` (`showOverlay`, `OverlayHandle`), but extension `ctx.ui.custom` mounting in interactive mode currently replaces the editor component area directly.\n- The `custom(..., options?: { overlay?: boolean })` option exists in extension types; interactive extension mounting currently ignores this option.\n\n## Mount points and return contracts\n\n## 1) Extension UI (`ExtensionUIContext`)\n\nCurrent signature (`extensibility/extensions/types.ts`):\n\n```ts\ncustom<T>(\n factory: (\n tui: TUI,\n theme: Theme,\n keybindings: KeybindingsManager,\n done: (result: T) => void,\n ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,\n options?: { overlay?: boolean },\n): Promise<T>\n```\n\nBehavior in interactive mode (`extension-ui-controller.ts`):\n\n- Saves editor text.\n- Replaces editor component with your component.\n- Focuses your component.\n- On `done(result)`: calls `component.dispose?.()`, restores editor + text, focuses editor, resolves promise.\n\nSo `done(...)` is mandatory for completion.\n\n## 2) Hook/custom-tool UI context (legacy typing)\n\n`HookUIContext.custom` is typed as `(tui, theme, done)` in hook/custom-tool types.\nUnderlying interactive implementation calls factories with `(tui, theme, keybindings, done)`. JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.\n\nCustom tools typically use the same UI entrypoint via the factory-scoped `pi.ui` object, then return the selected value in normal tool content:\n\n```ts\nasync execute(toolCallId, params, onUpdate, ctx, signal) {\n if (!pi.hasUI) {\n return { content: [{ type: \"text\", text: \"UI unavailable\" }] };\n }\n\n const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {\n const component = new MyPickerComponent(done, signal);\n return component;\n });\n\n return { content: [{ type: \"text\", text: picked ? `Picked: ${picked}` : \"Cancelled\" }] };\n}\n```\n\n## 3) Custom tool call/result renderers\n\nCustom tools and extension tools can return components from:\n\n- `renderCall(args, theme)`\n- `renderResult(result, options, theme, args?)`\n\n`options` currently includes:\n\n- `expanded: boolean`\n- `isPartial: boolean`\n- `spinnerFrame?: number`\n\nThese renderers are mounted by `ToolExecutionComponent`.\n\n## Lifecycle and cancellation\n\n- `dispose()` is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.\n- `done(...)` should be called exactly once from your component flow.\n- For cancellable long-running UI, pair `CancellableLoader` with `AbortSignal` and call `done(...)` from `onAbort`.\n\nExample cancellation pattern:\n\n```ts\nconst loader = new CancellableLoader(tui, theme.fg(\"accent\"), theme.fg(\"muted\"), \"Working...\");\nloader.onAbort = () => done(undefined);\nvoid doWork(loader.signal).then(result => done(result));\nreturn loader;\n```\n\n## Realistic custom component example (extension command)\n\n```ts\nimport type { Component } from \"@f5xc-salesdemos/pi-tui\";\nimport { SelectList, matchesKey, replaceTabs, truncateToWidth } from \"@f5xc-salesdemos/pi-tui\";\nimport { getSelectListTheme, type ExtensionAPI } from \"@f5xc-salesdemos/xcsh\";\n\nclass Picker implements Component {\n list: SelectList;\n keybindings: any;\n done: (value: string | undefined) => void;\n\n constructor(\n items: Array<{ value: string; label: string }>,\n keybindings: any,\n done: (value: string | undefined) => void,\n ) {\n this.list = new SelectList(items, 8, getSelectListTheme());\n this.keybindings = keybindings;\n this.done = done;\n this.list.onSelect = item => this.done(item.value);\n this.list.onCancel = () => this.done(undefined);\n }\n\n handleInput(data: string): void {\n if (this.keybindings.matches(data, \"interrupt\")) {\n this.done(undefined);\n return;\n }\n this.list.handleInput(data);\n }\n\n render(width: number): string[] {\n return this.list.render(width).map(line => truncateToWidth(replaceTabs(line), width));\n }\n\n invalidate(): void {\n this.list.invalidate();\n }\n}\n\nexport default function extension(pi: ExtensionAPI): void {\n pi.registerCommand(\"pick-model\", {\n description: \"Pick a model profile\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) return;\n\n const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {\n const items = [\n { value: \"fast\", label: theme.fg(\"accent\", \"Fast\") },\n { value: \"balanced\", label: \"Balanced\" },\n { value: \"quality\", label: \"Quality\" },\n ];\n return new Picker(items, keybindings, done);\n });\n\n if (selected) ctx.ui.notify(`Selected profile: ${selected}`, \"info\");\n },\n });\n}\n```\n\n## Key implementation files\n\n- `packages/tui/src/tui.ts` — `Component`, `Focusable`, cursor marker, focus, overlay, input dispatch.\n- `packages/tui/src/utils.ts` — width/truncation/sanitization primitives.\n- `packages/tui/src/keys.ts` / `keybindings.ts` — key parsing and configurable action mapping.\n- `packages/coding-agent/src/modes/controllers/extension-ui-controller.ts` — interactive mounting/unmounting for extension/hook/custom-tool UI.\n- `packages/coding-agent/src/extensibility/extensions/types.ts` — extension UI and renderer contracts.\n- `packages/coding-agent/src/extensibility/hooks/types.ts` — hook UI contract (legacy custom signature).\n- `packages/coding-agent/src/extensibility/custom-tools/types.ts` — custom tool execute/render contracts.\n- `packages/coding-agent/src/modes/components/tool-execution.ts` — mounting `renderCall`/`renderResult` components and partial-state options.\n- `packages/coding-agent/src/tools/context.ts` — tool UI context propagation (`hasUI`, `ui`).\n",
@@ -221,6 +221,15 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
221
221
  { value: "15", label: "15 items" },
222
222
  { value: "20", label: "20 items" },
223
223
  ],
224
+ // Chord binding timeout (clamped to [200, 5000] ms at read time)
225
+ "keybindings.chordTimeout": [
226
+ { value: "200", label: "200 ms", description: "Minimum — fastest abandon" },
227
+ { value: "500", label: "500 ms", description: "Snappy" },
228
+ { value: "1000", label: "1 second", description: "Default" },
229
+ { value: "2000", label: "2 seconds", description: "Relaxed" },
230
+ { value: "3000", label: "3 seconds" },
231
+ { value: "5000", label: "5 seconds", description: "Maximum" },
232
+ ],
224
233
  // Ask timeout
225
234
  "ask.timeout": [
226
235
  { value: "0", label: "Disabled" },
@@ -174,17 +174,20 @@ const gitSegment: StatusLineSegment = {
174
174
 
175
175
  if (!content) return { content: "", visible: false };
176
176
 
177
- // State priority: conflicted > modified > untracked > clean (matches p10k)
177
+ // State priority: conflicted > unstaged(dirty) > staged-only > untracked > clean (issue #242)
178
178
  const hasConflict = gitStatus && gitStatus.conflicted > 0;
179
- const hasModified = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0);
179
+ const hasUnstaged = gitStatus && gitStatus.unstaged > 0;
180
+ const hasStaged = gitStatus && gitStatus.staged > 0;
180
181
  const hasUntracked = gitStatus && gitStatus.untracked > 0;
181
182
  const [bgToken, fgToken] = hasConflict
182
183
  ? (["statusLineGitConflictBg", "statusLineGitConflictFg"] as const)
183
- : hasModified
184
+ : hasUnstaged
184
185
  ? (["statusLineGitDirtyBg", "statusLineGitDirtyFg"] as const)
185
- : hasUntracked
186
- ? (["statusLineGitUntrackedBg", "statusLineGitUntrackedFg"] as const)
187
- : (["statusLineGitCleanBg", "statusLineGitCleanFg"] as const);
186
+ : hasStaged
187
+ ? (["statusLineGitStagedBg", "statusLineGitStagedFg"] as const)
188
+ : hasUntracked
189
+ ? (["statusLineGitUntrackedBg", "statusLineGitUntrackedFg"] as const)
190
+ : (["statusLineGitCleanBg", "statusLineGitCleanFg"] as const);
188
191
 
189
192
  return {
190
193
  content: theme.fg(fgToken, content),
@@ -3,6 +3,7 @@ import type { AssistantMessage } from "@f5xc-salesdemos/pi-ai";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
4
4
  import { formatCount, getShellPwd } from "@f5xc-salesdemos/pi-utils";
5
5
  import { $ } from "bun";
6
+ import { formatKeyHint } from "../../config/keybindings";
6
7
  import { settings } from "../../config/settings";
7
8
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
8
9
  import { theme } from "../../modes/theme/theme";
@@ -62,6 +63,10 @@ export class StatusLineComponent implements Component {
62
63
  #sessionStartTime: number = Date.now();
63
64
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
64
65
  #cwd: string = getShellPwd();
66
+ // Display text for a pending chord leader (e.g. "Ctrl+X-") or null when no
67
+ // chord is pending. Populated by the InputController's ChordDispatcher
68
+ // callbacks; rendered as a dim right-aligned segment in the top border.
69
+ #chordPending: string | null = null;
65
70
 
66
71
  // Git status caching (1s TTL)
67
72
  #cachedGitStatus: {
@@ -116,6 +121,33 @@ export class StatusLineComponent implements Component {
116
121
  this.#planModeStatus = status ?? null;
117
122
  }
118
123
 
124
+ /**
125
+ * Called by the InputController's ChordDispatcher when a chord leader is
126
+ * pressed. Displays e.g. "Ctrl+X-" in the top border so the user knows a
127
+ * partial chord is active and the dispatcher is waiting for the 2nd key.
128
+ */
129
+ setChordPending(leader: string): void {
130
+ // formatKeyHint's param type is KeyId (a template-literal union) but its
131
+ // implementation only needs a "+"-separated key string, which is exactly
132
+ // what the chord dispatcher hands us. Cast so callers can pass a plain
133
+ // string without having to thread KeyId through the controller API.
134
+ const next = `${formatKeyHint(leader as Parameters<typeof formatKeyHint>[0])}-`;
135
+ if (this.#chordPending === next) return;
136
+ this.#chordPending = next;
137
+ this.#onStatusChanged?.();
138
+ }
139
+
140
+ /**
141
+ * Called when the chord is dispatched, abandoned, or times out. Clears the
142
+ * pending indicator. No-op when no chord is pending (avoids spurious
143
+ * re-renders).
144
+ */
145
+ clearChordPending(): void {
146
+ if (this.#chordPending === null) return;
147
+ this.#chordPending = null;
148
+ this.#onStatusChanged?.();
149
+ }
150
+
119
151
  setHookStatus(key: string, text: string | undefined): void {
120
152
  if (text === undefined) {
121
153
  this.#hookStatuses.delete(key);
@@ -486,6 +518,18 @@ export class StatusLineComponent implements Component {
486
518
  });
487
519
  }
488
520
 
521
+ // Chord-pending indicator: rightmost segment, dim style. Appended last
522
+ // so it visually sits at the far right of the top border (after any
523
+ // background-jobs indicator). Swallowed by truncation-from-right in the
524
+ // sizing loop below if the terminal is too narrow, which is acceptable.
525
+ if (this.#chordPending !== null) {
526
+ rightParts.push({
527
+ content: theme.fg("dim", this.#chordPending),
528
+ bg: defaultBg,
529
+ fg: defaultFg,
530
+ });
531
+ }
532
+
489
533
  const topFillWidth = Math.max(0, width);
490
534
  const left = [...leftParts];
491
535
  const right = [...rightParts];
@@ -0,0 +1,37 @@
1
+ import type { ChordResult } from "@f5xc-salesdemos/pi-tui";
2
+
3
+ /**
4
+ * Sinks invoked by routeChordResult. Exactly one of these fires per call
5
+ * (or neither, for pending / abandoned — which are intentionally swallowed
6
+ * so the user's partial chord never leaks into the editor buffer).
7
+ */
8
+ export interface ChordRouteSinks {
9
+ action: (actionId: string) => void;
10
+ editor: (key: string) => void;
11
+ }
12
+
13
+ /**
14
+ * Pure routing: given a ChordResult, dispatch to the right sink.
15
+ *
16
+ * - `dispatched` → action sink (keybinding matched a complete chord)
17
+ * - `passthrough` → editor sink (key is not a chord leader or match)
18
+ * - `pending` / `abandoned` → swallowed (Emacs convention: an abandoned
19
+ * leader does not emit the second key into the buffer)
20
+ *
21
+ * Extracted so tests can exercise routing logic without constructing a
22
+ * full InputController.
23
+ */
24
+ export function routeChordResult(result: ChordResult, key: string, sinks: ChordRouteSinks): void {
25
+ switch (result.kind) {
26
+ case "dispatched":
27
+ sinks.action(result.action);
28
+ return;
29
+ case "passthrough":
30
+ sinks.editor(key);
31
+ return;
32
+ case "pending":
33
+ case "abandoned":
34
+ // Swallowed — Emacs convention for abandoned leaders.
35
+ return;
36
+ }
37
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { type AgentMessage, ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
3
3
  import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
4
- import type { AutocompleteProvider, SlashCommand } from "@f5xc-salesdemos/pi-tui";
4
+ import { type AutocompleteProvider, ChordDispatcher, type SlashCommand } from "@f5xc-salesdemos/pi-tui";
5
5
  import { $env } from "@f5xc-salesdemos/pi-utils";
6
6
  import { settings } from "../../config/settings";
7
7
  import { createStreamingAssistantGutter } from "../../modes/components/gutter-block";
@@ -26,9 +26,63 @@ function isExpandable(obj: unknown): obj is Expandable {
26
26
  }
27
27
 
28
28
  export class InputController {
29
+ #dispatcher: ChordDispatcher | null = null;
30
+
29
31
  constructor(private ctx: InteractiveModeContext) {}
30
32
 
33
+ /**
34
+ * Rebuild the chord dispatcher from the current keybindings + settings.
35
+ * Called from setupKeyHandlers so a subsequent keybindings reload / settings
36
+ * change can re-init without leaking the previous pending-timer.
37
+ *
38
+ * The dispatcher callbacks drive the status-line chord-pending indicator.
39
+ * Optional-chaining is deliberate: Task 11 adds setChordPending/clearChordPending
40
+ * on StatusLineComponent, so this wiring stays safe before Task 11 lands.
41
+ */
42
+ #initChordDispatcher(): void {
43
+ this.#dispatcher?.dispose();
44
+ this.#dispatcher = null;
45
+ // Feature-detect the context: partial mocks in tests may omit these methods.
46
+ // Production always satisfies both interfaces (see config/keybindings.ts,
47
+ // config/settings.ts), so the no-op branch here only protects test fakes.
48
+ const getBindings = this.ctx.keybindings?.getChordBindings?.bind(this.ctx.keybindings);
49
+ const getTimeout = this.ctx.settings?.getChordTimeoutMs?.bind(this.ctx.settings);
50
+ if (!getBindings || !getTimeout) return;
51
+ const bindings = getBindings();
52
+ const timeoutMs = getTimeout();
53
+ // Optional-chaining on methods that Task 11 adds to StatusLineComponent.
54
+ // Typed loosely so Task 10 can ship before Task 11 wires the methods.
55
+ const statusLine = this.ctx.statusLine as
56
+ | {
57
+ setChordPending?: (leader: string) => void;
58
+ clearChordPending?: () => void;
59
+ }
60
+ | undefined;
61
+ this.#dispatcher = new ChordDispatcher(bindings, timeoutMs, {
62
+ onPending: leader => statusLine?.setChordPending?.(leader),
63
+ onCleared: () => statusLine?.clearChordPending?.(),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Dispose of the chord dispatcher (clears any pending chord timer).
69
+ * Exposed for tests and for the outer controller lifecycle.
70
+ */
71
+ dispose(): void {
72
+ this.#dispatcher?.dispose();
73
+ this.#dispatcher = null;
74
+ }
75
+
76
+ /**
77
+ * Exposed for tests: returns the current dispatcher instance (or null if
78
+ * setupKeyHandlers has not been called yet).
79
+ */
80
+ getChordDispatcher(): ChordDispatcher | null {
81
+ return this.#dispatcher;
82
+ }
83
+
31
84
  setupKeyHandlers(): void {
85
+ this.#initChordDispatcher();
32
86
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
33
87
  this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
34
88
  Boolean(
@@ -90,11 +90,12 @@
90
90
  "statusLinePathFg": 254,
91
91
  "statusLineGitCleanBg": 2,
92
92
  "statusLineGitDirtyBg": 3,
93
+ "statusLineGitStagedBg": 64,
93
94
  "statusLineGitUntrackedBg": 39,
94
95
  "statusLineGitConflictBg": 1,
95
- "statusLineGitFg": 0,
96
96
  "statusLineGitCleanFg": 0,
97
97
  "statusLineGitDirtyFg": 0,
98
+ "statusLineGitStagedFg": 0,
98
99
  "statusLineGitUntrackedFg": 0,
99
100
  "statusLineGitConflictFg": 7,
100
101
  "statusLinePlanModeBg": 236,
@@ -86,27 +86,28 @@
86
86
  "statusLineOutput": "f5DarkRed",
87
87
  "statusLineCost": "#ffb347",
88
88
  "statusLineSubagents": "f5Red",
89
- "statusLineOsIconBg": 7,
90
- "statusLineOsIconFg": 232,
91
- "statusLinePathBg": 4,
92
- "statusLinePathFg": 254,
93
- "statusLineGitCleanBg": 2,
94
- "statusLineGitDirtyBg": 3,
95
- "statusLineGitUntrackedBg": 39,
96
- "statusLineGitConflictBg": 1,
97
- "statusLineGitFg": 0,
98
- "statusLineGitCleanFg": 0,
99
- "statusLineGitDirtyFg": 0,
100
- "statusLineGitUntrackedFg": 0,
101
- "statusLineGitConflictFg": 7,
102
- "statusLinePlanModeBg": 236,
103
- "statusLinePlanModeFg": 117,
104
- "statusLineContextPctBg": 238,
105
- "statusLineContextPctFg": 255,
106
- "statusLineContextPctNormalBg": 17,
107
- "statusLineContextPctWarningBg": 22,
108
- "statusLineContextPctPurpleBg": 94,
109
- "statusLineContextPctErrorBg": 88,
89
+ "statusLineOsIconBg": 236,
90
+ "statusLineOsIconFg": 255,
91
+ "statusLinePathBg": 24,
92
+ "statusLinePathFg": 255,
93
+ "statusLineGitCleanBg": 28,
94
+ "statusLineGitDirtyBg": 136,
95
+ "statusLineGitStagedBg": 100,
96
+ "statusLineGitUntrackedBg": 26,
97
+ "statusLineGitConflictBg": 88,
98
+ "statusLineGitCleanFg": 255,
99
+ "statusLineGitDirtyFg": 255,
100
+ "statusLineGitStagedFg": 255,
101
+ "statusLineGitUntrackedFg": 255,
102
+ "statusLineGitConflictFg": 255,
103
+ "statusLinePlanModeBg": 223,
104
+ "statusLinePlanModeFg": 238,
105
+ "statusLineContextPctBg": 253,
106
+ "statusLineContextPctFg": 238,
107
+ "statusLineContextPctNormalBg": 189,
108
+ "statusLineContextPctWarningBg": 194,
109
+ "statusLineContextPctPurpleBg": 225,
110
+ "statusLineContextPctErrorBg": 224,
110
111
  "statusLineProfileF5xcBg": "f5Red",
111
112
  "statusLineProfileF5xcFg": 231,
112
113
  "pythonMode": "warningAmber",
@@ -101,7 +101,36 @@
101
101
  "statusLineUntracked",
102
102
  "statusLineOutput",
103
103
  "statusLineCost",
104
- "statusLineSubagents"
104
+ "statusLineSubagents",
105
+ "chromeAccent",
106
+ "spinnerAccent",
107
+ "contentAccent",
108
+ "gutterSuccess",
109
+ "gutterWarning",
110
+ "statusLineOsIconBg",
111
+ "statusLineOsIconFg",
112
+ "statusLinePathBg",
113
+ "statusLinePathFg",
114
+ "statusLineGitCleanBg",
115
+ "statusLineGitCleanFg",
116
+ "statusLineGitDirtyBg",
117
+ "statusLineGitDirtyFg",
118
+ "statusLineGitStagedBg",
119
+ "statusLineGitStagedFg",
120
+ "statusLineGitUntrackedBg",
121
+ "statusLineGitUntrackedFg",
122
+ "statusLineGitConflictBg",
123
+ "statusLineGitConflictFg",
124
+ "statusLinePlanModeBg",
125
+ "statusLinePlanModeFg",
126
+ "statusLineContextPctBg",
127
+ "statusLineContextPctFg",
128
+ "statusLineContextPctNormalBg",
129
+ "statusLineContextPctWarningBg",
130
+ "statusLineContextPctPurpleBg",
131
+ "statusLineContextPctErrorBg",
132
+ "statusLineProfileF5xcBg",
133
+ "statusLineProfileF5xcFg"
105
134
  ],
106
135
  "properties": {
107
136
  "accent": {
@@ -338,15 +367,15 @@
338
367
  },
339
368
  "statusLinePath": {
340
369
  "$ref": "#/$defs/colorValue",
341
- "description": "Status line path segment"
370
+ "description": "Status line path text color (hex). Used for non-powerline rendering. See 'statusLinePathBg'/'Fg' for the corresponding powerline palette indices."
342
371
  },
343
372
  "statusLineGitClean": {
344
373
  "$ref": "#/$defs/colorValue",
345
- "description": "Status line git clean"
374
+ "description": "Status line git clean text color (hex). Used for non-powerline rendering. See 'statusLineGitCleanBg'/'Fg' for the corresponding powerline palette indices."
346
375
  },
347
376
  "statusLineGitDirty": {
348
377
  "$ref": "#/$defs/colorValue",
349
- "description": "Status line git dirty"
378
+ "description": "Status line git dirty text color (hex). Used for non-powerline rendering. See 'statusLineGitDirtyBg'/'Fg' for the corresponding powerline palette indices."
350
379
  },
351
380
  "statusLineContext": {
352
381
  "$ref": "#/$defs/colorValue",
@@ -358,15 +387,15 @@
358
387
  },
359
388
  "statusLineStaged": {
360
389
  "$ref": "#/$defs/colorValue",
361
- "description": "Status line git staged"
390
+ "description": "Status line git staged count text color (hex). Used for non-powerline rendering. See 'statusLineGitStagedBg'/'Fg' for the corresponding powerline palette indices."
362
391
  },
363
392
  "statusLineDirty": {
364
393
  "$ref": "#/$defs/colorValue",
365
- "description": "Status line git dirty count"
394
+ "description": "Status line git dirty count text color (hex). Used for non-powerline rendering. See 'statusLineGitDirtyBg'/'Fg' for the corresponding powerline palette indices."
366
395
  },
367
396
  "statusLineUntracked": {
368
397
  "$ref": "#/$defs/colorValue",
369
- "description": "Status line git untracked"
398
+ "description": "Status line git untracked text color (hex). Used for non-powerline rendering. See 'statusLineGitUntrackedBg'/'Fg' for the corresponding powerline palette indices."
370
399
  },
371
400
  "statusLineOutput": {
372
401
  "$ref": "#/$defs/colorValue",
@@ -379,6 +408,118 @@
379
408
  "statusLineSubagents": {
380
409
  "$ref": "#/$defs/colorValue",
381
410
  "description": "Status line subagents segment"
411
+ },
412
+ "chromeAccent": {
413
+ "$ref": "#/$defs/colorValue",
414
+ "description": "Chrome / UI accent color (non-content accent). Defaults to `accent` when omitted — but in this project it is always overridden per theme."
415
+ },
416
+ "spinnerAccent": {
417
+ "$ref": "#/$defs/colorValue",
418
+ "description": "Spinner accent color. Defaults to `accent` when omitted — but in this project it is always overridden per theme."
419
+ },
420
+ "contentAccent": {
421
+ "$ref": "#/$defs/colorValue",
422
+ "description": "Content accent color (distinct from UI chrome). Defaults to `accent` when omitted — but in this project it is always overridden per theme."
423
+ },
424
+ "gutterWarning": {
425
+ "$ref": "#/$defs/colorValue",
426
+ "description": "Done-state tool/command gutter dot color for warning outcomes. Defaults to `warning` when omitted — but in this project it is always overridden per theme."
427
+ },
428
+ "statusLineOsIconBg": {
429
+ "$ref": "#/$defs/colorValue",
430
+ "description": "256-color palette index for the powerline OS icon segment background. Independent from hex-based text colors used for non-powerline rendering. See issue #242 for the two-domain color model."
431
+ },
432
+ "statusLineOsIconFg": {
433
+ "$ref": "#/$defs/colorValue",
434
+ "description": "256-color palette index for the powerline OS icon segment foreground. Independent from hex-based text colors used for non-powerline rendering. See issue #242 for the two-domain color model."
435
+ },
436
+ "statusLinePathBg": {
437
+ "$ref": "#/$defs/colorValue",
438
+ "description": "256-color palette index for the powerline path segment background. Independent from the hex-based `statusLinePath` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
439
+ },
440
+ "statusLinePathFg": {
441
+ "$ref": "#/$defs/colorValue",
442
+ "description": "256-color palette index for the powerline path segment foreground. Independent from the hex-based `statusLinePath` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
443
+ },
444
+ "statusLineGitCleanBg": {
445
+ "$ref": "#/$defs/colorValue",
446
+ "description": "256-color palette index for the powerline git-clean segment background. Independent from the hex-based `statusLineGitClean` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
447
+ },
448
+ "statusLineGitCleanFg": {
449
+ "$ref": "#/$defs/colorValue",
450
+ "description": "256-color palette index for the powerline git-clean segment foreground. Independent from the hex-based `statusLineGitClean` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
451
+ },
452
+ "statusLineGitDirtyBg": {
453
+ "$ref": "#/$defs/colorValue",
454
+ "description": "256-color palette index for the powerline git-dirty (unstaged) segment background. Independent from the hex-based `statusLineGitDirty` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
455
+ },
456
+ "statusLineGitDirtyFg": {
457
+ "$ref": "#/$defs/colorValue",
458
+ "description": "256-color palette index for the powerline git-dirty (unstaged) segment foreground. Independent from the hex-based `statusLineGitDirty` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
459
+ },
460
+ "statusLineGitStagedBg": {
461
+ "$ref": "#/$defs/colorValue",
462
+ "description": "256-color palette index for the powerline git-staged-only segment background. Rendered when the repo has staged changes but no unstaged or conflicted changes. See issue #242 for the two-domain color model."
463
+ },
464
+ "statusLineGitStagedFg": {
465
+ "$ref": "#/$defs/colorValue",
466
+ "description": "256-color palette index for the powerline git-staged-only segment foreground. Rendered when the repo has staged changes but no unstaged or conflicted changes. See issue #242 for the two-domain color model."
467
+ },
468
+ "statusLineGitUntrackedBg": {
469
+ "$ref": "#/$defs/colorValue",
470
+ "description": "256-color palette index for the powerline git-untracked-only segment background. Independent from the hex-based `statusLineUntracked` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
471
+ },
472
+ "statusLineGitUntrackedFg": {
473
+ "$ref": "#/$defs/colorValue",
474
+ "description": "256-color palette index for the powerline git-untracked-only segment foreground. Independent from the hex-based `statusLineUntracked` text color used for non-powerline rendering. See issue #242 for the two-domain color model."
475
+ },
476
+ "statusLineGitConflictBg": {
477
+ "$ref": "#/$defs/colorValue",
478
+ "description": "256-color palette index for the powerline git-conflict segment background. See issue #242 for the two-domain color model."
479
+ },
480
+ "statusLineGitConflictFg": {
481
+ "$ref": "#/$defs/colorValue",
482
+ "description": "256-color palette index for the powerline git-conflict segment foreground. See issue #242 for the two-domain color model."
483
+ },
484
+ "statusLinePlanModeBg": {
485
+ "$ref": "#/$defs/colorValue",
486
+ "description": "256-color palette index for the powerline plan-mode segment background. See issue #242 for the two-domain color model."
487
+ },
488
+ "statusLinePlanModeFg": {
489
+ "$ref": "#/$defs/colorValue",
490
+ "description": "256-color palette index for the powerline plan-mode segment foreground. See issue #242 for the two-domain color model."
491
+ },
492
+ "statusLineContextPctBg": {
493
+ "$ref": "#/$defs/colorValue",
494
+ "description": "256-color palette index for the powerline context-percent segment default background. See issue #242 for the two-domain color model."
495
+ },
496
+ "statusLineContextPctFg": {
497
+ "$ref": "#/$defs/colorValue",
498
+ "description": "256-color palette index for the powerline context-percent segment default foreground. See issue #242 for the two-domain color model."
499
+ },
500
+ "statusLineContextPctNormalBg": {
501
+ "$ref": "#/$defs/colorValue",
502
+ "description": "256-color palette index for the powerline context-percent segment background in the normal tier. See issue #242 for the two-domain color model."
503
+ },
504
+ "statusLineContextPctWarningBg": {
505
+ "$ref": "#/$defs/colorValue",
506
+ "description": "256-color palette index for the powerline context-percent segment background in the warning tier. See issue #242 for the two-domain color model."
507
+ },
508
+ "statusLineContextPctPurpleBg": {
509
+ "$ref": "#/$defs/colorValue",
510
+ "description": "256-color palette index for the powerline context-percent segment background in the purple tier. See issue #242 for the two-domain color model."
511
+ },
512
+ "statusLineContextPctErrorBg": {
513
+ "$ref": "#/$defs/colorValue",
514
+ "description": "256-color palette index for the powerline context-percent segment background in the error tier. See issue #242 for the two-domain color model."
515
+ },
516
+ "statusLineProfileF5xcBg": {
517
+ "$ref": "#/$defs/colorValue",
518
+ "description": "256-color palette index OR color reference for the powerline F5 XC profile segment background. Stays F5 brand red across light and dark themes. See issue #242 for the two-domain color model."
519
+ },
520
+ "statusLineProfileF5xcFg": {
521
+ "$ref": "#/$defs/colorValue",
522
+ "description": "256-color palette index for the powerline F5 XC profile segment foreground. See issue #242 for the two-domain color model."
382
523
  }
383
524
  },
384
525
  "additionalProperties": false
@@ -826,114 +826,118 @@ const ThemeJsonSchema = Type.Object({
826
826
  $schema: Type.Optional(Type.String()),
827
827
  name: Type.String(),
828
828
  vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
829
- colors: Type.Object({
830
- // Core UI (12 colors)
831
- accent: ColorValueSchema,
832
- chromeAccent: Type.Optional(ColorValueSchema),
833
- spinnerAccent: Type.Optional(ColorValueSchema),
834
- contentAccent: Type.Optional(ColorValueSchema),
835
- border: ColorValueSchema,
836
- borderAccent: ColorValueSchema,
837
- borderMuted: ColorValueSchema,
838
- success: ColorValueSchema,
839
- error: ColorValueSchema,
840
- warning: ColorValueSchema,
841
- muted: ColorValueSchema,
842
- dim: ColorValueSchema,
843
- gutterSuccess: Type.Optional(ColorValueSchema),
844
- gutterError: Type.Optional(ColorValueSchema),
845
- gutterWarning: Type.Optional(ColorValueSchema),
846
- text: ColorValueSchema,
847
- thinkingText: ColorValueSchema,
848
- // Backgrounds & Content Text (11 colors)
849
- selectedBg: ColorValueSchema,
850
- userMessageBg: ColorValueSchema,
851
- userMessageText: ColorValueSchema,
852
- customMessageBg: ColorValueSchema,
853
- customMessageText: ColorValueSchema,
854
- customMessageLabel: ColorValueSchema,
855
- toolPendingBg: ColorValueSchema,
856
- toolSuccessBg: ColorValueSchema,
857
- toolErrorBg: ColorValueSchema,
858
- toolTitle: ColorValueSchema,
859
- toolOutput: ColorValueSchema,
860
- // Markdown (10 colors)
861
- mdHeading: ColorValueSchema,
862
- mdLink: ColorValueSchema,
863
- mdLinkUrl: ColorValueSchema,
864
- mdCode: ColorValueSchema,
865
- mdCodeBlock: ColorValueSchema,
866
- mdCodeBlockBorder: ColorValueSchema,
867
- mdQuote: ColorValueSchema,
868
- mdQuoteBorder: ColorValueSchema,
869
- mdHr: ColorValueSchema,
870
- mdListBullet: ColorValueSchema,
871
- // Tool Diffs (3 colors)
872
- toolDiffAdded: ColorValueSchema,
873
- toolDiffRemoved: ColorValueSchema,
874
- toolDiffContext: ColorValueSchema,
875
- // Syntax Highlighting (9 colors)
876
- syntaxComment: ColorValueSchema,
877
- syntaxKeyword: ColorValueSchema,
878
- syntaxFunction: ColorValueSchema,
879
- syntaxVariable: ColorValueSchema,
880
- syntaxString: ColorValueSchema,
881
- syntaxNumber: ColorValueSchema,
882
- syntaxType: ColorValueSchema,
883
- syntaxOperator: ColorValueSchema,
884
- syntaxPunctuation: ColorValueSchema,
885
- syntaxControl: ColorValueSchema,
886
- // Thinking Level Borders (6 colors)
887
- thinkingOff: ColorValueSchema,
888
- thinkingMinimal: ColorValueSchema,
889
- thinkingLow: ColorValueSchema,
890
- thinkingMedium: ColorValueSchema,
891
- thinkingHigh: ColorValueSchema,
892
- thinkingXhigh: ColorValueSchema,
893
- // Bash Mode (1 color)
894
- bashMode: ColorValueSchema,
895
- // Python Mode (1 color)
896
- pythonMode: ColorValueSchema,
897
- // Footer Status Line
898
- statusLineBg: ColorValueSchema,
899
- statusLineSep: ColorValueSchema,
900
- statusLineModel: ColorValueSchema,
901
- statusLinePath: ColorValueSchema,
902
- statusLineGitClean: ColorValueSchema,
903
- statusLineGitDirty: ColorValueSchema,
904
- statusLineContext: ColorValueSchema,
905
- statusLineSpend: ColorValueSchema,
906
- statusLineStaged: ColorValueSchema,
907
- statusLineDirty: ColorValueSchema,
908
- statusLineUntracked: ColorValueSchema,
909
- statusLineOutput: ColorValueSchema,
910
- statusLineCost: ColorValueSchema,
911
- statusLineSubagents: ColorValueSchema,
912
- // Powerline segment backgrounds
913
- statusLineOsIconBg: Type.Optional(ColorValueSchema),
914
- statusLineOsIconFg: Type.Optional(ColorValueSchema),
915
- statusLinePathBg: Type.Optional(ColorValueSchema),
916
- statusLinePathFg: Type.Optional(ColorValueSchema),
917
- statusLineGitCleanBg: Type.Optional(ColorValueSchema),
918
- statusLineGitDirtyBg: Type.Optional(ColorValueSchema),
919
- statusLineGitUntrackedBg: Type.Optional(ColorValueSchema),
920
- statusLineGitConflictBg: Type.Optional(ColorValueSchema),
921
- statusLineGitFg: Type.Optional(ColorValueSchema),
922
- statusLineGitCleanFg: Type.Optional(ColorValueSchema),
923
- statusLineGitDirtyFg: Type.Optional(ColorValueSchema),
924
- statusLineGitUntrackedFg: Type.Optional(ColorValueSchema),
925
- statusLineGitConflictFg: Type.Optional(ColorValueSchema),
926
- statusLinePlanModeBg: Type.Optional(ColorValueSchema),
927
- statusLinePlanModeFg: Type.Optional(ColorValueSchema),
928
- statusLineContextPctBg: Type.Optional(ColorValueSchema),
929
- statusLineContextPctFg: Type.Optional(ColorValueSchema),
930
- statusLineContextPctNormalBg: Type.Optional(ColorValueSchema),
931
- statusLineContextPctWarningBg: Type.Optional(ColorValueSchema),
932
- statusLineContextPctPurpleBg: Type.Optional(ColorValueSchema),
933
- statusLineContextPctErrorBg: Type.Optional(ColorValueSchema),
934
- statusLineProfileF5xcBg: Type.Optional(ColorValueSchema),
935
- statusLineProfileF5xcFg: Type.Optional(ColorValueSchema),
936
- }),
829
+ colors: Type.Object(
830
+ {
831
+ // Core UI (12 colors)
832
+ accent: ColorValueSchema,
833
+ chromeAccent: ColorValueSchema,
834
+ spinnerAccent: ColorValueSchema,
835
+ contentAccent: ColorValueSchema,
836
+ border: ColorValueSchema,
837
+ borderAccent: ColorValueSchema,
838
+ borderMuted: ColorValueSchema,
839
+ success: ColorValueSchema,
840
+ error: ColorValueSchema,
841
+ warning: ColorValueSchema,
842
+ muted: ColorValueSchema,
843
+ dim: ColorValueSchema,
844
+ gutterSuccess: ColorValueSchema,
845
+ gutterError: Type.Optional(ColorValueSchema),
846
+ gutterWarning: ColorValueSchema,
847
+ text: ColorValueSchema,
848
+ thinkingText: ColorValueSchema,
849
+ // Backgrounds & Content Text (11 colors)
850
+ selectedBg: ColorValueSchema,
851
+ userMessageBg: ColorValueSchema,
852
+ userMessageText: ColorValueSchema,
853
+ customMessageBg: ColorValueSchema,
854
+ customMessageText: ColorValueSchema,
855
+ customMessageLabel: ColorValueSchema,
856
+ toolPendingBg: ColorValueSchema,
857
+ toolSuccessBg: ColorValueSchema,
858
+ toolErrorBg: ColorValueSchema,
859
+ toolTitle: ColorValueSchema,
860
+ toolOutput: ColorValueSchema,
861
+ // Markdown (10 colors)
862
+ mdHeading: ColorValueSchema,
863
+ mdLink: ColorValueSchema,
864
+ mdLinkUrl: ColorValueSchema,
865
+ mdCode: ColorValueSchema,
866
+ mdCodeBlock: ColorValueSchema,
867
+ mdCodeBlockBorder: ColorValueSchema,
868
+ mdQuote: ColorValueSchema,
869
+ mdQuoteBorder: ColorValueSchema,
870
+ mdHr: ColorValueSchema,
871
+ mdListBullet: ColorValueSchema,
872
+ // Tool Diffs (3 colors)
873
+ toolDiffAdded: ColorValueSchema,
874
+ toolDiffRemoved: ColorValueSchema,
875
+ toolDiffContext: ColorValueSchema,
876
+ // Syntax Highlighting (9 colors)
877
+ syntaxComment: ColorValueSchema,
878
+ syntaxKeyword: ColorValueSchema,
879
+ syntaxFunction: ColorValueSchema,
880
+ syntaxVariable: ColorValueSchema,
881
+ syntaxString: ColorValueSchema,
882
+ syntaxNumber: ColorValueSchema,
883
+ syntaxType: ColorValueSchema,
884
+ syntaxOperator: ColorValueSchema,
885
+ syntaxPunctuation: ColorValueSchema,
886
+ syntaxControl: ColorValueSchema,
887
+ // Thinking Level Borders (6 colors)
888
+ thinkingOff: ColorValueSchema,
889
+ thinkingMinimal: ColorValueSchema,
890
+ thinkingLow: ColorValueSchema,
891
+ thinkingMedium: ColorValueSchema,
892
+ thinkingHigh: ColorValueSchema,
893
+ thinkingXhigh: ColorValueSchema,
894
+ // Bash Mode (1 color)
895
+ bashMode: ColorValueSchema,
896
+ // Python Mode (1 color)
897
+ pythonMode: ColorValueSchema,
898
+ // Footer Status Line
899
+ statusLineBg: ColorValueSchema,
900
+ statusLineSep: ColorValueSchema,
901
+ statusLineModel: ColorValueSchema,
902
+ statusLinePath: ColorValueSchema,
903
+ statusLineGitClean: ColorValueSchema,
904
+ statusLineGitDirty: ColorValueSchema,
905
+ statusLineContext: ColorValueSchema,
906
+ statusLineSpend: ColorValueSchema,
907
+ statusLineStaged: ColorValueSchema,
908
+ statusLineDirty: ColorValueSchema,
909
+ statusLineUntracked: ColorValueSchema,
910
+ statusLineOutput: ColorValueSchema,
911
+ statusLineCost: ColorValueSchema,
912
+ statusLineSubagents: ColorValueSchema,
913
+ // Powerline segment backgrounds
914
+ statusLineOsIconBg: ColorValueSchema,
915
+ statusLineOsIconFg: ColorValueSchema,
916
+ statusLinePathBg: ColorValueSchema,
917
+ statusLinePathFg: ColorValueSchema,
918
+ statusLineGitCleanBg: ColorValueSchema,
919
+ statusLineGitDirtyBg: ColorValueSchema,
920
+ statusLineGitStagedBg: ColorValueSchema,
921
+ statusLineGitUntrackedBg: ColorValueSchema,
922
+ statusLineGitConflictBg: ColorValueSchema,
923
+ statusLineGitCleanFg: ColorValueSchema,
924
+ statusLineGitDirtyFg: ColorValueSchema,
925
+ statusLineGitStagedFg: ColorValueSchema,
926
+ statusLineGitUntrackedFg: ColorValueSchema,
927
+ statusLineGitConflictFg: ColorValueSchema,
928
+ statusLinePlanModeBg: ColorValueSchema,
929
+ statusLinePlanModeFg: ColorValueSchema,
930
+ statusLineContextPctBg: ColorValueSchema,
931
+ statusLineContextPctFg: ColorValueSchema,
932
+ statusLineContextPctNormalBg: ColorValueSchema,
933
+ statusLineContextPctWarningBg: ColorValueSchema,
934
+ statusLineContextPctPurpleBg: ColorValueSchema,
935
+ statusLineContextPctErrorBg: ColorValueSchema,
936
+ statusLineProfileF5xcBg: ColorValueSchema,
937
+ statusLineProfileF5xcFg: ColorValueSchema,
938
+ },
939
+ { additionalProperties: false },
940
+ ),
937
941
  export: Type.Optional(
938
942
  Type.Object({
939
943
  pageBg: Type.Optional(ColorValueSchema),
@@ -1022,13 +1026,14 @@ export type ThemeColor =
1022
1026
  | "statusLinePathFg"
1023
1027
  | "statusLineGitCleanBg"
1024
1028
  | "statusLineGitDirtyBg"
1029
+ | "statusLineGitStagedBg"
1025
1030
  | "statusLineGitUntrackedBg"
1026
1031
  | "statusLineGitConflictBg"
1027
1032
  | "statusLineGitCleanFg"
1028
1033
  | "statusLineGitDirtyFg"
1034
+ | "statusLineGitStagedFg"
1029
1035
  | "statusLineGitUntrackedFg"
1030
1036
  | "statusLineGitConflictFg"
1031
- | "statusLineGitFg"
1032
1037
  | "statusLinePlanModeBg"
1033
1038
  | "statusLinePlanModeFg"
1034
1039
  | "statusLineContextPctBg"
@@ -1114,11 +1119,12 @@ const THEME_COLOR_RECORD = {
1114
1119
  statusLinePathFg: true,
1115
1120
  statusLineGitCleanBg: true,
1116
1121
  statusLineGitDirtyBg: true,
1122
+ statusLineGitStagedBg: true,
1117
1123
  statusLineGitUntrackedBg: true,
1118
1124
  statusLineGitConflictBg: true,
1119
- statusLineGitFg: true,
1120
1125
  statusLineGitCleanFg: true,
1121
1126
  statusLineGitDirtyFg: true,
1127
+ statusLineGitStagedFg: true,
1122
1128
  statusLineGitUntrackedFg: true,
1123
1129
  statusLineGitConflictFg: true,
1124
1130
  statusLinePlanModeBg: true,
@@ -1342,38 +1348,8 @@ export class Theme {
1342
1348
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
1343
1349
  this.#fgColors[key] = fgAnsi(value, mode);
1344
1350
  }
1345
- // Fallback: chromeAccent and contentAccent inherit from accent when not defined
1346
- this.#fgColors.chromeAccent ??= this.#fgColors.accent;
1347
- this.#fgColors.spinnerAccent ??= this.#fgColors.accent;
1348
- // Gutter outcome colors inherit from success/error unless a theme overrides them
1349
- this.#fgColors.gutterSuccess ??= this.#fgColors.success;
1351
+ // gutterError remains optional neither shipped theme carries it
1350
1352
  this.#fgColors.gutterError ??= this.#fgColors.error;
1351
- this.#fgColors.gutterWarning ??= this.#fgColors.warning;
1352
- // Powerline segment bg/fg fallbacks
1353
- this.#fgColors.statusLineOsIconBg ??= this.#fgColors.muted;
1354
- this.#fgColors.statusLineOsIconFg ??= this.#fgColors.text;
1355
- this.#fgColors.statusLinePathBg ??= this.#fgColors.statusLinePath;
1356
- this.#fgColors.statusLinePathFg ??= this.#fgColors.text;
1357
- this.#fgColors.statusLineGitCleanBg ??= this.#fgColors.statusLineGitClean;
1358
- this.#fgColors.statusLineGitDirtyBg ??= this.#fgColors.statusLineGitDirty;
1359
- this.#fgColors.statusLineGitUntrackedBg ??= this.#fgColors.statusLineUntracked;
1360
- this.#fgColors.statusLineGitConflictBg ??= this.#fgColors.error;
1361
- this.#fgColors.statusLineGitFg ??= this.#fgColors.text;
1362
- this.#fgColors.statusLineGitCleanFg ??= this.#fgColors.statusLineGitFg;
1363
- this.#fgColors.statusLineGitDirtyFg ??= this.#fgColors.statusLineGitFg;
1364
- this.#fgColors.statusLineGitUntrackedFg ??= this.#fgColors.statusLineGitFg;
1365
- this.#fgColors.statusLineGitConflictFg ??= this.#fgColors.statusLineGitFg;
1366
- this.#fgColors.statusLinePlanModeBg ??= this.#fgColors.muted;
1367
- this.#fgColors.statusLinePlanModeFg ??= this.#fgColors.text;
1368
- this.#fgColors.statusLineContextPctBg ??= this.#fgColors.muted;
1369
- this.#fgColors.statusLineContextPctFg ??= this.#fgColors.text;
1370
- this.#fgColors.statusLineContextPctNormalBg ??= this.#fgColors.statusLineContextPctBg;
1371
- this.#fgColors.statusLineContextPctWarningBg ??= this.#fgColors.statusLineContextPctBg;
1372
- this.#fgColors.statusLineContextPctPurpleBg ??= this.#fgColors.statusLineContextPctBg;
1373
- this.#fgColors.statusLineContextPctErrorBg ??= this.#fgColors.statusLineContextPctBg;
1374
- this.#fgColors.statusLineProfileF5xcBg ??= this.#fgColors.muted;
1375
- this.#fgColors.statusLineProfileF5xcFg ??= this.#fgColors.text;
1376
- this.#fgColors.contentAccent ??= this.#fgColors.accent;
1377
1353
  this.#bgColors = {} as Record<ThemeBg, string>;
1378
1354
  for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
1379
1355
  this.#bgColors[key] = bgAnsi(value, mode);