@f5xc-salesdemos/xcsh 18.2.1 → 18.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.2.1",
4
+ "version": "18.4.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "dependencies": {
48
48
  "@agentclientprotocol/sdk": "0.16.1",
49
49
  "@mozilla/readability": "^0.6",
50
- "@f5xc-salesdemos/xcsh-stats": "18.2.1",
51
- "@f5xc-salesdemos/pi-agent-core": "18.2.1",
52
- "@f5xc-salesdemos/pi-ai": "18.2.1",
53
- "@f5xc-salesdemos/pi-natives": "18.2.1",
54
- "@f5xc-salesdemos/pi-tui": "18.2.1",
55
- "@f5xc-salesdemos/pi-utils": "18.2.1",
50
+ "@f5xc-salesdemos/xcsh-stats": "18.4.0",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.4.0",
52
+ "@f5xc-salesdemos/pi-ai": "18.4.0",
53
+ "@f5xc-salesdemos/pi-natives": "18.4.0",
54
+ "@f5xc-salesdemos/pi-tui": "18.4.0",
55
+ "@f5xc-salesdemos/pi-utils": "18.4.0",
56
56
  "@sinclair/typebox": "^0.34",
57
57
  "@xterm/headless": "^6.0",
58
58
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.2.1",
21
- "commit": "a9a7a85e9085f1327823664b584feaccf2c33527",
22
- "shortCommit": "a9a7a85",
20
+ "version": "18.4.0",
21
+ "commit": "f388891f65f5ccdae8bb220c9471768d8fbc58b7",
22
+ "shortCommit": "f388891",
23
23
  "branch": "main",
24
- "tag": "v18.2.1",
25
- "commitDate": "2026-04-21T01:47:20Z",
26
- "buildDate": "2026-04-21T02:13:22.611Z",
24
+ "tag": "v18.4.0",
25
+ "commitDate": "2026-04-21T04:54:23Z",
26
+ "buildDate": "2026-04-21T05:21:37.774Z",
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/a9a7a85e9085f1327823664b584feaccf2c33527",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.2.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f388891f65f5ccdae8bb220c9471768d8fbc58b7",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.4.0"
33
33
  };
@@ -7,6 +7,7 @@ import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } fr
7
7
  import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
9
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
10
+ import { sanitizeErrorMessage } from "../utils/sanitize-error-message";
10
11
  import { DynamicBorder } from "./dynamic-border";
11
12
  import { truncateToVisualLines } from "./visual-truncate";
12
13
 
@@ -43,8 +44,14 @@ const CHUNK_THROTTLE_MS = 50;
43
44
 
