@f5xc-salesdemos/xcsh 18.5.2 → 18.5.3

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.
Files changed (149) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +7 -7
  3. package/src/config/settings-schema.ts +1 -1
  4. package/src/config/settings.ts +1 -1
  5. package/src/cursor.ts +24 -3
  6. package/src/edit/renderer.ts +0 -1
  7. package/src/exa/factory.ts +2 -0
  8. package/src/exa/mcp-client.ts +2 -0
  9. package/src/exa/render.ts +4 -11
  10. package/src/exec/bash-executor.ts +50 -15
  11. package/src/extensibility/extensions/types.ts +2 -0
  12. package/src/extensibility/hooks/types.ts +4 -0
  13. package/src/internal-urls/build-info.generated.ts +8 -8
  14. package/src/internal-urls/docs-index.generated.ts +1 -1
  15. package/src/modes/components/btw-panel.ts +1 -2
  16. package/src/modes/components/gutter-block.ts +36 -5
  17. package/src/modes/components/oauth-selector.ts +2 -2
  18. package/src/modes/components/read-tool-group.ts +4 -19
  19. package/src/modes/components/tool-execution.ts +6 -2
  20. package/src/modes/components/welcome.ts +13 -12
  21. package/src/modes/controllers/command-controller.ts +2 -2
  22. package/src/modes/controllers/event-controller.ts +10 -2
  23. package/src/modes/theme/defaults/index.ts +0 -196
  24. package/src/modes/theme/defaults/xcsh-dark.json +2 -0
  25. package/src/modes/theme/defaults/xcsh-light.json +41 -22
  26. package/src/modes/theme/theme.ts +21 -21
  27. package/src/session/agent-session.ts +1 -0
  28. package/src/task/render.ts +5 -4
  29. package/src/tools/ask.ts +4 -8
  30. package/src/tools/ast-edit.ts +3 -6
  31. package/src/tools/ast-grep.ts +3 -6
  32. package/src/tools/bash.ts +1 -2
  33. package/src/tools/calculator.ts +4 -3
  34. package/src/tools/fetch.ts +0 -1
  35. package/src/tools/find.ts +3 -10
  36. package/src/tools/gh-renderer.ts +0 -1
  37. package/src/tools/grep.ts +7 -13
  38. package/src/tools/inspect-image-renderer.ts +0 -1
  39. package/src/tools/notebook.ts +1 -1
  40. package/src/tools/read.ts +1 -4
  41. package/src/tools/render-utils.ts +4 -3
  42. package/src/tools/search-tool-bm25.ts +2 -2
  43. package/src/tools/ssh.ts +1 -4
  44. package/src/tools/todo-write.ts +1 -4
  45. package/src/tools/tool-result.ts +7 -0
  46. package/src/tools/vim.ts +1 -3
  47. package/src/tools/write.ts +1 -2
  48. package/src/tui/code-cell.ts +5 -17
  49. package/src/web/search/render.ts +1 -4
  50. package/src/modes/theme/dark.json +0 -98
  51. package/src/modes/theme/defaults/alabaster.json +0 -94
  52. package/src/modes/theme/defaults/amethyst.json +0 -97
  53. package/src/modes/theme/defaults/anthracite.json +0 -94
  54. package/src/modes/theme/defaults/basalt.json +0 -92
  55. package/src/modes/theme/defaults/birch.json +0 -96
  56. package/src/modes/theme/defaults/dark-abyss.json +0 -92
  57. package/src/modes/theme/defaults/dark-arctic.json +0 -105
  58. package/src/modes/theme/defaults/dark-aurora.json +0 -96
  59. package/src/modes/theme/defaults/dark-catppuccin.json +0 -108
  60. package/src/modes/theme/defaults/dark-cavern.json +0 -92
  61. package/src/modes/theme/defaults/dark-copper.json +0 -96
  62. package/src/modes/theme/defaults/dark-cosmos.json +0 -91
  63. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -103
  64. package/src/modes/theme/defaults/dark-dracula.json +0 -99
  65. package/src/modes/theme/defaults/dark-eclipse.json +0 -92
  66. package/src/modes/theme/defaults/dark-ember.json +0 -96
  67. package/src/modes/theme/defaults/dark-equinox.json +0 -91
  68. package/src/modes/theme/defaults/dark-forest.json +0 -97
  69. package/src/modes/theme/defaults/dark-github.json +0 -106
  70. package/src/modes/theme/defaults/dark-gruvbox.json +0 -113
  71. package/src/modes/theme/defaults/dark-lavender.json +0 -96
  72. package/src/modes/theme/defaults/dark-lunar.json +0 -90
  73. package/src/modes/theme/defaults/dark-midnight.json +0 -96
  74. package/src/modes/theme/defaults/dark-monochrome.json +0 -95
  75. package/src/modes/theme/defaults/dark-monokai.json +0 -99
  76. package/src/modes/theme/defaults/dark-nebula.json +0 -91
  77. package/src/modes/theme/defaults/dark-nord.json +0 -98
  78. package/src/modes/theme/defaults/dark-ocean.json +0 -102
  79. package/src/modes/theme/defaults/dark-one.json +0 -101
  80. package/src/modes/theme/defaults/dark-poimandres.json +0 -139
  81. package/src/modes/theme/defaults/dark-rainforest.json +0 -92
  82. package/src/modes/theme/defaults/dark-reef.json +0 -92
  83. package/src/modes/theme/defaults/dark-retro.json +0 -93
  84. package/src/modes/theme/defaults/dark-rose-pine.json +0 -97
  85. package/src/modes/theme/defaults/dark-sakura.json +0 -96
  86. package/src/modes/theme/defaults/dark-slate.json +0 -96
  87. package/src/modes/theme/defaults/dark-solarized.json +0 -98
  88. package/src/modes/theme/defaults/dark-solstice.json +0 -91
  89. package/src/modes/theme/defaults/dark-starfall.json +0 -92
  90. package/src/modes/theme/defaults/dark-sunset.json +0 -100
  91. package/src/modes/theme/defaults/dark-swamp.json +0 -91
  92. package/src/modes/theme/defaults/dark-synthwave.json +0 -104
  93. package/src/modes/theme/defaults/dark-taiga.json +0 -92
  94. package/src/modes/theme/defaults/dark-terminal.json +0 -96
  95. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -102
  96. package/src/modes/theme/defaults/dark-tundra.json +0 -92
  97. package/src/modes/theme/defaults/dark-twilight.json +0 -92
  98. package/src/modes/theme/defaults/dark-volcanic.json +0 -92
  99. package/src/modes/theme/defaults/graphite.json +0 -93
  100. package/src/modes/theme/defaults/light-arctic.json +0 -108
  101. package/src/modes/theme/defaults/light-aurora-day.json +0 -92
  102. package/src/modes/theme/defaults/light-canyon.json +0 -92
  103. package/src/modes/theme/defaults/light-catppuccin.json +0 -107
  104. package/src/modes/theme/defaults/light-cirrus.json +0 -91
  105. package/src/modes/theme/defaults/light-coral.json +0 -96
  106. package/src/modes/theme/defaults/light-cyberpunk.json +0 -97
  107. package/src/modes/theme/defaults/light-dawn.json +0 -91
  108. package/src/modes/theme/defaults/light-dunes.json +0 -92
  109. package/src/modes/theme/defaults/light-eucalyptus.json +0 -96
  110. package/src/modes/theme/defaults/light-forest.json +0 -101
  111. package/src/modes/theme/defaults/light-frost.json +0 -96
  112. package/src/modes/theme/defaults/light-github.json +0 -116
  113. package/src/modes/theme/defaults/light-glacier.json +0 -92
  114. package/src/modes/theme/defaults/light-gruvbox.json +0 -109
  115. package/src/modes/theme/defaults/light-haze.json +0 -91
  116. package/src/modes/theme/defaults/light-honeycomb.json +0 -96
  117. package/src/modes/theme/defaults/light-lagoon.json +0 -92
  118. package/src/modes/theme/defaults/light-lavender.json +0 -96
  119. package/src/modes/theme/defaults/light-meadow.json +0 -92
  120. package/src/modes/theme/defaults/light-mint.json +0 -96
  121. package/src/modes/theme/defaults/light-monochrome.json +0 -102
  122. package/src/modes/theme/defaults/light-ocean.json +0 -100
  123. package/src/modes/theme/defaults/light-one.json +0 -100
  124. package/src/modes/theme/defaults/light-opal.json +0 -92
  125. package/src/modes/theme/defaults/light-orchard.json +0 -92
  126. package/src/modes/theme/defaults/light-paper.json +0 -96
  127. package/src/modes/theme/defaults/light-poimandres.json +0 -139
  128. package/src/modes/theme/defaults/light-prism.json +0 -91
  129. package/src/modes/theme/defaults/light-retro.json +0 -99
  130. package/src/modes/theme/defaults/light-sand.json +0 -96
  131. package/src/modes/theme/defaults/light-savanna.json +0 -92
  132. package/src/modes/theme/defaults/light-solarized.json +0 -103
  133. package/src/modes/theme/defaults/light-soleil.json +0 -91
  134. package/src/modes/theme/defaults/light-sunset.json +0 -100
  135. package/src/modes/theme/defaults/light-synthwave.json +0 -99
  136. package/src/modes/theme/defaults/light-tokyo-night.json +0 -112
  137. package/src/modes/theme/defaults/light-wetland.json +0 -92
  138. package/src/modes/theme/defaults/light-zenith.json +0 -90
  139. package/src/modes/theme/defaults/limestone.json +0 -95
  140. package/src/modes/theme/defaults/mahogany.json +0 -98
  141. package/src/modes/theme/defaults/marble.json +0 -94
  142. package/src/modes/theme/defaults/obsidian.json +0 -92
  143. package/src/modes/theme/defaults/onyx.json +0 -92
  144. package/src/modes/theme/defaults/pearl.json +0 -94
  145. package/src/modes/theme/defaults/porcelain.json +0 -92
  146. package/src/modes/theme/defaults/quartz.json +0 -97
  147. package/src/modes/theme/defaults/sandstone.json +0 -96
  148. package/src/modes/theme/defaults/titanium.json +0 -91
  149. package/src/modes/theme/light.json +0 -97
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.5.3] - 2026-04-22
6
+
7
+ ### Removed
8
+
9
+ - **All built-in themes except `xcsh-dark` and `xcsh-light` have been removed.** The 99 community themes previously shipped under `packages/coding-agent/src/modes/theme/defaults/` (`dark-ocean`, `dark-dracula`, `titanium`, `anthracite`, etc.), as well as the base `dark` and `light` themes, are no longer included. The hardcoded fallback when a user's theme fails to load now targets `xcsh-dark`. Users on a removed theme will fall back to `xcsh-dark` on next launch. User-authored themes under `~/.xcsh/agent/themes/` are unaffected.
10
+
11
+ ### Changed
12
+
13
+ - **TUI tool-call outcomes now render as a single colored gutter ball (●).** Inline `✓/✗/⚠` status icons were removed from every tool renderer. A new "warning" outcome (orange ball) signals non-error degraded states such as grep with zero matches, exa with zero results, ast-grep with zero matches, or ask falling back to a headless/empty-questions path. `xcsh-dark` uses `warmAmber` (#ffb347); `xcsh-light` uses `#b85e00` (WCAG AA on white). User-authored themes inherit via the `gutterWarning → warning` fallback chain.
14
+ - **`AgentToolResult` and `tool_execution_end` events gain an optional `isWarning?: boolean`** alongside `isError`. Tools that want an orange gutter ball on zero-result or fallback paths set this field (or use the new `ToolResultBuilder.isWarning()` fluent method). Extensions subscribing to `tool_execution_end` and `tool_result` hook events now receive the flag; existing extensions are unaffected. Backwards-compatible — unset means "not a warning."
15
+ - **`formatEmptyMessage` and `formatErrorMessage` helpers no longer prepend ⚠/✗ glyphs.** Centralized fix so every tool renderer inherits the glyph-free output without a per-file workaround.
16
+
17
+ Refs: #173.
18
+
5
19
  ## [18.1.1] - 2026-04-20
6
20
 
7
21
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.5.2",
4
+ "version": "18.5.3",
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.5.2",
51
- "@f5xc-salesdemos/pi-agent-core": "18.5.2",
52
- "@f5xc-salesdemos/pi-ai": "18.5.2",
53
- "@f5xc-salesdemos/pi-natives": "18.5.2",
54
- "@f5xc-salesdemos/pi-tui": "18.5.2",
55
- "@f5xc-salesdemos/pi-utils": "18.5.2",
50
+ "@f5xc-salesdemos/xcsh-stats": "18.5.3",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.5.3",
52
+ "@f5xc-salesdemos/pi-ai": "18.5.3",
53
+ "@f5xc-salesdemos/pi-natives": "18.5.3",
54
+ "@f5xc-salesdemos/pi-tui": "18.5.3",
55
+ "@f5xc-salesdemos/pi-utils": "18.5.3",
56
56
  "@sinclair/typebox": "^0.34",
57
57
  "@xterm/headless": "^6.0",
58
58
  "ajv": "^8.18",
@@ -9,7 +9,7 @@ import { THINKING_EFFORTS } from "@f5xc-salesdemos/pi-ai";
9
9
  *
10
10
  * The Settings singleton provides type-safe path-based access:
11
11
  * settings.get("compaction.enabled") // => boolean
12
- * settings.set("theme.dark", "titanium") // sync, saves in background
12
+ * settings.set("theme.dark", "xcsh-dark") // sync, saves in background
13
13
  */
14
14
 
15
15
  // ═══════════════════════════════════════════════════════════════════════════
@@ -5,7 +5,7 @@
5
5
  * import { settings } from "./settings";
6
6
  *
7
7
  * const enabled = settings.get("compaction.enabled"); // sync read
8
- * settings.set("theme.dark", "titanium"); // sync write, saves in background
8
+ * settings.set("theme.dark", "xcsh-dark"); // sync write, saves in background
9
9
  *
10
10
  * For tests:
11
11
  * const isolated = Settings.isolated({ "compaction.enabled": false });
package/src/cursor.ts CHANGED
@@ -89,7 +89,14 @@ async function executeTool(
89
89
  isError = true;
90
90
  }
91
91
 
92
- options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
92
+ options.emitEvent?.({
93
+ type: "tool_execution_end",
94
+ toolCallId,
95
+ toolName,
96
+ result,
97
+ isError,
98
+ isWarning: Boolean(result.isWarning),
99
+ });
93
100
 
94
101
  return createToolResultMessage(toolCallId, toolName, result, isError);
95
102
  }
