@f5xc-salesdemos/xcsh 18.2.1 → 18.3.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.3.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.3.0",
51
+ "@f5xc-salesdemos/pi-agent-core": "18.3.0",
52
+ "@f5xc-salesdemos/pi-ai": "18.3.0",
53
+ "@f5xc-salesdemos/pi-natives": "18.3.0",
54
+ "@f5xc-salesdemos/pi-tui": "18.3.0",
55
+ "@f5xc-salesdemos/pi-utils": "18.3.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.3.0",
21
+ "commit": "1d4143afd9489476ed3bf4dd6cfba1ffdd45b81a",
22
+ "shortCommit": "1d4143a",
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.3.0",
25
+ "commitDate": "2026-04-21T02:39:53Z",
26
+ "buildDate": "2026-04-21T03:01:13.203Z",
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/1d4143afd9489476ed3bf4dd6cfba1ffdd45b81a",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.3.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,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"
@@ -818,6 +818,8 @@ const ThemeJsonSchema = Type.Object({
818
818
  warning: ColorValueSchema,
819
819
  muted: ColorValueSchema,
820
820
  dim: ColorValueSchema,
821
+ gutterSuccess: Type.Optional(ColorValueSchema),
822
+ gutterError: Type.Optional(ColorValueSchema),
821
823
  text: ColorValueSchema,
822
824
  thinkingText: ColorValueSchema,
823
825
  // Backgrounds & Content Text (11 colors)
@@ -937,6 +939,8 @@ export type ThemeColor =
937
939
  | "warning"
938
940
  | "muted"
939
941
  | "dim"
942
+ | "gutterSuccess"
943
+ | "gutterError"
940
944
  | "text"
941
945
  | "thinkingText"
942
946
  | "userMessageText"
@@ -1026,6 +1030,8 @@ const THEME_COLOR_RECORD = {
1026
1030
  warning: true,
1027
1031
  muted: true,
1028
1032
  dim: true,
1033
+ gutterSuccess: true,
1034
+ gutterError: true,
1029
1035
  text: true,
1030
1036
  thinkingText: true,
1031
1037
  userMessageText: true,
@@ -1314,6 +1320,9 @@ export class Theme {
1314
1320
  // Fallback: chromeAccent and contentAccent inherit from accent when not defined
1315
1321
  this.#fgColors.chromeAccent ??= this.#fgColors.accent;
1316
1322
  this.#fgColors.spinnerAccent ??= this.#fgColors.accent;
1323
+ // Gutter outcome colors inherit from success/error unless a theme overrides them
1324
+ this.#fgColors.gutterSuccess ??= this.#fgColors.success;
1325
+ this.#fgColors.gutterError ??= this.#fgColors.error;
1317
1326
  // Powerline segment bg/fg fallbacks
1318
1327
  this.#fgColors.statusLineOsIconBg ??= this.#fgColors.muted;
1319
1328
  this.#fgColors.statusLineOsIconFg ??= this.#fgColors.text;
@@ -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 = [];