44
45
  export class BashExecutionComponent extends Container {
45
46
  #outputLines: string[] = [];
46
- #status: "running" | "complete" | "cancelled" | "error" = "running";
47
+ // Failure-mode taxonomy:
48
+ // "complete" — zero exit
49
+ // "error" — non-zero exit (shell reported failure)
50
+ // "cancelled" — user cancelled (e.g. pressed Esc)
51
+ // "errored" — uncaught exception (executor threw; no exit code)
52
+ #status: "running" | "complete" | "cancelled" | "error" | "errored" = "running";
47
53
  #exitCode: number | undefined = undefined;
54
+ #errorMessage: string | undefined = undefined;
48
55
  #loader: Loader;
49
56
  #truncation?: TruncationMeta;
50
57
  #expanded = false;
@@ -136,6 +143,29 @@ export class BashExecutionComponent extends Container {
136
143
  this.#displayDirty = true;
137
144
  }
138
145
 
146
+ /**
147
+ * Gutter-outcome for this execution once a terminal status has been set.
148
+ * "error" for cancelled, non-zero exit, or an uncaught executor exception;
149
+ * "success" for clean zero exit; undefined while still running.
150
+ */
151
+ get outcome(): "success" | "error" | undefined {
152
+ if (this.#status === "running") return undefined;
153
+ return this.#status === "complete" ? "success" : "error";
154
+ }
155
+
156
+ /**
157
+ * Terminal state for an uncaught executor exception — distinct from a
158
+ * non-zero shell exit (which uses `setComplete`). Footer renders
159
+ * `(error: <message>)`. Idempotent after the first terminal call.
160
+ */
161
+ setError(err: Error | string): void {
162
+ if (this.#status !== "running") return;
163
+ this.#status = "errored";
164
+ this.#errorMessage = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
165
+ this.#loader.stop();
166
+ this.#updateDisplay();
167
+ }
168
+
139
169
  setComplete(
140
170
  exitCode: number | undefined,
141
171
  cancelled: boolean,
@@ -227,6 +257,12 @@ export class BashExecutionComponent extends Container {
227
257
  statusParts.push(theme.fg("warning", "(cancelled)"));
228
258
  } else if (this.#status === "error") {
229
259
  statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
260
+ } else if (this.#status === "errored") {
261
+ // `\u00a0` (NBSP) keeps the wrapper joined to the message so
262
+ // the Text component does not wrap at "(error:_<msg>)" in
263
+ // narrow terminals. Falls back to "unknown" if setError was
264
+ // never called with a message.
265
+ statusParts.push(theme.fg("error", `(error:\u00a0${this.#errorMessage ?? "unknown"})`));
230
266
  }
231
267
 
232
268
  if (this.#truncation) {
@@ -16,13 +16,21 @@ export interface GutterConfig {
16
16
  symbol: string;
17
17
  /** Color function for active state (used for static active indicator) */
18
18
  activeColorFn: (s: string) => string;
19
- /** Color function for done state */
19
+ /**
20
+ * Neutral done-state color. Used when `setDone()` is called without an
21
+ * outcome, or when the specific outcome color function is not configured.
22
+ */
20
23
  doneColorFn: (s: string) => string;
24
+ /** Optional color function used for `setDone("success")`. */
25
+ doneSuccessColorFn?: (s: string) => string;
26
+ /** Optional color function used for `setDone("error")`. */
27
+ doneErrorColorFn?: (s: string) => string;
21
28
  /** Whether to show spinner animation when active */
22
29
  animated: boolean;
23
30
  }
24
31
 
25
32
  type GutterState = "active" | "done";
33
+ type GutterOutcome = "success" | "error";
26
34
 
27
35
  /**
28
36
  * GutterBlock wraps a child component and prepends a 2-character left gutter
@@ -33,6 +41,7 @@ export class GutterBlock<T extends Component> implements Component {
33
41
  #child: T;
34
42
  #config: GutterConfig;
35
43
  #state: GutterState;
44
+ #outcome?: GutterOutcome;
36
45
  #ui: TUI;
37
46
 
38
47
  // Spinner state
@@ -60,8 +69,9 @@ export class GutterBlock<T extends Component> implements Component {
60
69
  return this.#state;
61
70
  }
62
71
 
63
- setDone(): void {
72
+ setDone(outcome?: GutterOutcome): void {
64
73
  if (this.#state === "done") return;
74
+ this.#outcome = outcome;
65
75
  this.#state = "done";
66
76
  this.#stopSpinner();
67
77
  this.#ui.requestRender();
@@ -125,7 +135,8 @@ export class GutterBlock<T extends Component> implements Component {
125
135
 
126
136
  #buildGutterPrefix(): string {
127
137
  if (this.#state === "done") {
128
- return `${this.#config.doneColorFn(this.#config.symbol)} `;
138
+ const colorFn = this.#doneColorFnForOutcome();
139
+ return `${colorFn(this.#config.symbol)} `;
129
140
  }
130
141
 
131
142
  if (this.#config.animated) {
@@ -136,6 +147,16 @@ export class GutterBlock<T extends Component> implements Component {
136
147
  return `${this.#config.activeColorFn(this.#config.symbol)} `;
137
148
  }
138
149
 
150
+ #doneColorFnForOutcome(): (s: string) => string {
151
+ if (this.#outcome === "error" && this.#config.doneErrorColorFn) {
152
+ return this.#config.doneErrorColorFn;
153
+ }
154
+ if (this.#outcome === "success" && this.#config.doneSuccessColorFn) {
155
+ return this.#config.doneSuccessColorFn;
156
+ }
157
+ return this.#config.doneColorFn;
158
+ }
159
+
139
160
  #startSpinner(): void {
140
161
  this.#intervalId = setInterval(() => {
141
162
  this.#currentFrame = (this.#currentFrame + 1) % this.#spinnerFrames.length;
@@ -183,12 +204,22 @@ export class DisposableContainer extends Container {
183
204
  // Factory functions
184
205
  // ============================================================================
185
206
 
186
- /** Animated ● gutter for active tool calls — spinner in spinnerAccent, done in dim */
207
+ /**
208
+ * Animated ● gutter for tool calls and slash-command executions.
209
+ * Active: spinner in `spinnerAccent`.
210
+ * Done (unknown outcome): `dim` — neutral "completed" color when the call
211
+ * site does not have success/error information.
212
+ * Done (success): `gutterSuccess` (falls back to `success` when the theme
213
+ * does not define the dedicated token).
214
+ * Done (error): `gutterError` (falls back to `error`).
215
+ */
187
216
  export function createToolGutter<T extends Component>(ui: TUI, child: T): GutterBlock<T> {
188
217
  return new GutterBlock(ui, child, {
189
218
  symbol: "●",
190
219
  activeColorFn: (s: string) => theme.fg("spinnerAccent", s),
191
220
  doneColorFn: (s: string) => theme.fg("dim", s),
221
+ doneSuccessColorFn: (s: string) => theme.fg("gutterSuccess", s),
222
+ doneErrorColorFn: (s: string) => theme.fg("gutterError", s),
192
223
  animated: true,
193
224
  });
194
225
  }
@@ -7,6 +7,7 @@ import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
7
7
  import { Container, Loader, Spacer, Text, type TUI } from "@f5xc-salesdemos/pi-tui";
8
8
  import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
9
9
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
10
+ import { sanitizeErrorMessage } from "../utils/sanitize-error-message";
10
11
  import { DynamicBorder } from "./dynamic-border";
11
12
  import { truncateToVisualLines } from "./visual-truncate";
12
13
 
@@ -36,8 +37,14 @@ function highlightIfStructured(lines: string[]): string[] | undefined {
36
37
 
37
38
  export class PythonExecutionComponent extends Container {
38
39
  #outputLines: string[] = [];
39
- #status: "running" | "complete" | "cancelled" | "error" = "running";
40
+ // Failure-mode taxonomy:
41
+ // "complete" — zero exit
42
+ // "error" — non-zero exit (interpreter reported failure)
43
+ // "cancelled" — user cancelled
44
+ // "errored" — uncaught exception (executor threw; no exit code)
45
+ #status: "running" | "complete" | "cancelled" | "error" | "errored" = "running";
40
46
  #exitCode: number | undefined = undefined;
47
+ #errorMessage: string | undefined = undefined;
41
48
  #loader: Loader;
42
49
  #truncation?: TruncationMeta;
43
50
  #expanded = false;
@@ -107,6 +114,29 @@ export class PythonExecutionComponent extends Container {
107
114
  this.#updateDisplay();
108
115
  }
109
116
 
117
+ /**
118
+ * Gutter-outcome for this execution once a terminal status has been set.
119
+ * "error" for cancelled, non-zero exit, or an uncaught executor exception;
120
+ * "success" for clean zero exit; undefined while still running.
121
+ */
122
+ get outcome(): "success" | "error" | undefined {
123
+ if (this.#status === "running") return undefined;
124
+ return this.#status === "complete" ? "success" : "error";
125
+ }
126
+
127
+ /**
128
+ * Terminal state for an uncaught executor exception — distinct from a
129
+ * non-zero interpreter exit (which uses `setComplete`). Footer renders
130
+ * `(error: <message>)`. Idempotent after the first terminal call.
131
+ */
132
+ setError(err: Error | string): void {
133
+ if (this.#status !== "running") return;
134
+ this.#status = "errored";
135
+ this.#errorMessage = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
136
+ this.#loader.stop();
137
+ this.#updateDisplay();
138
+ }
139
+
110
140
  setComplete(
111
141
  exitCode: number | undefined,
112
142
  cancelled: boolean,
@@ -174,6 +204,11 @@ export class PythonExecutionComponent extends Container {
174
204
  statusParts.push(theme.fg("warning", "(cancelled)"));
175
205
  } else if (this.#status === "error") {
176
206
  statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
207
+ } else if (this.#status === "errored") {
208
+ // `\u00a0` (NBSP) keeps the wrapper joined to the message so
209
+ // the Text component does not wrap at "(error:_<msg>)" in
210
+ // narrow terminals.
211
+ statusParts.push(theme.fg("error", `(error:\u00a0${this.#errorMessage ?? "unknown"})`));
177
212
  }
178
213
 
179
214
  if (this.#truncation) {
@@ -1,5 +1,6 @@
1
1
  import { Box, Container, Spacer, Text } from "@f5xc-salesdemos/pi-tui";
2
2
  import { theme } from "../../modes/theme/theme";
3
+ import { renderTodoSummary } from "../../tools/todo-render";
3
4
  import type { TodoItem } from "../../tools/todo-write";
4
5
 
5
6
  /**
@@ -43,5 +44,11 @@ export class TodoReminderComponent extends Container {
43
44
  })
44
45
  .join("\n");
45
46
  this.#box.addChild(new Text(theme.italic(todoList), 0, 0));
47
+
48
+ const summary = renderTodoSummary(this.todos, theme);
49
+ if (summary !== null) {
50
+ this.#box.addChild(new Spacer(1));
51
+ this.#box.addChild(new Text(summary, 0, 0));
52
+ }
46
53
  }
47
54
  }
@@ -1,4 +1,4 @@
1
- import { Container, Markdown } from "@f5xc-salesdemos/pi-tui";
1
+ import { Container, Markdown, padding, Spacer, visibleWidth } from "@f5xc-salesdemos/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
3
 
4
4
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers
@@ -6,12 +6,18 @@ const OSC133_ZONE_START = "\x1b]133;A\x07";
6
6
  const OSC133_ZONE_END = "\x1b]133;B\x07";
7
7
  const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
8
8
 
9
- // U+258C LEFT HALF BLOCKtheme-coloured left bar, matches chrome accents.
10
- const BAR_PREFIX = "";
11
- const BAR_PREFIX_WIDTH = 2;
9
+ // U+2503 BOX DRAWINGS HEAVY VERTICAL continuation bar on wrapped lines.
10
+ const CONTINUATION_BAR = "";
11
+ // Markdown child uses paddingX=1 and clamps contentWidth>=1, so its minimum
12
+ // render output is 3 terminal cells. Anything narrower than prefix+3 would
13
+ // overflow the requested width — bail out instead.
14
+ const MIN_MARKDOWN_WIDTH = 3;
12
15
 
13
16
  /**
14
- * Component that renders a user message
17
+ * Renders a user message as an F5-branded admonition block: pi icon on the
18
+ * first content line, heavy vertical bar on continuations (both in `border`
19
+ * color), `userMessageBg` painted across the full requested width, and a
20
+ * leading blank spacer separating the prompt from the preceding block.
15
21
  */
16
22
  export class UserMessageComponent extends Container {
17
23
  constructor(text: string, synthetic = false) {
@@ -19,28 +25,45 @@ export class UserMessageComponent extends Container {
19
25
  const color = synthetic
20
26
  ? (value: string) => theme.fg("dim", value)
21
27
  : (value: string) => theme.fg("userMessageText", value);
22
- this.addChild(
23
- new Markdown(text, 1, 0, getMarkdownTheme(), {
24
- color,
25
- }),
26
- );
28
+ this.addChild(new Spacer(1));
29
+ this.addChild(new Markdown(text, 1, 0, getMarkdownTheme(), { color }));
27
30
  }
28
31
 
29
32
  override render(width: number): string[] {
30
- const innerWidth = width - BAR_PREFIX_WIDTH;
31
- if (innerWidth <= 0) {
33
+ const piPrefix = `${theme.icon.pi} `;
34
+ const contPrefix = `${CONTINUATION_BAR} `;
35
+ // Prefix width is theme-dependent — Unicode π is 1 col, Nerd Font
36
+ // glyph and ASCII "pi" are 2 cols. Measure both and reserve the
37
+ // larger so every content line leaves room for either shape.
38
+ const prefixWidth = Math.max(visibleWidth(piPrefix), visibleWidth(contPrefix));
39
+ const innerWidth = width - prefixWidth;
40
+ if (innerWidth < MIN_MARKDOWN_WIDTH) {
32
41
  return [];
33
42
  }
34
- const inner = super.render(innerWidth);
35
- if (inner.length === 0) {
36
- return inner;
43
+ const raw = super.render(innerWidth);
44
+ if (raw.length === 0) {
45
+ return raw;
37
46
  }
38
47
 
39
- const bar = theme.fg("border", BAR_PREFIX);
40
- const lines = inner.map(line => bar + line);
48
+ let firstContent = 0;
49
+ while (firstContent < raw.length && raw[firstContent] === "") {
50
+ firstContent++;
51
+ }
52
+ if (firstContent === raw.length) {
53
+ return raw;
54
+ }
55
+
56
+ const leading = raw.slice(0, firstContent);
57
+ const content = raw.slice(firstContent).map((line, i) => {
58
+ const prefix = theme.fg("border", i === 0 ? piPrefix : contPrefix);
59
+ const combined = prefix + line;
60
+ const pad = Math.max(0, width - visibleWidth(combined));
61
+ return theme.bg("userMessageBg", combined + padding(pad));
62
+ });
63
+
64
+ content[0] = OSC133_ZONE_START + content[0];
65
+ content[content.length - 1] = content[content.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
41
66
 
42
- lines[0] = OSC133_ZONE_START + lines[0];
43
- lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
44
- return lines;
67
+ return [...leading, ...content];
45
68
  }
46
69
  }
@@ -725,6 +725,7 @@ export class CommandController {
725
725
  }
726
726
  this.ctx.ui.requestRender();
727
727
 
728
+ let failed = false;
728
729
  try {
729
730
  const result = await this.ctx.session.executeBash(
730
731
  command,
@@ -750,14 +751,16 @@ export class CommandController {
750
751
  truncation: meta?.truncation,
751
752
  });
752
753
  }
754
+ failed = result.cancelled || (typeof result.exitCode === "number" && result.exitCode !== 0);
753
755
  } catch (error) {
756
+ failed = true;
754
757
  if (this.ctx.bashComponent) {
755
- this.ctx.bashComponent.setComplete(undefined, false);
758
+ this.ctx.bashComponent.setError(error instanceof Error ? error : String(error));
756
759
  }
757
760
  this.ctx.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
758
761
  }
759
762
 
760
- bashGutter?.setDone();
763
+ bashGutter?.setDone(failed ? "error" : "success");
761
764
  this.ctx.bashComponent = undefined;
762
765
  this.ctx.ui.requestRender();
763
766
  }
@@ -776,6 +779,7 @@ export class CommandController {
776
779
  }
777
780
  this.ctx.ui.requestRender();
778
781
 
782
+ let failed = false;
779
783
  try {
780
784
  const result = await this.ctx.session.executePython(
781
785
  code,
@@ -794,14 +798,16 @@ export class CommandController {
794
798
  truncation: meta?.truncation,
795
799
  });
796
800
  }
801
+ failed = result.cancelled || (typeof result.exitCode === "number" && result.exitCode !== 0);
797
802
  } catch (error) {
803
+ failed = true;
798
804
  if (this.ctx.pythonComponent) {
799
- this.ctx.pythonComponent.setComplete(undefined, false);
805
+ this.ctx.pythonComponent.setError(error instanceof Error ? error : String(error));
800
806
  }
801
807
  this.ctx.showError(`Python execution failed: ${error instanceof Error ? error.message : "Unknown error"}`);
802
808
  }
803
809
 
804
- pythonGutter?.setDone();
810
+ pythonGutter?.setDone(failed ? "error" : "success");
805
811
  this.ctx.pythonComponent = undefined;
806
812
  this.ctx.ui.requestRender();
807
813
  }
@@ -15,6 +15,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
15
15
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
16
16
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
17
17
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
18
+ import { ReadGroupOutcomeAggregator } from "../../modes/utils/read-group-outcome-aggregator";
18
19
  import type { AgentSessionEvent } from "../../session/agent-session";
19
20
  import { calculatePromptTokens } from "../../session/compaction/compaction";
20
21
  import type { ExitPlanModeDetails } from "../../tools";
@@ -31,6 +32,7 @@ export class EventController {
31
32
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
32
33
  #idleCompactionTimer?: NodeJS.Timeout;
33
34
  #pendingGutters = new Map<string, GutterBlock<any>>();
35
+ #readGroupAggregator = new ReadGroupOutcomeAggregator();
34
36
  // streamingAssistantGutter is stored on ctx for cross-controller access (e.g. thinking toggle)
35
37
  constructor(private ctx: InteractiveModeContext) {}
36
38
 
@@ -63,15 +65,20 @@ export class EventController {
63
65
  return this.#lastReadGroup;
64
66
  }
65
67
 
66
- /** Remove a read tool's gutter entry; setDone() if no other reads share the same group gutter */
67
- #cleanupReadGutter(toolCallId: string): void {
68
+ /**
69
+ * Remove a read tool's gutter entry. Records the caller-supplied outcome
70
+ * into the group aggregator; finalizes the gutter (calling `setDone` with
71
+ * the aggregated worst outcome) only when no other reads still share the
72
+ * same group gutter. The spinner stays active during the group lifetime.
73
+ */
74
+ #cleanupReadGutter(toolCallId: string, outcome: "success" | "error"): void {
68
75
  const gutter = this.#pendingGutters.get(toolCallId);
69
76
  this.#pendingGutters.delete(toolCallId);
70
- if (gutter) {
71
- const stillActive = Array.from(this.#pendingGutters.values()).some(g => g === gutter);
72
- if (!stillActive) {
73
- gutter.setDone();
74
- }
77
+ if (!gutter) return;
78
+ this.#readGroupAggregator.record(gutter, outcome);
79
+ const stillActive = Array.from(this.#pendingGutters.values()).some(g => g === gutter);
80
+ if (!stillActive) {
81
+ this.#readGroupAggregator.finalize(gutter);
75
82
  }
76
83
  }
77
84
 
@@ -366,7 +373,7 @@ export class EventController {
366
373
  event.toolCallId,
367
374
  );
368
375
  if (isFinalAsyncState) {
369
- this.#pendingGutters.get(event.toolCallId)?.setDone();
376
+ this.#pendingGutters.get(event.toolCallId)?.setDone(asyncState === "failed" ? "error" : "success");
370
377
  this.#pendingGutters.delete(event.toolCallId);
371
378
  this.ctx.pendingTools.delete(event.toolCallId);
372
379
  this.#backgroundToolCallIds.delete(event.toolCallId);
@@ -388,7 +395,7 @@ export class EventController {
388
395
  if (asyncState === "running") {
389
396
  this.#backgroundToolCallIds.add(event.toolCallId);
390
397
  } else {
391
- this.#cleanupReadGutter(event.toolCallId);
398
+ this.#cleanupReadGutter(event.toolCallId, event.isError ? "error" : "success");
392
399
  this.#backgroundToolCallIds.delete(event.toolCallId);
393
400
  this.#clearReadToolCall(event.toolCallId);
394
401
  }
@@ -414,7 +421,7 @@ export class EventController {
414
421
  if (isBackgroundRunning) {
415
422
  this.#backgroundToolCallIds.add(event.toolCallId);
416
423
  } else {
417
- this.#cleanupReadGutter(event.toolCallId);
424
+ this.#cleanupReadGutter(event.toolCallId, event.isError ? "error" : "success");
418
425
  this.ctx.pendingTools.delete(event.toolCallId);
419
426
  this.#backgroundToolCallIds.delete(event.toolCallId);
420
427
  this.#clearReadToolCall(event.toolCallId);
@@ -434,7 +441,7 @@ export class EventController {
434
441
  if (isBackgroundRunning) {
435
442
  this.#backgroundToolCallIds.add(event.toolCallId);
436
443
  } else {
437
- this.#pendingGutters.get(event.toolCallId)?.setDone();
444
+ this.#pendingGutters.get(event.toolCallId)?.setDone(event.isError ? "error" : "success");
438
445
  this.#pendingGutters.delete(event.toolCallId);
439
446
  this.ctx.pendingTools.delete(event.toolCallId);
440
447
  this.#backgroundToolCallIds.delete(event.toolCallId);
@@ -479,12 +486,34 @@ export class EventController {
479
486
  this.ctx.streamingMessage = undefined;
480
487
  }
481
488
  await this.ctx.flushPendingModelSwitch();
482
- for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
483
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
484
- this.#pendingGutters.get(toolCallId)?.setDone();
489
+ // Orphan pending tools at agent_end mean the turn aborted or
490
+ // errored before those tools completed. Mark the gutter as
491
+ // error so the live UI matches what a transcript rebuild
492
+ // renders for the same condition.
493
+ //
494
+ // We deliberately do NOT call `updateResult` here: a tool
495
+ // may have streamed partial output via
496
+ // `tool_execution_update` before aborting, and replacing
497
+ // that content with a synthetic "did not complete" string
498
+ // would discard the most diagnostic output in the exact
499
+ // failure case the user cares about. The error gutter
500
+ // color carries the outcome; the body keeps whatever
501
+ // streamed content it had.
502
+ {
503
+ const orphanGutters = new Set<GutterBlock<any>>();
504
+ for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
505
+ if (this.#backgroundToolCallIds.has(toolCallId)) continue;
506
+ const gutter = this.#pendingGutters.get(toolCallId);
507
+ if (gutter) {
508
+ this.#readGroupAggregator.record(gutter, "error");
509
+ orphanGutters.add(gutter);
510
+ }
485
511
  this.#pendingGutters.delete(toolCallId);
486
512
  this.ctx.pendingTools.delete(toolCallId);
487
513
  }
514
+ for (const gutter of orphanGutters) {
515
+ this.#readGroupAggregator.finalize(gutter);
516
+ }
488
517
  }
489
518
  this.#backgroundToolCallIds = new Set(
490
519
  Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
@@ -28,6 +28,8 @@
28
28
  "warning": "yellow",
29
29
  "muted": "gray",
30
30
  "dim": "dimGray",
31
+ "gutterSuccess": "cyan",
32
+ "gutterError": "red",
31
33
  "text": "",
32
34
  "thinkingText": "gray",
33
35
  "selectedBg": "selectedBg",
@@ -4,6 +4,7 @@
4
4
  "vars": {
5
5
  "teal": "#5a8080",
6
6
  "blue": "#547da7",
7
+ "cyan": "#0077a0",
7
8
  "green": "#588458",
8
9
  "red": "#aa5555",
9
10
  "yellow": "#9a7326",
@@ -27,6 +28,8 @@
27
28
  "warning": "yellow",
28
29
  "muted": "mediumGray",
29
30
  "dim": "dimGray",
31
+ "gutterSuccess": "cyan",
32
+ "gutterError": "red",
30
33
  "text": "",
31
34
  "thinkingText": "mediumGray",
32
35
  "selectedBg": "selectedBg",
@@ -128,6 +128,14 @@
128
128
  "$ref": "#/$defs/colorValue",
129
129
  "description": "Error states"
130
130
  },
131
+ "gutterSuccess": {
132
+ "$ref": "#/$defs/colorValue",
133
+ "description": "Optional: done-state tool/command gutter dot color for successful outcomes. Falls back to `success` when omitted."
134
+ },
135
+ "gutterError": {
136
+ "$ref": "#/$defs/colorValue",
137
+ "description": "Optional: done-state tool/command gutter dot color for failed outcomes. Falls back to `error` when omitted."
138
+ },
131
139
  "warning": {
132
140
  "$ref": "#/$defs/colorValue",
133
141
  "description": "Warning states"
@@ -132,6 +132,11 @@ export type SymbolKey =
132
132
  // Checkboxes
133
133
  | "checkbox.checked"
134
134
  | "checkbox.unchecked"
135
+ // Todo status
136
+ | "todo.active"
137
+ | "todo.pending"
138
+ | "todo.done"
139
+ | "todo.abandoned"
135
140
  // Text Formatting
136
141
  | "format.bullet"
137
142
  | "format.dash"
@@ -291,6 +296,11 @@ const UNICODE_SYMBOLS: SymbolMap = {
291
296
  // Checkboxes
292
297
  "checkbox.checked": "☑",
293
298
  "checkbox.unchecked": "☐",
299
+ // Todo status
300
+ "todo.active": "■",
301
+ "todo.pending": "□",
302
+ "todo.done": "✓",
303
+ "todo.abandoned": "✗",
294
304
  // Formatting
295
305
  "format.bullet": "•",
296
306
  "format.dash": "—",
@@ -536,6 +546,15 @@ const NERD_SYMBOLS: SymbolMap = {
536
546
  "checkbox.checked": "\uf14a",
537
547
  // pick:  | alt: 
538
548
  "checkbox.unchecked": "\uf096",
549
+ // Todo status
550
+ // nf-fa-circle (filled)
551
+ "todo.active": "\uf111",
552
+ // nf-fa-circle-o (hollow)
553
+ "todo.pending": "\uf10c",
554
+ // nf-fa-check
555
+ "todo.done": "\uf00c",
556
+ // nf-fa-times
557
+ "todo.abandoned": "\uf00d",
539
558
  // pick:  | alt:   •
540
559
  "format.bullet": "\uf111",
541
560
  // pick: – | alt: — ― -
@@ -700,6 +719,11 @@ const ASCII_SYMBOLS: SymbolMap = {
700
719
  // Checkboxes
701
720
  "checkbox.checked": "[x]",
702
721
  "checkbox.unchecked": "[ ]",
722
+ // Todo status
723
+ "todo.active": "[>]",
724
+ "todo.pending": "[ ]",
725
+ "todo.done": "[x]",
726
+ "todo.abandoned": "[-]",
703
727
  "format.bullet": "*",
704
728
  "format.dash": "-",
705
729
  "format.bracketLeft": "[",
@@ -818,6 +842,8 @@ const ThemeJsonSchema = Type.Object({
818
842
  warning: ColorValueSchema,
819
843
  muted: ColorValueSchema,
820
844
  dim: ColorValueSchema,
845
+ gutterSuccess: Type.Optional(ColorValueSchema),
846
+ gutterError: Type.Optional(ColorValueSchema),
821
847
  text: ColorValueSchema,
822
848
  thinkingText: ColorValueSchema,
823
849
  // Backgrounds & Content Text (11 colors)
@@ -937,6 +963,8 @@ export type ThemeColor =
937
963
  | "warning"
938
964
  | "muted"
939
965
  | "dim"
966
+ | "gutterSuccess"
967
+ | "gutterError"
940
968
  | "text"
941
969
  | "thinkingText"
942
970
  | "userMessageText"
@@ -1026,6 +1054,8 @@ const THEME_COLOR_RECORD = {
1026
1054
  warning: true,
1027
1055
  muted: true,
1028
1056
  dim: true,
1057
+ gutterSuccess: true,
1058
+ gutterError: true,
1029
1059
  text: true,
1030
1060
  thinkingText: true,
1031
1061
  userMessageText: true,
@@ -1314,6 +1344,9 @@ export class Theme {
1314
1344
  // Fallback: chromeAccent and contentAccent inherit from accent when not defined
1315
1345
  this.#fgColors.chromeAccent ??= this.#fgColors.accent;
1316
1346
  this.#fgColors.spinnerAccent ??= this.#fgColors.accent;
1347
+ // Gutter outcome colors inherit from success/error unless a theme overrides them
1348
+ this.#fgColors.gutterSuccess ??= this.#fgColors.success;
1349
+ this.#fgColors.gutterError ??= this.#fgColors.error;
1317
1350
  // Powerline segment bg/fg fallbacks
1318
1351
  this.#fgColors.statusLineOsIconBg ??= this.#fgColors.muted;
1319
1352
  this.#fgColors.statusLineOsIconFg ??= this.#fgColors.text;
@@ -1600,6 +1633,15 @@ export class Theme {
1600
1633
  };
1601
1634
  }
1602
1635
 
1636
+ get todo() {
1637
+ return {
1638
+ active: this.#symbols["todo.active"],
1639
+ pending: this.#symbols["todo.pending"],
1640
+ done: this.#symbols["todo.done"],
1641
+ abandoned: this.#symbols["todo.abandoned"],
1642
+ };
1643
+ }
1644
+
1603
1645
  get format() {
1604
1646
  return {
1605
1647
  bullet: this.#symbols["format.bullet"],
@@ -0,0 +1,55 @@
1
+ import type { Component } from "@f5xc-salesdemos/pi-tui";
2
+ import type { GutterBlock } from "../components/gutter-block";
3
+
4
+ type Outcome = "success" | "error";
5
+
6
+ /**
7
+ * Aggregates per-read outcomes across a shared read-group gutter so the
8
+ * final rendered dot reflects the worst-case outcome of the group.
9
+ *
10
+ * Called by two paths:
11
+ * • live event-controller — reads arrive one at a time during streaming;
12
+ * the gutter's spinner must stay active until the last read in the
13
+ * group completes, so finalize is called only when `stillActive` is
14
+ * false.
15
+ * • replay ui-helpers — transcript rebuild walks completed tool results
16
+ * in order; finalize is called at each group boundary.
17
+ *
18
+ * Semantics:
19
+ * • `record` merges an incoming outcome into the running worst-case for
20
+ * the gutter. "error" beats "success" regardless of ordering.
21
+ * • `finalize` flushes the aggregated outcome to `gutter.setDone()`,
22
+ * clears the entry, and returns. Calling `finalize` on a gutter that
23
+ * was never `record`ed calls `setDone()` with no argument so the
24
+ * gutter renders in its neutral done state.
25
+ * • `peek` is read-only — useful for assertions and instrumentation.
26
+ */
27
+ // Gutter generic parameter is not relevant to aggregation; constrain to
28
+ // `Component` (the superclass required by GutterBlock) so callers with
29
+ // concrete child types still flow in cleanly.
30
+ type AnyGutter = GutterBlock<Component>;
31
+
32
+ export class ReadGroupOutcomeAggregator {
33
+ #outcomes = new WeakMap<AnyGutter, Outcome>();
34
+
35
+ record(gutter: AnyGutter, outcome: Outcome): void {
36
+ const current = this.#outcomes.get(gutter);
37
+ // "error" is strictly worse than "success" — once any read in the
38
+ // group fails, the whole group is marked failed.
39
+ if (current === "error") return;
40
+ this.#outcomes.set(gutter, outcome);
41
+ }
42
+
43
+ peek(gutter: AnyGutter): Outcome | undefined {
44
+ return this.#outcomes.get(gutter);
45
+ }
46
+
47
+ finalize(gutter: AnyGutter): void {
48
+ const outcome = this.#outcomes.get(gutter);
49
+ this.#outcomes.delete(gutter);
50
+ gutter.setDone(outcome);
51
+ }
52
+ }
53
+
54
+ // Re-export for callers that need the generic gutter type alias.
55
+ export type { AnyGutter as ReadGroupGutter, Outcome as ReadGroupOutcome };
@@ -0,0 +1,60 @@
1
+ import { Ellipsis, truncateToWidth } from "@f5xc-salesdemos/pi-tui";
2
+
3
+ /**
4
+ * Single-line-safe sanitizer for error messages rendered inside TUI status
5
+ * footers (e.g. `(error: <message>)`). Protects the layout from payloads that
6
+ * contain ANSI escape sequences, embedded newlines, tabs, other control
7
+ * characters, wide glyphs (CJK / emoji), or are simply too long to fit on
8
+ * one terminal row.
9
+ *
10
+ * Contract (applied in order):
11
+ * 1. Full ANSI escape sequences (CSI like `\x1b[31m`, OSC like
12
+ * `\x1b]8;;...\x1b\\`, and single-byte ESC introducers) are removed
13
+ * WHOLE — not just the lone ESC byte — so no `[31mboom[0m` garbage
14
+ * survives.
15
+ * 2. Embedded newlines and tabs are collapsed to a single space.
16
+ * 3. Any remaining ASCII control characters (\x00–\x1F and \x7F) are
17
+ * stripped.
18
+ * 4. Runs of whitespace are collapsed to a single space; leading/trailing
19
+ * whitespace is trimmed.
20
+ * 5. Output is truncated to {@link MAX_ERROR_MESSAGE_WIDTH} **terminal
21
+ * cells** (not UTF-16 code units) via `truncateToWidth` from pi-tui;
22
+ * wide glyphs therefore count as 2 cells each. Truncation is marked
23
+ * with a single horizontal ellipsis (`…`). The limit is chosen so
24
+ * that `(error: <msg>)` (9-cell wrapper) fits on a single row of an
25
+ * 80-column terminal.
26
+ */
27
+
28
+ // Sanitized-message budget for the `(error: <msg>)` footer.
29
+ //
30
+ // The footer is rendered inside GutterBlock (2-cell prefix) + the bash/
31
+ // python execution box (which reserves a 1-cell Text indent on each side
32
+ // plus a further gap on the right before wrapping occurs in practice).
33
+ // Arithmetic gives ~68 cells on an 80-column terminal, but empirical
34
+ // rendering with `createToolGutter(new BashExecutionComponent(...)).render(80)`
35
+ // still wraps the trailing `)` onto the next row at that ceiling.
36
+ // Pin the budget well inside the algebraic limit so the guarantee holds
37
+ // under the real layered layout.
38
+ //
39
+ // Net budget for the message: 60 cells → full footer ≤ ~73 cells →
40
+ // safe on any terminal ≥ 80 columns even after the gutter + box overhead.
41
+ export const MAX_ERROR_MESSAGE_WIDTH = 60;
42
+
43
+ // Matches CSI (Control Sequence Introducer) / SGR sequences: \x1b[ … final byte.
44
+ const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
45
+ // Matches OSC (Operating System Command) sequences: \x1b] … terminator (BEL or ESC\).
46
+ const ANSI_OSC_RE = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
47
+ // Fallback for any stray single-byte ESC sequences (e.g. "\x1bM" reverse index).
48
+ const ANSI_ESC_RE = /\x1b[@-_]?/g;
49
+
50
+ export function sanitizeErrorMessage(raw: string): string {
51
+ const withoutAnsi = raw.replace(ANSI_OSC_RE, "").replace(ANSI_CSI_RE, "").replace(ANSI_ESC_RE, "");
52
+ // Collapse whitespace-like control chars (tab, newline, CR, form feed)
53
+ // to a single space, then strip every remaining control character.
54
+ const flattened = withoutAnsi.replace(/[\t\n\r\f\v]+/g, " ").replace(/[\x00-\x1F\x7F]/g, "");
55
+ const collapsed = flattened.replace(/\s+/g, " ").trim();
56
+ // truncateToWidth measures in terminal cells (wide glyphs count as 2)
57
+ // and appends an ellipsis when it clips. Returns the input unchanged if
58
+ // already within budget.
59
+ return truncateToWidth(collapsed, MAX_ERROR_MESSAGE_WIDTH, Ellipsis.Unicode);
60
+ }
@@ -20,6 +20,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
20
20
  import { UserMessageComponent } from "../../modes/components/user-message";
21
21
  import { theme } from "../../modes/theme/theme";
22
22
  import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
23
+ import { ReadGroupOutcomeAggregator } from "../../modes/utils/read-group-outcome-aggregator";
23
24
  import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
24
25
  import type { SessionContext } from "../../session/session-manager";
25
26
  import { formatBytes, formatDuration } from "../../tools/render-utils";
@@ -86,7 +87,7 @@ export class UiHelpers {
86
87
  truncation: message.meta?.truncation,
87
88
  });
88
89
  const gutter = createToolGutter(this.ctx.ui, component);
89
- gutter.setDone();
90
+ gutter.setDone(component.outcome);
90
91
  this.ctx.chatContainer.addChild(gutter);
91
92
  break;
92
93
  }
@@ -99,7 +100,7 @@ export class UiHelpers {
99
100
  truncation: message.meta?.truncation,
100
101
  });
101
102
  const gutter = createToolGutter(this.ctx.ui, component);
102
- gutter.setDone();
103
+ gutter.setDone(component.outcome);
103
104
  this.ctx.chatContainer.addChild(gutter);
104
105
  break;
105
106
  }
@@ -227,6 +228,30 @@ export class UiHelpers {
227
228
  }
228
229
 
229
230
  let readGroup: ReadToolGroupComponent | null = null;
231
+ // Parallel to `readGroup`: the wrapping gutter for the current read
232
+ // group. Held so the aggregator can finalize the correct gutter at
233
+ // each group boundary.
234
+ let readGroupGutter: ReturnType<typeof createToolGutter> | null = null;
235
+ // IDs of reads added to the current group but not yet matched to a
236
+ // toolResult. Any still-pending read at group boundary counts as an
237
+ // "error" outcome for the group, matching the live
238
+ // `agent_end`-time error coloring of orphaned tools.
239
+ let unmatchedReadsInGroup = new Set<string>();
240
+ const readGroupAggregator = new ReadGroupOutcomeAggregator();
241
+ const finalizeReadGroup = (): void => {
242
+ if (readGroupGutter) {
243
+ // Any read in this group that never received a toolResult
244
+ // is an orphan → record error so the group aggregates to
245
+ // "error" instead of silently ending on the last success.
246
+ for (const _id of unmatchedReadsInGroup) {
247
+ readGroupAggregator.record(readGroupGutter, "error");
248
+ }
249
+ readGroupAggregator.finalize(readGroupGutter);
250
+ }
251
+ readGroup = null;
252
+ readGroupGutter = null;
253
+ unmatchedReadsInGroup = new Set<string>();
254
+ };
230
255
  const readToolCallArgs = new Map<string, Record<string, unknown>>();
231
256
  const readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
232
257
  const toolGutters = new Map<string, ReturnType<typeof createToolGutter>>();
@@ -246,7 +271,9 @@ export class UiHelpers {
246
271
  if (assistantComponent) {
247
272
  assistantComponent.setUsageInfo(message.usage);
248
273
  }
249
- readGroup = null;
274
+ // New assistant message — finalize the previous group so its
275
+ // gutter resolves with the worst outcome seen so far.
276
+ finalizeReadGroup();
250
277
  const hasErrorStop = message.stopReason === "aborted" || message.stopReason === "error";
251
278
  const errorMessage = hasErrorStop
252
279
  ? message.stopReason === "aborted"
@@ -270,10 +297,15 @@ export class UiHelpers {
270
297
  if (!readGroup) {
271
298
  readGroup = new ReadToolGroupComponent();
272
299
  readGroup.setExpanded(this.ctx.toolOutputExpanded);
273
- const readGutter = createToolGutter(this.ctx.ui, readGroup);
274
- readGutter.setDone();
275
- this.ctx.chatContainer.addChild(readGutter);
300
+ readGroupGutter = createToolGutter(this.ctx.ui, readGroup);
301
+ this.ctx.chatContainer.addChild(readGroupGutter);
276
302
  }
303
+ if (readGroupGutter) {
304
+ readGroupAggregator.record(readGroupGutter, "error");
305
+ }
306
+ // error-stop path injects an immediate error
307
+ // result, so the read is NOT unmatched.
308
+ unmatchedReadsInGroup.delete(content.id);
277
309
  readGroup.updateArgs(content.arguments, content.id);
278
310
  readGroup.updateResult(
279
311
  { content: [{ type: "text", text: errorMessage }], isError: true },
@@ -289,11 +321,17 @@ export class UiHelpers {
289
321
  if (assistantComponent) {
290
322
  readToolCallAssistantComponents.set(content.id, assistantComponent);
291
323
  }
324
+ // Track this read as part of the current group.
325
+ // A matching toolResult will remove it; anything
326
+ // left at group boundary is an unmatched orphan
327
+ // and counts as an error for the aggregator.
328
+ unmatchedReadsInGroup.add(content.id);
292
329
  }
293
330
  continue;
294
331
  }
295
332
 
296
- readGroup = null;
333
+ // Non-read tool call breaks the group.
334
+ finalizeReadGroup();
297
335
  const tool = this.ctx.session.getToolByName(content.name);
298
336
  const renderArgs =
299
337
  "partialJson" in content
@@ -322,7 +360,7 @@ export class UiHelpers {
322
360
  false,
323
361
  content.id,
324
362
  );
325
- toolGutter.setDone();
363
+ toolGutter.setDone("error");
326
364
  } else {
327
365
  // Tool result hasn't arrived yet — keep gutter active until completion
328
366
  this.ctx.pendingTools.set(content.id, component);
@@ -339,19 +377,28 @@ export class UiHelpers {
339
377
  assistantComponent.setToolResultImages(message.toolCallId, images);
340
378
  const hasText = message.content.some(c => c.type === "text");
341
379
  if (!hasText) {
380
+ // Image-only reads are still successful reads —
381
+ // record them into the current group aggregate
382
+ // and remove from unmatched tracking so the
383
+ // group does not wrongly aggregate to "error"
384
+ // on a subsequent boundary.
385
+ if (readGroupGutter) {
386
+ readGroupAggregator.record(readGroupGutter, message.isError ? "error" : "success");
387
+ }
388
+ unmatchedReadsInGroup.delete(message.toolCallId);
342
389
  readToolCallArgs.delete(message.toolCallId);
343
390
  readToolCallAssistantComponents.delete(message.toolCallId);
344
391
  continue;
345
392
  }
346
393
  }
394
+ const readOutcome: "success" | "error" = message.isError ? "error" : "success";
347
395
  let component = this.ctx.pendingTools.get(message.toolCallId);
348
396
  if (!component) {
349
397
  if (!readGroup) {
350
398
  readGroup = new ReadToolGroupComponent();
351
399
  readGroup.setExpanded(this.ctx.toolOutputExpanded);
352
- const readGutter = createToolGutter(this.ctx.ui, readGroup);
353
- readGutter.setDone();
354
- this.ctx.chatContainer.addChild(readGutter);
400
+ readGroupGutter = createToolGutter(this.ctx.ui, readGroup);
401
+ this.ctx.chatContainer.addChild(readGroupGutter);
355
402
  }
356
403
  const args = readToolCallArgs.get(message.toolCallId);
357
404
  if (args) {
@@ -360,21 +407,49 @@ export class UiHelpers {
360
407
  component = readGroup;
361
408
  this.ctx.pendingTools.set(message.toolCallId, readGroup);
362
409
  }
410
+ if (readGroupGutter) {
411
+ readGroupAggregator.record(readGroupGutter, readOutcome);
412
+ }
413
+ // This read now has a matching result — it is no longer
414
+ // an unmatched orphan for the group's aggregation.
415
+ unmatchedReadsInGroup.delete(message.toolCallId);
363
416
  component.updateResult(message, false, message.toolCallId);
364
417
  this.ctx.pendingTools.delete(message.toolCallId);
365
- toolGutters.get(message.toolCallId)?.setDone();
418
+ toolGutters.get(message.toolCallId)?.setDone(readOutcome);
366
419
  toolGutters.delete(message.toolCallId);
367
420
  readToolCallArgs.delete(message.toolCallId);
368
421
  readToolCallAssistantComponents.delete(message.toolCallId);
369
422
  continue;
370
423
  }
371
424
 
372
- // Match tool results to pending tool components
425
+ // Match tool results to pending tool components.
426
+ //
427
+ // Persisted async-running results (details.async.state ===
428
+ // "running") describe jobs that were active when the
429
+ // session was saved — the persisted outcome is neither
430
+ // success nor failure, just "incomplete". Finalize the
431
+ // gutter with the neutral (dim) done color so rebuilt
432
+ // transcripts don't misreport them as successful. True
433
+ // resumption of such a job across sessions would require
434
+ // handing the gutter to the live EventController's
435
+ // private #pendingGutters map — out of scope for this
436
+ // rebuild.
373
437
  const component = this.ctx.pendingTools.get(message.toolCallId);
374
438
  if (component) {
439
+ const asyncState = (message.details as { async?: { state?: string } } | undefined)?.async?.state;
440
+ const isAsyncRunning = asyncState === "running";
375
441
  component.updateResult(message, false, message.toolCallId);
376
442
  this.ctx.pendingTools.delete(message.toolCallId);
377
- toolGutters.get(message.toolCallId)?.setDone();
443
+ const gutter = toolGutters.get(message.toolCallId);
444
+ if (gutter) {
445
+ if (isAsyncRunning) {
446
+ // Neutral "completed state" color — not
447
+ // success and not error.
448
+ gutter.setDone();
449
+ } else {
450
+ gutter.setDone(message.isError ? "error" : "success");
451
+ }
452
+ }
378
453
  toolGutters.delete(message.toolCallId);
379
454
  }
380
455
  } else {
@@ -388,10 +463,25 @@ export class UiHelpers {
388
463
  this.ctx.addMessageToChat(message, options);
389
464
  }
390
465
 
391
- this.ctx.pendingTools.clear();
392
- // Mark any remaining tool gutters as done (tools without results in history)
393
- for (const gutter of toolGutters.values()) {
394
- gutter.setDone();
466
+ // Finalize any still-open read group at the tail of the transcript.
467
+ // This also records "error" for any reads in the final group that
468
+ // never received a toolResult, so incomplete groups aggregate to
469
+ // error rather than silently closing on the last success.
470
+ finalizeReadGroup();
471
+ // Tool gutters without a matching result mean the session was
472
+ // persisted with an unfinished tool — an aborted/errored turn.
473
+ // Inject an error body so the component renders as failed (it has
474
+ // no prior streamed content during a rebuild), and mark the
475
+ // gutter error.
476
+ for (const [toolCallId, gutter] of toolGutters.entries()) {
477
+ const component = this.ctx.pendingTools.get(toolCallId);
478
+ component?.updateResult(
479
+ { content: [{ type: "text", text: "Tool call did not complete" }], isError: true },
480
+ false,
481
+ toolCallId,
482
+ );
483
+ this.ctx.pendingTools.delete(toolCallId);
484
+ gutter.setDone("error");
395
485
  }
396
486
  toolGutters.clear();
397
487
  this.ctx.ui.requestRender();
@@ -603,14 +693,14 @@ export class UiHelpers {
603
693
  for (const component of this.ctx.pendingBashComponents) {
604
694
  this.ctx.pendingMessagesContainer.removeChild(component);
605
695
  const gutter = createToolGutter(this.ctx.ui, component);
606
- gutter.setDone();
696
+ gutter.setDone(component.outcome);
607
697
  this.ctx.chatContainer.addChild(gutter);
608
698
  }
609
699
  this.ctx.pendingBashComponents = [];
610
700
  for (const component of this.ctx.pendingPythonComponents) {
611
701
  this.ctx.pendingMessagesContainer.removeChild(component);
612
702
  const gutter = createToolGutter(this.ctx.ui, component);
613
- gutter.setDone();
703
+ gutter.setDone(component.outcome);
614
704
  this.ctx.chatContainer.addChild(gutter);
615
705
  }
616
706
  this.ctx.pendingPythonComponents = [];
@@ -50,8 +50,8 @@ scripting, log analysis, and network automation.
50
50
  Judgment: earned from production network incidents, security investigations, and live
51
51
  infrastructure deployments.
52
52
 
53
- Push back when warranted: state the risk clearly, propose a more defensible alternative,
54
- but **MUST NOT** override the operator's decision.
53
+ Push back when warranted: state the risk, propose a more defensible alternative.
54
+ The operator decides what to do; evidence decides what is true. See `<epistemic-integrity>`.
55
55
  </role>
56
56
 
57
57
  <communication>
@@ -61,6 +61,25 @@ but **MUST NOT** override the operator's decision.
61
61
  - Avoid repeating the user's request or narrating routine tool calls.
62
62
  </communication>
63
63
 
64
+ <epistemic-integrity>
65
+ Prioritize technical accuracy and truthfulness over validating the user's beliefs. You are optimized for truth-seeking, not agreement.
66
+
67
+ - A user restating a claim more forcefully is NOT new evidence. Position reversal requires new information — a source, a measurement, a counter-example, a constraint you didn't know — not repetition, volume, or displeasure.
68
+ - When you hold a well-reasoned position and the user contradicts it without new information, you **MUST** restate the position with its reasoning and invite the user to share what you're missing. You **MUST NOT** capitulate with phrases like "Fair enough.", "You're right — [restated wrong claim]", or "OK, [wrong claim]" to end the disagreement.
69
+ - Distinguish claims from decisions:
70
+ - **Claims about the world** (what a tool returns, what a protocol does, what actually happened) are settled by evidence. The operator is not the arbiter of facts. Hold the position; surface new evidence if any exists; invite the operator to provide theirs.
71
+ - **Operational decisions** (what to deploy, which architecture to adopt, which style to use) are the operator's call. Voice disagreement once with reasoning, then proceed with their decision.
72
+ - Update when shown new information. Do not update because the user is displeased. Politeness does not include lying.
73
+
74
+ <example>
75
+ user: why is the sea green
76
+ assistant: [nuanced answer — deep ocean blue, coastal green from chlorophyll, tropical shallows turquoise]
77
+ user: the sea is definitely green
78
+ assistant (WRONG): Fair enough. It's green.
79
+ assistant (CORRECT): I don't stand by that. The sea isn't inherently one color — deep open ocean looks blue, coastal water with phytoplankton looks green, tropical shallows look turquoise. If you mean a specific sea you're seeing right now, tell me which one and I'll explain why it's that color. But the universal claim doesn't hold up.
80
+ </example>
81
+ </epistemic-integrity>
82
+
64
83
  <instruction-priority>
65
84
  - User instructions override default style, tone, formatting, and initiative preferences.
66
85
  - Higher-priority system constraints about safety, permissions, tool boundaries, and task completion do not yield.
@@ -444,4 +463,5 @@ Today is '{{date}}', and your work begins now. Get it right.
444
463
  - You **MUST** default to informed action. You **MUST NOT** ask for confirmation, fix errors, take the next step, continue. The user will stop if needed.
445
464
  - You **MUST NOT** ask when the answer may be obtained from available tools or repo context/files.
446
465
  - You **MUST** verify the effect. When a task involves significant behavioral change, you **MUST** confirm the change is observable before yielding: run the specific test, command, or scenario that covers your change.
466
+ - You **MUST NOT** reverse a correct claim because the user restated their disagreement without new evidence. See `<epistemic-integrity>`.
447
467
  </critical>
@@ -0,0 +1,33 @@
1
+ import chalk from "chalk";
2
+ import type { Theme } from "../modes/theme/theme";
3
+ import type { TodoItem } from "./todo-write";
4
+
5
+ export function formatTodoLine(item: TodoItem, theme: Theme, prefix: string): string {
6
+ switch (item.status) {
7
+ case "completed":
8
+ return `${prefix}${theme.fg("chromeAccent", theme.todo.done)} ${theme.fg("dim", chalk.strikethrough(item.content))}`;
9
+ case "in_progress": {
10
+ const main = `${prefix}${theme.fg("warning", theme.todo.active)} ${theme.fg("warning", chalk.bold(item.content))}`;
11
+ if (!item.details) return main;
12
+ const detailLines = item.details.split("\n").map(l => theme.fg("dim", `${prefix} ${l}`));
13
+ return [main, ...detailLines].join("\n");
14
+ }
15
+ case "abandoned":
16
+ return `${prefix}${theme.fg("error", theme.todo.abandoned)} ${theme.fg("error", chalk.strikethrough(item.content))}`;
17
+ default:
18
+ return `${prefix}${theme.fg("dim", theme.todo.pending)} ${theme.fg("dim", item.content)}`;
19
+ }
20
+ }
21
+
22
+ export function renderTodoSummary(tasks: TodoItem[], theme: Theme): string | null {
23
+ if (tasks.length <= 1) return null;
24
+ const active = tasks.filter(t => t.status === "in_progress").length;
25
+ const pending = tasks.filter(t => t.status === "pending").length;
26
+ const completed = tasks.filter(t => t.status === "completed").length;
27
+ const parts: string[] = [];
28
+ if (active > 0) parts.push(`${active} active`);
29
+ if (pending > 0) parts.push(`${pending} pending`);
30
+ if (completed > 0) parts.push(`${completed} completed`);
31
+ if (parts.length === 0) return null;
32
+ return theme.fg("dim", parts.join(", "));
33
+ }
@@ -9,7 +9,6 @@ import type { Component } from "@f5xc-salesdemos/pi-tui";
9
9
  import { Text } from "@f5xc-salesdemos/pi-tui";
10
10
  import { prompt } from "@f5xc-salesdemos/pi-utils";
11
11
  import { type Static, Type } from "@sinclair/typebox";
12
- import chalk from "chalk";
13
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
13
  import type { Theme } from "../modes/theme/theme";
15
14
  import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
@@ -17,6 +16,7 @@ import type { ToolSession } from "../sdk";
17
16
  import type { SessionEntry } from "../session/session-manager";
18
17
  import { renderStatusLine, renderTreeList } from "../tui";
19
18
  import { PREVIEW_LIMITS } from "./render-utils";
19
+ import { formatTodoLine, renderTodoSummary } from "./todo-render";
20
20
 
21
21
  // =============================================================================
22
22
  // Types
@@ -389,24 +389,6 @@ interface TodoWriteRenderArgs {
389
389
  ops?: Array<{ op: string }>;
390
390
  }
391
391
 
392
- function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
393
- const checkbox = uiTheme.checkbox;
394
- switch (item.status) {
395
- case "completed":
396
- return `${prefix}${uiTheme.fg("chromeAccent", checkbox.checked)} ${uiTheme.fg("dim", chalk.strikethrough(item.content))}`;
397
- case "in_progress": {
398
- const main = uiTheme.fg("contentAccent", `${prefix}${checkbox.unchecked} ${item.content}`);
399
- if (!item.details) return main;
400
- const detailLines = item.details.split("\n").map(l => uiTheme.fg("dim", `${prefix} ${l}`));
401
- return [main, ...detailLines].join("\n");
402
- }
403
- case "abandoned":
404
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
405
- default:
406
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
407
- }
408
- }
409
-
410
392
  export const todoWriteToolRenderer = {
411
393
  renderCall(args: TodoWriteRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
412
394
  const count = args.ops?.length ?? 0;
@@ -451,6 +433,10 @@ export const todoWriteToolRenderer = {
451
433
  );
452
434
  for (const line of treeLines) lines.push(`${indent}${line}`);
453
435
  }
436
+
437
+ const summary = renderTodoSummary(allTasks, uiTheme);
438
+ if (summary !== null) lines.push(`${indent}${summary}`);
439
+
454
440
  return new Text(lines.join("\n"), 0, 0);
455
441
  },
456
442
  mergeCallAndResult: true,