@@ -124,7 +131,14 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string,
124
131
  isError = true;
125
132
  }
126
133
 
127
- options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
134
+ options.emitEvent?.({
135
+ type: "tool_execution_end",
136
+ toolCallId,
137
+ toolName,
138
+ result,
139
+ isError,
140
+ isWarning: Boolean(result.isWarning),
141
+ });
128
142
  return createToolResultMessage(toolCallId, toolName, result, isError);
129
143
  }
130
144
 
@@ -265,7 +279,14 @@ export class CursorExecHandlers implements ICursorExecHandlers {
265
279
  callbacks.onStdout(finalText.slice(streamedLen));
266
280
  }
267
281
 
268
- this.options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
282
+ this.options.emitEvent?.({
283
+ type: "tool_execution_end",
284
+ toolCallId,
285
+ toolName,
286
+ result,
287
+ isError,
288
+ isWarning: Boolean(result.isWarning),
289
+ });
269
290
  return createToolResultMessage(toolCallId, toolName, result, isError);
270
291
  }
271
292
 
@@ -523,7 +523,6 @@ function renderSingleFileResult(
523
523
 
524
524
  const header = renderStatusLine(
525
525
  {
526
- icon: isError ? "error" : "success",
527
526
  title: getOperationTitle(op),
528
527
  description,
529
528
  },
@@ -37,9 +37,11 @@ export function createExaTool(
37
37
 
38
38
  if (formatResponse && isSearchResponse(response)) {
39
39
  const formatted = formatSearchResults(response);
40
+ const resultCount = response.results?.length ?? 0;
40
41
  return {
41
42
  content: [{ type: "text" as const, text: formatted }],
42
43
  details: { response, toolName: name },
44
+ ...(resultCount === 0 ? { isWarning: true } : {}),
43
45
  };
44
46
  }
45
47
 
@@ -253,9 +253,11 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
253
253
 
254
254
  if (isSearchResponse(response)) {
255
255
  const formatted = formatSearchResults(response);
256
+ const resultCount = response.results?.length ?? 0;
256
257
  return {
257
258
  content: [{ type: "text" as const, text: formatted }],
258
259
  details: { response, toolName: this.config.name },
260
+ ...(resultCount === 0 ? { isWarning: true } : {}),
259
261
  };
260
262
  }
261
263
 
package/src/exa/render.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  formatCount,
13
13
  formatExpandHint,
14
14
  formatMoreItems,
15
- formatStatusIcon,
16
15
  getDomain,
17
16
  getPreviewLines,
18
17
  PREVIEW_LIMITS,
@@ -30,15 +29,11 @@ const MAX_HIGHLIGHT_LEN = TRUNCATE_LENGTHS.CONTENT;
30
29
 
31
30
  function renderErrorMessage(message: string, theme: Theme): Text {
32
31
  const clean = message.replace(/^Error:\s*/, "").trim();
33
- return new Text(
34
- `${formatStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
35
- 0,
36
- 0,
37
- );
32
+ return new Text(theme.fg("error", `Error: ${clean || "Unknown error"}`), 0, 0);
38
33
  }
39
34
 
40
35
  function renderEmptyMessage(message: string, theme: Theme): Text {
41
- return new Text(`${formatStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0);
36
+ return new Text(theme.fg("muted", message), 0, 0);
42
37
  }
43
38
 
44
39
  /** Render Exa result with tree-based layout */
@@ -65,7 +60,7 @@ export function renderExaResult(
65
60
  const remaining = rawLines.length - maxLines;
66
61
  const expandHint = formatExpandHint(uiTheme, expanded, remaining > 0);
67
62
 
68
- let text = `${formatStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${expandHint}`;
63
+ let text = `${uiTheme.fg("dim", "Raw response")}${expandHint}`;
69
64
 
70
65
  for (let i = 0; i < displayLines.length; i++) {
71
66
  const isLast = i === displayLines.length - 1 && remaining === 0;
@@ -93,8 +88,6 @@ export function renderExaResult(
93
88
  const cost = response.costDollars?.total;
94
89
  const time = response.searchTime;
95
90
 
96
- const icon = formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
97
-
98
91
  const metaParts = [formatCount("result", resultCount)];
99
92
  if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`);
100
93
  if (time !== undefined) metaParts.push(`time:${time.toFixed(2)}s`);
@@ -108,7 +101,7 @@ export function renderExaResult(
108
101
  }
109
102
  const expandHint = formatExpandHint(uiTheme, expanded, hasMorePreview);
110
103
 
111
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
104
+ let text = `${uiTheme.fg("dim", summaryText)}${expandHint}`;
112
105
 
113
106
  if (!expanded) {
114
107
  if (resultCount === 0) {
@@ -90,6 +90,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
90
90
  // Only appended for persistent shell sessions (one-shot shells don't persist CWD).
91
91
  const CWD_SENTINEL_START = "__XCSH_CWD__:";
92
92
  const CWD_SENTINEL_END = ":__XCSH_CWD_END__";
93
+ // Exit-code capture sentinel — the persistent shell's own exit code reflects
94
+ // the trailing printf (always 0), so the user command's actual exit status
95
+ // must be captured in-band. `$?` is referenced directly as a printf argument
96
+ // (see finalCommand below) because a variable-assignment capture like
97
+ // `_x=$?` resets `$?` to 0 in brush-core before the RHS is evaluated. Without
98
+ // this sentinel, subprocess failures like `false`, `ls /nonexistent`, or
99
+ // `(exit 3)` silently report success.
100
+ const EXIT_SENTINEL_START = "__XCSH_EXIT__:";
101
+ const EXIT_SENTINEL_END = ":__XCSH_EXIT_END__";
93
102
 
94
103
  // Create output sink for truncation and artifact handling
95
104
  const sink = new OutputSink({
@@ -129,9 +138,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
129
138
  shellSessions.set(sessionKey, shellSession);
130
139
  }
131
140
 
132
- // Append CWD sentinel only for persistent shell sessions
141
+ // Append CWD + exit-code sentinels only for persistent shell sessions.
142
+ // `$?` must be referenced DIRECTLY in the printf arguments — using a
143
+ // variable assignment like `_x=$?` first resets `$?` to 0 in brush-core
144
+ // before the RHS is evaluated, defeating the capture. The persistent
145
+ // shell's winner.exitCode always reflects the printf's return (0), so
146
+ // the EXIT sentinel is the authoritative source for the user command's
147
+ // actual exit status.
133
148
  const finalCommand = shellSession
134
- ? `${prefixedCommand}\nprintf '${CWD_SENTINEL_START}%s${CWD_SENTINEL_END}\\n' "$PWD"`
149
+ ? `${prefixedCommand}\nprintf '${CWD_SENTINEL_START}%s${CWD_SENTINEL_END}\\n${EXIT_SENTINEL_START}%s${EXIT_SENTINEL_END}\\n' "$PWD" "$?"`
135
150
  : prefixedCommand;
136
151
  const userSignal = options?.signal;
137
152
  const runAbortController = new AbortController();
@@ -237,32 +252,52 @@ export async function executeBash(command: string, options?: BashExecutorOptions
237
252
  };
238
253
  }
239
254
 
240
- // Parse CWD sentinel from output, strip it from the displayed result.
255
+ // Parse CWD + EXIT sentinels from output and strip them from the displayed
256
+ // result. Exit sentinel override is the authoritative exit code for
257
+ // persistent shell sessions — `winner.result.exitCode` would otherwise
258
+ // be the trailing printf's exit code (always 0).
241
259
  const dumpResult = await sink.dump();
242
260
  let newCwd: string | undefined;
261
+ let overrideExitCode: number | undefined;
243
262
  if (shellSession) {
244
- const sentinelIdx = dumpResult.output.lastIndexOf(CWD_SENTINEL_START);
245
- if (sentinelIdx !== -1) {
246
- const endIdx = dumpResult.output.indexOf(CWD_SENTINEL_END, sentinelIdx);
247
- if (endIdx !== -1) {
248
- const captured = dumpResult.output.slice(sentinelIdx + CWD_SENTINEL_START.length, endIdx).trim();
263
+ const cwdIdx = dumpResult.output.lastIndexOf(CWD_SENTINEL_START);
264
+ if (cwdIdx !== -1) {
265
+ const cwdEndIdx = dumpResult.output.indexOf(CWD_SENTINEL_END, cwdIdx);
266
+ if (cwdEndIdx !== -1) {
267
+ const captured = dumpResult.output.slice(cwdIdx + CWD_SENTINEL_START.length, cwdEndIdx).trim();
249
268
  if (captured) newCwd = captured;
250
- // Strip the sentinel from displayed output (from sentinel start to end of its line)
251
- const afterLine = dumpResult.output.indexOf("\n", endIdx + CWD_SENTINEL_END.length);
269
+ // Parse the adjacent exit sentinel (on the next printf line) if present.
270
+ const exitIdx = dumpResult.output.indexOf(EXIT_SENTINEL_START, cwdEndIdx);
271
+ let stripEndIdx = cwdEndIdx + CWD_SENTINEL_END.length;
272
+ let linesRemoved = 1;
273
+ if (exitIdx !== -1) {
274
+ const exitEndIdx = dumpResult.output.indexOf(EXIT_SENTINEL_END, exitIdx);
275
+ if (exitEndIdx !== -1) {
276
+ const exitCaptured = dumpResult.output
277
+ .slice(exitIdx + EXIT_SENTINEL_START.length, exitEndIdx)
278
+ .trim();
279
+ const parsed = Number.parseInt(exitCaptured, 10);
280
+ if (Number.isFinite(parsed)) overrideExitCode = parsed;
281
+ stripEndIdx = exitEndIdx + EXIT_SENTINEL_END.length;
282
+ linesRemoved = 2;
283
+ }
284
+ }
285
+ // Strip both sentinel lines from displayed output.
286
+ const afterLine = dumpResult.output.indexOf("\n", stripEndIdx);
252
287
  const stripEnd = afterLine === -1 ? dumpResult.output.length : afterLine + 1;
253
- const strippedBytes = stripEnd - sentinelIdx;
254
- dumpResult.output = dumpResult.output.slice(0, sentinelIdx) + dumpResult.output.slice(stripEnd);
288
+ const strippedBytes = stripEnd - cwdIdx;
289
+ dumpResult.output = dumpResult.output.slice(0, cwdIdx) + dumpResult.output.slice(stripEnd);
255
290
  dumpResult.totalBytes = Math.max(0, dumpResult.totalBytes - strippedBytes);
256
- dumpResult.totalLines = Math.max(0, dumpResult.totalLines - 1);
291
+ dumpResult.totalLines = Math.max(0, dumpResult.totalLines - linesRemoved);
257
292
  dumpResult.outputBytes = dumpResult.output.length;
258
- dumpResult.outputLines = Math.max(0, dumpResult.outputLines - 1);
293
+ dumpResult.outputLines = Math.max(0, dumpResult.outputLines - linesRemoved);
259
294
  }
260
295
  }
261
296
  }
262
297
 
263
298
  // Normal completion
264
299
  return {
265
- exitCode: winner.result.exitCode,
300
+ exitCode: overrideExitCode ?? winner.result.exitCode,
266
301
  cancelled: false,
267
302
  newCwd,
268
303
  ...dumpResult,
@@ -565,6 +565,8 @@ export interface ToolExecutionEndEvent {
565
565
  toolName: string;
566
566
  result: unknown;
567
567
  isError: boolean;
568
+ /** Tool completed without error but produced a degraded/empty result (e.g. grep 0 matches). */
569
+ isWarning?: boolean;
568
570
  }
569
571
 
570
572
  /** Fired when auto-compaction starts */
@@ -468,6 +468,8 @@ interface ToolResultEventBase {
468
468
  content: (TextContent | ImageContent)[];
469
469
  /** Whether the tool execution was an error */
470
470
  isError?: boolean;
471
+ /** Tool completed without error but produced a degraded/empty result (e.g. grep 0 matches). */
472
+ isWarning?: boolean;
471
473
  }
472
474
 
473
475
  /** Tool result event for bash tool */
@@ -581,6 +583,8 @@ export interface ToolResultEventResult {
581
583
  details?: unknown;
582
584
  /** Override isError flag */
583
585
  isError?: boolean;
586
+ /** Override isWarning flag */
587
+ isWarning?: boolean;
584
588
  }
585
589
 
586
590
  /**
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.5.2",
21
- "commit": "241bff6e524c48aacf4e14b31b8fc5b39d7f9047",
22
- "shortCommit": "241bff6",
20
+ "version": "18.5.3",
21
+ "commit": "94fee5084a501db13d610c4844a763e47314cfc9",
22
+ "shortCommit": "94fee50",
23
23
  "branch": "main",
24
- "tag": "v18.5.2",
25
- "commitDate": "2026-04-21T20:23:54Z",
26
- "buildDate": "2026-04-21T20:47:19.277Z",
24
+ "tag": "v18.5.3",
25
+ "commitDate": "2026-04-22T03:46:25Z",
26
+ "buildDate": "2026-04-22T04:28:15.429Z",
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/241bff6e524c48aacf4e14b31b8fc5b39d7f9047",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.5.2"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/94fee5084a501db13d610c4844a763e47314cfc9",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.5.3"
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 (`dark.json`, `light.json`, and all `defaults/*.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 = \"titanium\"`\n- `theme.light = \"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:\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",
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",
@@ -95,8 +95,7 @@ export class BtwPanelComponent extends Container {
95
95
  }
96
96
  const text = replaceTabs(this.#answer).trim();
97
97
  if (!text) {
98
- const waiting =
99
- this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
98
+ const waiting = this.#state === "running" ? "Waiting for response…" : "No text returned.";
100
99
  return new Text(theme.fg("dim", waiting), 1, 0);
101
100
  }
102
101
  return new Markdown(text, 1, 0, getMarkdownTheme());