@f5xc-salesdemos/xcsh 18.2.0 → 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 +7 -7
- package/src/config/prompt-templates.ts +69 -53
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/bash-execution.ts +37 -1
- package/src/modes/components/gutter-block.ts +35 -4
- package/src/modes/components/python-execution.ts +36 -1
- package/src/modes/components/user-message.ts +43 -20
- package/src/modes/controllers/command-controller.ts +10 -4
- package/src/modes/controllers/event-controller.ts +43 -14
- package/src/modes/theme/dark.json +2 -0
- package/src/modes/theme/light.json +3 -0
- package/src/modes/theme/theme-schema.json +8 -0
- package/src/modes/theme/theme.ts +9 -0
- package/src/modes/utils/read-group-outcome-aggregator.ts +55 -0
- package/src/modes/utils/sanitize-error-message.ts +60 -0
- package/src/modes/utils/ui-helpers.ts +110 -20
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
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.
|
|
51
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
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",
|
|
@@ -23,14 +23,6 @@ export interface PromptTemplate {
|
|
|
23
23
|
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => {
|
|
27
|
-
try {
|
|
28
|
-
return jtdToTypeScript(schema);
|
|
29
|
-
} catch {
|
|
30
|
-
return "unknown";
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
26
|
/**
|
|
35
27
|
* Renders a section separator:
|
|
36
28
|
*
|
|
@@ -42,8 +34,6 @@ export function sectionSeparator(name: string): string {
|
|
|
42
34
|
return `\n\n═══════════${name}═══════════\n`;
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
prompt.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name)));
|
|
46
|
-
|
|
47
37
|
function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
|
|
48
38
|
const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
|
|
49
39
|
const raw = typeof content === "string" ? content : String(content ?? "");
|
|
@@ -53,51 +43,77 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
|
|
|
53
43
|
}
|
|
54
44
|
|
|
55
45
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
prompt
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* {{hline lineNum "content"}} — format a full read-style line with prefix.
|
|
66
|
-
* Returns `"lineNum#hash:content"`.
|
|
67
|
-
*/
|
|
68
|
-
prompt.registerHelper("hline", (lineNum: unknown, content: unknown): string => {
|
|
69
|
-
const { ref, text } = formatHashlineRef(lineNum, content);
|
|
70
|
-
return `${ref}:${text}`;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* {{anchor name checksum}} — render a branch anchor tag using the current anchor style.
|
|
75
|
-
* Style is resolved from the template context (`anchorStyle`) or defaults to "full".
|
|
46
|
+
* Registers all coding-agent-specific Handlebars helpers on the shared prompt engine.
|
|
47
|
+
*
|
|
48
|
+
* Called at module load for production (bottom of this file). Tests that render
|
|
49
|
+
* prompt templates directly via `prompt.render(...)` without going through
|
|
50
|
+
* `buildSystemPrompt()` MUST call this in `beforeAll` — see Issue #175.
|
|
51
|
+
*
|
|
52
|
+
* Handlebars `registerHelper` is idempotent (overwrites), so calling this more
|
|
53
|
+
* than once is safe.
|
|
76
54
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
55
|
+
export function registerCodingAgentPromptHelpers(): void {
|
|
56
|
+
prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => {
|
|
57
|
+
try {
|
|
58
|
+
return jtdToTypeScript(schema);
|
|
59
|
+
} catch {
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
prompt.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name)));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
|
|
68
|
+
* Returns `"lineNum#hash"` using the actual hash algorithm.
|
|
69
|
+
*/
|
|
70
|
+
prompt.registerHelper("href", (lineNum: unknown, content: unknown): string => {
|
|
71
|
+
const { ref } = formatHashlineRef(lineNum, content);
|
|
72
|
+
return JSON.stringify(ref);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* {{hline lineNum "content"}} — format a full read-style line with prefix.
|
|
77
|
+
* Returns `"lineNum#hash:content"`.
|
|
78
|
+
*/
|
|
79
|
+
prompt.registerHelper("hline", (lineNum: unknown, content: unknown): string => {
|
|
80
|
+
const { ref, text } = formatHashlineRef(lineNum, content);
|
|
81
|
+
return `${ref}:${text}`;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* {{anchor name checksum}} — render a branch anchor tag using the current anchor style.
|
|
86
|
+
* Style is resolved from the template context (`anchorStyle`) or defaults to "full".
|
|
87
|
+
*/
|
|
88
|
+
prompt.registerHelper("anchor", function (this: prompt.TemplateContext, name: string, checksum: string): string {
|
|
89
|
+
const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full";
|
|
90
|
+
return formatAnchor(name, checksum, style);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* {{sel "parent_Name.child_Name"}} — render a chunk path for `sel` fields in examples.
|
|
95
|
+
* In `full` style the path is returned as-is (`class_Server.fn_start`).
|
|
96
|
+
* In `kind` style each segment is trimmed to its kind prefix (`class.fn`).
|
|
97
|
+
* In `bare` style the path is omitted (the model uses only `crc` to identify chunks).
|
|
98
|
+
*/
|
|
99
|
+
prompt.registerHelper("sel", function (this: prompt.TemplateContext, chunkPath: string): string {
|
|
100
|
+
const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full";
|
|
101
|
+
if (style === "full") return chunkPath;
|
|
102
|
+
if (style === "bare") return "";
|
|
103
|
+
// kind: trim each segment to its kind prefix (before the first `_`)
|
|
104
|
+
return chunkPath
|
|
105
|
+
.split(".")
|
|
106
|
+
.map(seg => {
|
|
107
|
+
const idx = seg.indexOf("_");
|
|
108
|
+
return idx === -1 ? seg : seg.slice(0, idx);
|
|
109
|
+
})
|
|
110
|
+
.join(".");
|
|
111
|
+
});
|
|
112
|
+
}
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
* In `kind` style each segment is trimmed to its kind prefix (`class.fn`).
|
|
86
|
-
* In `bare` style the path is omitted (the model uses only `crc` to identify chunks).
|
|
87
|
-
*/
|
|
88
|
-
prompt.registerHelper("sel", function (this: prompt.TemplateContext, chunkPath: string): string {
|
|
89
|
-
const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full";
|
|
90
|
-
if (style === "full") return chunkPath;
|
|
91
|
-
if (style === "bare") return "";
|
|
92
|
-
// kind: trim each segment to its kind prefix (before the first `_`)
|
|
93
|
-
return chunkPath
|
|
94
|
-
.split(".")
|
|
95
|
-
.map(seg => {
|
|
96
|
-
const idx = seg.indexOf("_");
|
|
97
|
-
return idx === -1 ? seg : seg.slice(0, idx);
|
|
98
|
-
})
|
|
99
|
-
.join(".");
|
|
100
|
-
});
|
|
114
|
+
// Preserve original module-load behavior so existing importers continue to work
|
|
115
|
+
// unchanged. See `registerCodingAgentPromptHelpers` docblock.
|
|
116
|
+
registerCodingAgentPromptHelpers();
|
|
101
117
|
|
|
102
118
|
const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
|
|
103
119
|
const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.3.0",
|
|
21
|
+
"commit": "1d4143afd9489476ed3bf4dd6cfba1ffdd45b81a",
|
|
22
|
+
"shortCommit": "1d4143a",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-04-
|
|
26
|
-
"buildDate": "2026-04-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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+
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
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
|
|
35
|
-
if (
|
|
36
|
-
return
|
|
43
|
+
const raw = super.render(innerWidth);
|
|
44
|
+
if (raw.length === 0) {
|
|
45
|
+
return raw;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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)),
|
|
@@ -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"
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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)
|
|
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
|
-
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
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 = [];
|