@anthodev/pi-tools-style 0.1.4 → 0.2.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/README.md +128 -52
- package/index.ts +116 -90
- package/package.json +5 -5
- package/src/config-menu.ts +261 -0
- package/src/frame.ts +282 -97
- package/src/render-decorator.ts +181 -200
- package/src/settings.ts +202 -30
- package/src/tool-category.ts +3 -30
- package/src/tool-presentation.ts +1484 -0
- package/src/tool-renderer.ts +752 -0
- package/src/tool-spinner.ts +72 -89
- package/src/web-search-presentation.ts +986 -0
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic presentation for tool calls and results.
|
|
3
|
+
*
|
|
4
|
+
* A producer turns one render revision into a {@link ToolView}: a plain-text head
|
|
5
|
+
* (title, target, metas, status, duration) plus semantic sections that carry real
|
|
6
|
+
* content. The frame layout owns clamping, hidden-count hints, duration formatting
|
|
7
|
+
* and styling, so nothing here pre-truncates or pre-styles a head field.
|
|
8
|
+
*
|
|
9
|
+
* Untrusted display text is normalized for the terminal only. Stored args and
|
|
10
|
+
* results are never rewritten.
|
|
11
|
+
*/
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { isAbsolute, join, resolve as nodeResolvePath } from "node:path";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MAX_BYTES,
|
|
18
|
+
DEFAULT_MAX_LINES,
|
|
19
|
+
formatSize,
|
|
20
|
+
getLanguageFromPath,
|
|
21
|
+
getMarkdownTheme,
|
|
22
|
+
highlightCode,
|
|
23
|
+
renderDiff,
|
|
24
|
+
type Theme,
|
|
25
|
+
type ToolInfo,
|
|
26
|
+
type ToolRenderers,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import {
|
|
29
|
+
Markdown,
|
|
30
|
+
Text,
|
|
31
|
+
getCapabilities,
|
|
32
|
+
getImageDimensions,
|
|
33
|
+
hyperlink,
|
|
34
|
+
imageFallback,
|
|
35
|
+
stripTerminalSequences,
|
|
36
|
+
type Component,
|
|
37
|
+
} from "@earendil-works/pi-tui";
|
|
38
|
+
import { getIconMode } from "./settings.js";
|
|
39
|
+
|
|
40
|
+
export type ToolStatus = "pending" | "running" | "done" | "error" | "cancelled";
|
|
41
|
+
|
|
42
|
+
export interface ToolHead {
|
|
43
|
+
title: string;
|
|
44
|
+
target: string;
|
|
45
|
+
meta: readonly string[];
|
|
46
|
+
status: ToolStatus;
|
|
47
|
+
durationMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToolPreview {
|
|
51
|
+
edge: "head" | "tail";
|
|
52
|
+
count: number;
|
|
53
|
+
unit: "visual-lines" | "entries";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ToolSection {
|
|
57
|
+
label?: string;
|
|
58
|
+
component: Component;
|
|
59
|
+
slot: "call" | "result";
|
|
60
|
+
preview?: boolean;
|
|
61
|
+
previewPolicy?: ToolPreview;
|
|
62
|
+
/**
|
|
63
|
+
* Set when the component's rows are already wrapped and padded for `render(width)`,
|
|
64
|
+
* so layout can skip its defensive wrap. Owned SDK `Text`/`Markdown` sections set it;
|
|
65
|
+
* entry lists, custom and interactive components leave it unset.
|
|
66
|
+
*/
|
|
67
|
+
renderedWidth?: "padded";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ToolView {
|
|
71
|
+
layout: "framed" | "inline";
|
|
72
|
+
head: ToolHead;
|
|
73
|
+
sections: readonly ToolSection[];
|
|
74
|
+
preview?: ToolPreview;
|
|
75
|
+
tone?: "warning" | "error";
|
|
76
|
+
expanded: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ToolContext = Parameters<NonNullable<ToolRenderers["renderCall"]>>[2];
|
|
80
|
+
export type ToolResult = Parameters<NonNullable<ToolRenderers["renderResult"]>>[0];
|
|
81
|
+
|
|
82
|
+
export interface ToolSnapshot {
|
|
83
|
+
args: unknown;
|
|
84
|
+
result?: ToolResult;
|
|
85
|
+
context: ToolContext;
|
|
86
|
+
toolInfo?: ToolInfo;
|
|
87
|
+
presentation: "builtin" | "mcp" | "mcp-unresolved" | "web" | "execution" | "generic";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ToolViewFactory = (toolName: string, snapshot: ToolSnapshot, theme: Theme) => ToolView;
|
|
91
|
+
|
|
92
|
+
/** Execution ownership follows the exact requested name, independent of provenance. */
|
|
93
|
+
export function isExecutionToolName(toolName: string): boolean {
|
|
94
|
+
return toolName === "codemode" || toolName.startsWith("ctx_execute");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Shown in place of a target whose value was streamed but is not the expected string. */
|
|
98
|
+
const INVALID_ARG = "[invalid arg]";
|
|
99
|
+
/** Marker for a streamed argument of the wrong type, so a valid string can still be that exact text. */
|
|
100
|
+
const WRONG_TYPE = Symbol("pi-tools-style:wrong-type-arg");
|
|
101
|
+
/** A streamed argument value: the string, absent, or present with a non-string type. */
|
|
102
|
+
type ArgValue = string | typeof WRONG_TYPE | undefined;
|
|
103
|
+
/** Shown while a streamed argument has not arrived yet. */
|
|
104
|
+
const PENDING = "…";
|
|
105
|
+
|
|
106
|
+
const MCP_RESOURCE_TITLES: Record<string, string> = {
|
|
107
|
+
list_mcp_resources: "MCP Resources",
|
|
108
|
+
list_mcp_resource_templates: "MCP Resource Templates",
|
|
109
|
+
read_mcp_resource: "MCP Resource",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
/* -------------------------------------------------------------------------- */
|
|
114
|
+
/* Display-text normalization */
|
|
115
|
+
/* -------------------------------------------------------------------------- */
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize untrusted text for terminal display:
|
|
119
|
+
* - terminal escape sequences are removed;
|
|
120
|
+
* - `\r` is dropped, tabs become three spaces, newlines and printable Unicode stay;
|
|
121
|
+
* - remaining C0 controls become their Control Pictures glyph, DEL becomes `␡`
|
|
122
|
+
* and C1 controls become a literal `\xNN`.
|
|
123
|
+
*
|
|
124
|
+
* Applied only to displayed values; stored arguments and results are never mutated.
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeDisplayText(value: string): string {
|
|
127
|
+
if (value.length === 0) {
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
const stripped = stripTerminalSequences(value);
|
|
131
|
+
const withoutCarriageReturns = stripped.replace(/\r/g, "");
|
|
132
|
+
const spaced = withoutCarriageReturns.replace(/\t/g, " ");
|
|
133
|
+
if (!/[\u0000-\u001f\u007f-\u009f\ufdd0-\ufdef\ufff9-\ufffb]/.test(spaced)) {
|
|
134
|
+
return spaced;
|
|
135
|
+
}
|
|
136
|
+
let result = "";
|
|
137
|
+
for (const char of spaced) {
|
|
138
|
+
const code = char.codePointAt(0) ?? 0;
|
|
139
|
+
if (code === 0x0a) {
|
|
140
|
+
result += char;
|
|
141
|
+
} else if (code < 0x20) {
|
|
142
|
+
result += String.fromCodePoint(0x2400 + code);
|
|
143
|
+
} else if (code === 0x7f) {
|
|
144
|
+
result += "\u2421";
|
|
145
|
+
} else if (code >= 0x80 && code <= 0x9f) {
|
|
146
|
+
result += `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
147
|
+
} else if (code >= 0xfdd0 && code <= 0xfdef) {
|
|
148
|
+
// Noncharacters have no printable form, so they are dropped.
|
|
149
|
+
} else if (code >= 0xfff9 && code <= 0xfffb) {
|
|
150
|
+
// Interlinear annotation controls carry no displayable text.
|
|
151
|
+
} else {
|
|
152
|
+
result += char;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* -------------------------------------------------------------------------- */
|
|
159
|
+
/* Path resolution and linking */
|
|
160
|
+
/* -------------------------------------------------------------------------- */
|
|
161
|
+
|
|
162
|
+
/** Convert Git Bash, MSYS, Cygwin and WSL drive paths to native Windows form. */
|
|
163
|
+
function normalizeWindowsShellPath(filePath: string): string {
|
|
164
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\")) {
|
|
165
|
+
return filePath;
|
|
166
|
+
}
|
|
167
|
+
const match = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i.exec(filePath);
|
|
168
|
+
if (!match) {
|
|
169
|
+
return filePath;
|
|
170
|
+
}
|
|
171
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
172
|
+
return `${(match[1] ?? "").toUpperCase()}:\\${suffix ?? ""}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeDisplayPathPart(value: string): string {
|
|
176
|
+
let normalized = process.platform === "win32" ? normalizeWindowsShellPath(value) : value;
|
|
177
|
+
if (normalized === "~") {
|
|
178
|
+
return homedir();
|
|
179
|
+
}
|
|
180
|
+
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
|
|
181
|
+
return join(homedir(), normalized.slice(2));
|
|
182
|
+
}
|
|
183
|
+
if (/^file:\/\//.test(normalized)) {
|
|
184
|
+
// A malformed URL or a non-local host must fail here so `linkPath` keeps the
|
|
185
|
+
// plain text instead of fabricating a cwd-relative file link.
|
|
186
|
+
return fileURLToPath(normalized);
|
|
187
|
+
}
|
|
188
|
+
return normalized;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Pure display counterpart of the host tool's path resolution: no trim, no
|
|
193
|
+
* unicode-space folding, no filesystem access. Windows shell drive paths are
|
|
194
|
+
* converted before tilde expansion, `file://` URLs are decoded, and the result is
|
|
195
|
+
* resolved against `cwd` when relative.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveDisplayPath(input: string, cwd: string): string {
|
|
198
|
+
const normalized = normalizeDisplayPathPart(input);
|
|
199
|
+
const normalizedCwd = normalizeDisplayPathPart(cwd);
|
|
200
|
+
return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedCwd, normalized);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Link a styled path to its resolved `file://` URL when the terminal supports links. */
|
|
204
|
+
function linkPath(styledText: string, rawPath: string, cwd: string): string {
|
|
205
|
+
if (!rawPath) {
|
|
206
|
+
return styledText;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (!getCapabilities().hyperlinks) {
|
|
210
|
+
return styledText;
|
|
211
|
+
}
|
|
212
|
+
return hyperlink(styledText, pathToFileURL(resolveDisplayPath(rawPath, cwd)).href);
|
|
213
|
+
} catch {
|
|
214
|
+
return styledText;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/* -------------------------------------------------------------------------- */
|
|
220
|
+
/* Result text extraction */
|
|
221
|
+
/* -------------------------------------------------------------------------- */
|
|
222
|
+
|
|
223
|
+
interface ContentBlock {
|
|
224
|
+
type?: string;
|
|
225
|
+
text?: string;
|
|
226
|
+
data?: string;
|
|
227
|
+
mimeType?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Shared text extraction for a tool result: text blocks are normalized and joined
|
|
232
|
+
* with newlines; image blocks become a text fallback when the terminal cannot show
|
|
233
|
+
* them or images are hidden for this revision.
|
|
234
|
+
*/
|
|
235
|
+
export function extractToolText(result: ToolResult | undefined, showImages: boolean): string {
|
|
236
|
+
if (!result) {
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
const blocks = (result.content ?? []) as readonly ContentBlock[];
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
for (const block of blocks) {
|
|
242
|
+
if (block.type === "text") {
|
|
243
|
+
parts.push(normalizeDisplayText(block.text ?? ""));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const images = blocks.filter((block) => block.type === "image");
|
|
247
|
+
if (images.length > 0 && (!getCapabilities().images || !showImages)) {
|
|
248
|
+
for (const image of images) {
|
|
249
|
+
const mimeType = image.mimeType ?? "image/unknown";
|
|
250
|
+
const dimensions =
|
|
251
|
+
image.data && image.mimeType ? (getImageDimensions(image.data, image.mimeType) ?? undefined) : undefined;
|
|
252
|
+
// The MIME comes from the tool, so only the generated display text is sanitized.
|
|
253
|
+
parts.push(normalizeDisplayText(imageFallback(mimeType, dimensions)));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return parts.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* -------------------------------------------------------------------------- */
|
|
260
|
+
/* Head computation */
|
|
261
|
+
/* -------------------------------------------------------------------------- */
|
|
262
|
+
|
|
263
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
264
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
265
|
+
? (value as Record<string, unknown>)
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
270
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function argValue(args: Record<string, unknown> | undefined, key: string): ArgValue {
|
|
274
|
+
const value = args?.[key];
|
|
275
|
+
if (value === undefined) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
return typeof value === "string" ? value : WRONG_TYPE;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveStatus(snapshot: ToolSnapshot): ToolStatus {
|
|
282
|
+
if (snapshot.result && !snapshot.context.isPartial) {
|
|
283
|
+
return snapshot.context.isError ? "error" : "done";
|
|
284
|
+
}
|
|
285
|
+
if (snapshot.context.executionStarted || (snapshot.result !== undefined && snapshot.context.isPartial)) {
|
|
286
|
+
return "running";
|
|
287
|
+
}
|
|
288
|
+
return "pending";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Sanitize raw display text for a single-line field (meta entry, note or path
|
|
293
|
+
* annotation). Styled strings must never be passed through here.
|
|
294
|
+
*/
|
|
295
|
+
function singleLineText(value: string): string {
|
|
296
|
+
return normalizeDisplayText(value).replace(/\n/g, " ");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function makeHead(
|
|
300
|
+
title: string,
|
|
301
|
+
target: string,
|
|
302
|
+
meta: readonly string[],
|
|
303
|
+
snapshot: ToolSnapshot,
|
|
304
|
+
): ToolHead {
|
|
305
|
+
const duration = snapshot.context.durationMs;
|
|
306
|
+
const durationMs = duration !== undefined && Number.isFinite(duration) && duration >= 0 ? duration : undefined;
|
|
307
|
+
// Target and meta arrive normalized by their producer, so links and newlines
|
|
308
|
+
// generated here survive for the layout to reveal and expand.
|
|
309
|
+
return {
|
|
310
|
+
title,
|
|
311
|
+
target,
|
|
312
|
+
meta,
|
|
313
|
+
status: resolveStatus(snapshot),
|
|
314
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
/* Components */
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
|
|
322
|
+
function textComponent(lines: readonly string[]): Component {
|
|
323
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Component shape for entry lists that must be clamped per whole entry before
|
|
328
|
+
* wrapping: the logical entries stay inspectable next to the rendered component.
|
|
329
|
+
*/
|
|
330
|
+
export interface EntryLinesComponent extends Component {
|
|
331
|
+
readonly entryLines: readonly string[];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Entry-list component that exposes its logical entries so the layout can select
|
|
336
|
+
* whole entries before wrapping instead of cutting a wrapped entry in half.
|
|
337
|
+
*/
|
|
338
|
+
function entriesComponent(lines: readonly string[]): EntryLinesComponent {
|
|
339
|
+
const text = new Text(lines.join("\n"), 0, 0);
|
|
340
|
+
return {
|
|
341
|
+
entryLines: [...lines],
|
|
342
|
+
render: (width: number) => text.render(width),
|
|
343
|
+
invalidate: () => text.invalidate(),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function trimTrailingEmptyLines(lines: readonly string[]): string[] {
|
|
348
|
+
let end = lines.length;
|
|
349
|
+
while (end > 0 && lines[end - 1] === "") {
|
|
350
|
+
end--;
|
|
351
|
+
}
|
|
352
|
+
return lines.slice(0, end);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface SectionOptions {
|
|
356
|
+
label?: string;
|
|
357
|
+
slot?: "call" | "result";
|
|
358
|
+
preview?: boolean;
|
|
359
|
+
previewPolicy?: ToolPreview;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function section(lines: readonly string[], options: SectionOptions = {}): ToolSection | undefined {
|
|
363
|
+
if (lines.length === 0 || lines.every((line) => line.length === 0)) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const result: ToolSection = {
|
|
367
|
+
component: textComponent(lines),
|
|
368
|
+
slot: options.slot ?? "result",
|
|
369
|
+
preview: options.preview ?? true,
|
|
370
|
+
renderedWidth: "padded",
|
|
371
|
+
};
|
|
372
|
+
if (options.label !== undefined) {
|
|
373
|
+
result.label = options.label;
|
|
374
|
+
}
|
|
375
|
+
if (options.previewPolicy !== undefined) {
|
|
376
|
+
result.previewPolicy = options.previewPolicy;
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sectionsOf(...candidates: readonly (ToolSection | undefined)[]): ToolSection[] {
|
|
382
|
+
return candidates.filter((candidate): candidate is ToolSection => candidate !== undefined);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const INPUT_PREVIEW: ToolPreview = { edge: "head", count: 10, unit: "visual-lines" };
|
|
386
|
+
const OUTPUT_PREVIEW: ToolPreview = { edge: "head", count: 5, unit: "visual-lines" };
|
|
387
|
+
const COMMAND_PREVIEW: ToolPreview = { edge: "head", count: 1, unit: "visual-lines" };
|
|
388
|
+
const WRITE_PREVIEW: ToolPreview = { edge: "head", count: 8, unit: "visual-lines" };
|
|
389
|
+
|
|
390
|
+
/** Explicit null/primitive arguments are delivered data, not a missing revision. */
|
|
391
|
+
function suppliedArgs(snapshot: ToolSnapshot): unknown {
|
|
392
|
+
return snapshot.args === undefined ? snapshot.context.args : snapshot.args;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function argumentText(value: unknown, pretty = false): string {
|
|
396
|
+
return normalizeDisplayText(typeof value === "number" && !Number.isFinite(value) ? String(value) : jsonText(value, pretty && typeof value !== "string"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function stringInput(value: unknown): string {
|
|
400
|
+
if (value === undefined) {
|
|
401
|
+
return PENDING;
|
|
402
|
+
}
|
|
403
|
+
if (typeof value !== "string") {
|
|
404
|
+
return `${INVALID_ARG} (${argumentText(value)})`;
|
|
405
|
+
}
|
|
406
|
+
const normalized = normalizeDisplayText(value);
|
|
407
|
+
return normalized.trim().length === 0 ? argumentText(value) : normalized;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function inputField(key: string, value: unknown, theme: Theme, cwd: string, required = false): string {
|
|
411
|
+
const label = normalizeDisplayText(key);
|
|
412
|
+
const pathText = key === "path" && typeof value === "string" ? normalizeDisplayText(value) : undefined;
|
|
413
|
+
if (pathText !== undefined && pathText.trim().length > 0) {
|
|
414
|
+
// Display the full caller text, while resolving only the hyperlink destination.
|
|
415
|
+
return `${label}: ${linkPath(theme.fg("accent", pathText), value as string, cwd)}`;
|
|
416
|
+
}
|
|
417
|
+
return `${label}: ${required || key === "path" || key === "pattern" || key === "glob" ? stringInput(value) : argumentText(value)}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Lightweight complete input beside an independently retained result primary. */
|
|
421
|
+
function nativeInputSection(
|
|
422
|
+
snapshot: ToolSnapshot,
|
|
423
|
+
theme: Theme,
|
|
424
|
+
required: readonly string[],
|
|
425
|
+
excluded: readonly string[] = [],
|
|
426
|
+
preview = true,
|
|
427
|
+
): ToolSection {
|
|
428
|
+
const value = suppliedArgs(snapshot);
|
|
429
|
+
const args = asRecord(value);
|
|
430
|
+
const lines: string[] = [];
|
|
431
|
+
if (args) {
|
|
432
|
+
for (const key of required) {
|
|
433
|
+
lines.push(inputField(key, args[key], theme, snapshot.context.cwd, true));
|
|
434
|
+
}
|
|
435
|
+
const entries = Object.entries(args);
|
|
436
|
+
for (const [key, entry] of entries) {
|
|
437
|
+
if (!required.includes(key) && !excluded.includes(key)) {
|
|
438
|
+
lines.push(inputField(key, entry, theme, snapshot.context.cwd));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (entries.length === 0) {
|
|
442
|
+
lines.push("{}");
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
lines.push(value === undefined ? PENDING : argumentText(value, true));
|
|
446
|
+
}
|
|
447
|
+
return section(lines, { label: "Input", slot: "call", preview, previewPolicy: INPUT_PREVIEW })!;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function fullArgumentsSection(args: unknown): ToolSection {
|
|
451
|
+
const text = args === undefined ? PENDING : argumentText(args, true);
|
|
452
|
+
return section(text.split("\n"), { label: "Input", slot: "call", previewPolicy: INPUT_PREVIEW })!;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* -------------------------------------------------------------------------- */
|
|
456
|
+
/* Retained primaries */
|
|
457
|
+
/* -------------------------------------------------------------------------- */
|
|
458
|
+
|
|
459
|
+
type PrimaryPart = string | number | boolean | undefined;
|
|
460
|
+
|
|
461
|
+
interface ThemeSignature {
|
|
462
|
+
readonly colors: object | undefined;
|
|
463
|
+
readonly mode: unknown;
|
|
464
|
+
readonly appearance: unknown;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface PreparedPrimary {
|
|
468
|
+
readonly kind: string;
|
|
469
|
+
readonly theme: ThemeSignature;
|
|
470
|
+
readonly parts: readonly PrimaryPart[];
|
|
471
|
+
readonly section: ToolSection | undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One retained primary preparation per render state — never a history or a path map. */
|
|
475
|
+
const PREPARED_PRIMARIES = new WeakMap<object, PreparedPrimary>();
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The installed SDK theme exposes its palette, colour mode and appearance as stable
|
|
479
|
+
* O(1) values. Neither the theme Proxy identity nor a freshly allocated
|
|
480
|
+
* `getMarkdownTheme()` may be used as a key: both change without changing a colour.
|
|
481
|
+
*/
|
|
482
|
+
function themeSignature(theme: Theme): ThemeSignature {
|
|
483
|
+
const source: Record<string, unknown> = theme as unknown as Record<string, unknown>;
|
|
484
|
+
const colors: unknown = source.colors;
|
|
485
|
+
const colorMode: unknown = source.getColorMode;
|
|
486
|
+
const appearance: unknown = source.appearance;
|
|
487
|
+
return {
|
|
488
|
+
colors: typeof colors === "object" && colors !== null ? (colors as object) : undefined,
|
|
489
|
+
mode: typeof colorMode === "function" ? (colorMode as () => unknown).call(theme) : colorMode,
|
|
490
|
+
appearance,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function matchesPreparedPrimary(
|
|
495
|
+
current: PreparedPrimary,
|
|
496
|
+
kind: string,
|
|
497
|
+
theme: ThemeSignature,
|
|
498
|
+
parts: readonly PrimaryPart[],
|
|
499
|
+
): boolean {
|
|
500
|
+
if (current.kind !== kind) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
const sameTheme =
|
|
504
|
+
current.theme.colors === theme.colors &&
|
|
505
|
+
current.theme.mode === theme.mode &&
|
|
506
|
+
current.theme.appearance === theme.appearance;
|
|
507
|
+
if (!sameTheme || current.parts.length !== parts.length) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
return current.parts.every((part, index) => part === parts[index]);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reuses the current primary section of one render state while its effective
|
|
515
|
+
* immutable inputs and theme are unchanged, and rebuilds it otherwise. Callers read
|
|
516
|
+
* every mutable args/result value first and pass those values as `parts`, so an
|
|
517
|
+
* in-place mutation always reaches the rebuilt section; no caller-owned object is
|
|
518
|
+
* ever retained here.
|
|
519
|
+
*/
|
|
520
|
+
export function reuseToolSection(
|
|
521
|
+
state: object | undefined,
|
|
522
|
+
theme: Theme,
|
|
523
|
+
kind: string,
|
|
524
|
+
parts: readonly PrimaryPart[],
|
|
525
|
+
build: () => ToolSection | undefined,
|
|
526
|
+
): ToolSection | undefined {
|
|
527
|
+
if (state === undefined) {
|
|
528
|
+
return build();
|
|
529
|
+
}
|
|
530
|
+
const themeKey = themeSignature(theme);
|
|
531
|
+
const current = PREPARED_PRIMARIES.get(state);
|
|
532
|
+
if (current !== undefined && matchesPreparedPrimary(current, kind, themeKey, parts)) {
|
|
533
|
+
return current.section;
|
|
534
|
+
}
|
|
535
|
+
const section = build();
|
|
536
|
+
PREPARED_PRIMARIES.set(state, { kind, theme: themeKey, parts: parts.slice(), section });
|
|
537
|
+
return section;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Drops the retained preparation for one render state after a root/environment change. */
|
|
541
|
+
export function invalidateToolViewCache(state: object): void {
|
|
542
|
+
PREPARED_PRIMARIES.delete(state);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/* -------------------------------------------------------------------------- */
|
|
546
|
+
/* Generic */
|
|
547
|
+
/* -------------------------------------------------------------------------- */
|
|
548
|
+
|
|
549
|
+
function genericView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
550
|
+
const isError = snapshot.context.isError;
|
|
551
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages);
|
|
552
|
+
return {
|
|
553
|
+
layout: "framed",
|
|
554
|
+
head: makeHead(normalizeDisplayText(toolName), "", [], snapshot),
|
|
555
|
+
sections: sectionsOf(
|
|
556
|
+
fullArgumentsSection(suppliedArgs(snapshot)),
|
|
557
|
+
output.length > 0
|
|
558
|
+
? section(output.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line)), {
|
|
559
|
+
label: "Output", slot: "result", previewPolicy: OUTPUT_PREVIEW,
|
|
560
|
+
}) : undefined,
|
|
561
|
+
),
|
|
562
|
+
...(isError ? { tone: "error" } : {}),
|
|
563
|
+
expanded: snapshot.context.expanded,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* -------------------------------------------------------------------------- */
|
|
568
|
+
/* Context execution */
|
|
569
|
+
/* -------------------------------------------------------------------------- */
|
|
570
|
+
|
|
571
|
+
const EXECUTION_INPUT_PREVIEW: ToolPreview = { edge: "head", count: 10, unit: "visual-lines" };
|
|
572
|
+
const EXECUTION_OUTPUT_PREVIEW: ToolPreview = { edge: "head", count: 5, unit: "visual-lines" };
|
|
573
|
+
const EXECUTION_LANGUAGES: Readonly<Record<string, true | undefined>> = {
|
|
574
|
+
javascript: true, typescript: true, python: true, shell: true, ruby: true, go: true,
|
|
575
|
+
rust: true, php: true, perl: true, r: true, elixir: true, csharp: true,
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
function executionCodeSection(
|
|
579
|
+
code: ArgValue,
|
|
580
|
+
language: string | undefined,
|
|
581
|
+
state: object | undefined,
|
|
582
|
+
theme: Theme,
|
|
583
|
+
label?: string,
|
|
584
|
+
): ToolSection {
|
|
585
|
+
const normalized = typeof code === "string" ? normalizeDisplayText(code) : undefined;
|
|
586
|
+
const literal = normalized !== undefined && normalized.trim().length === 0;
|
|
587
|
+
const text = code === undefined ? PENDING : code === WRONG_TYPE ? INVALID_ARG
|
|
588
|
+
: literal ? `code: ${normalizeDisplayText(jsonText(code, false))}` : normalized!;
|
|
589
|
+
const grammar = language !== undefined && Object.hasOwn(EXECUTION_LANGUAGES, language)
|
|
590
|
+
? language === "shell" ? "bash" : language : undefined;
|
|
591
|
+
return reuseToolSection(state, theme, "execution-code", [text, language, grammar, label, code === WRONG_TYPE, literal], () => {
|
|
592
|
+
const lines = typeof code === "string" && !literal && grammar !== undefined
|
|
593
|
+
? highlightCode(text, grammar) : text.split("\n");
|
|
594
|
+
return section(lines, {
|
|
595
|
+
slot: "call", previewPolicy: EXECUTION_INPUT_PREVIEW,
|
|
596
|
+
...(label === undefined ? {} : { label }),
|
|
597
|
+
});
|
|
598
|
+
})!;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function executionArgumentsSection(args: unknown, label?: string): ToolSection | undefined {
|
|
602
|
+
const text = args === undefined ? PENDING : normalizeDisplayText(jsonText(args, true));
|
|
603
|
+
return section(text.split("\n"), {
|
|
604
|
+
slot: "call", preview: false, ...(label === undefined ? {} : { label }),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function contextInputEcho(toolName: string, args: unknown): string | undefined {
|
|
609
|
+
if (toolName !== "ctx_execute" && toolName !== "ctx_execute_file") return undefined;
|
|
610
|
+
const record = asRecord(args);
|
|
611
|
+
if (typeof record?.language !== "string" || typeof record.code !== "string") return undefined;
|
|
612
|
+
const file = toolName === "ctx_execute_file";
|
|
613
|
+
if (file && typeof record.path !== "string") return undefined;
|
|
614
|
+
const code = record.code;
|
|
615
|
+
const shown = code.length <= 2000 ? code : `${code.slice(0, 2000)}\n… (truncated)`;
|
|
616
|
+
return `${file && record.path ? `path=${record.path}\n` : ""}\`\`\`${record.language}\n${shown}\n\`\`\`\n\n`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function executionOutputText(
|
|
620
|
+
toolName: string,
|
|
621
|
+
args: unknown,
|
|
622
|
+
result: ToolResult | undefined,
|
|
623
|
+
showImages: boolean,
|
|
624
|
+
): string {
|
|
625
|
+
const first = result?.content[0];
|
|
626
|
+
if (result && first?.type === "text") {
|
|
627
|
+
const echo = contextInputEcho(toolName, args);
|
|
628
|
+
let remaining: string | undefined;
|
|
629
|
+
if (echo !== undefined && first.text.startsWith(echo)) {
|
|
630
|
+
remaining = first.text.slice(echo.length);
|
|
631
|
+
} else if (toolName === "codemode" && /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/.test(first.text)) {
|
|
632
|
+
remaining = "";
|
|
633
|
+
}
|
|
634
|
+
if (remaining !== undefined) {
|
|
635
|
+
const content = remaining.length === 0 ? result.content.slice(1)
|
|
636
|
+
: [{ ...first, text: remaining }, ...result.content.slice(1)];
|
|
637
|
+
return extractToolText({ ...result, content }, showImages);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return extractToolText(result, showImages);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function executionView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
644
|
+
return toolName === "codemode" ? codemodeView(snapshot, theme) : contextExecutionView(toolName, snapshot, theme);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Match the native cents/two-significant-digit convention, including zero. */
|
|
648
|
+
function executionCost(cost: number): string {
|
|
649
|
+
return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function codemodeCallsSection(details: unknown, expanded: boolean, theme: Theme): ToolSection | undefined {
|
|
653
|
+
const calls = asRecord(details)?.calls;
|
|
654
|
+
if (!Array.isArray(calls) || calls.length === 0) return undefined;
|
|
655
|
+
const mode = getIconMode();
|
|
656
|
+
const states = {
|
|
657
|
+
running: { ascii: "...", nerd: "…", color: "warning" },
|
|
658
|
+
ok: { ascii: "ok", nerd: "✓", color: "success" },
|
|
659
|
+
error: { ascii: "!", nerd: "✗", color: "error" },
|
|
660
|
+
cancelled: { ascii: "-", nerd: "⊘", color: "muted" },
|
|
661
|
+
} as const;
|
|
662
|
+
const entries = calls.map((value: unknown) => {
|
|
663
|
+
const call = asRecord(value);
|
|
664
|
+
if (!call || typeof call.id !== "string" || typeof call.name !== "string"
|
|
665
|
+
|| typeof call.args !== "string" || typeof call.status !== "string"
|
|
666
|
+
|| !Object.hasOwn(states, call.status)) {
|
|
667
|
+
return normalizeDisplayText(jsonText(value, true));
|
|
668
|
+
}
|
|
669
|
+
const state = states[call.status as keyof typeof states];
|
|
670
|
+
const symbol = mode === "off" ? call.status : mode === "nerd-font" ? state.nerd : state.ascii;
|
|
671
|
+
const normalizedArgs = normalizeDisplayText(call.args);
|
|
672
|
+
const args = !expanded && normalizedArgs.length > 80 ? `${normalizedArgs.slice(0, 77)}...` : normalizedArgs;
|
|
673
|
+
let line = `${theme.fg(state.color, symbol)} ${theme.fg("toolTitle", normalizeDisplayText(call.name))}`;
|
|
674
|
+
if (args) line += ` ${theme.fg("muted", args)}`;
|
|
675
|
+
const duration = finiteNumber(call.durationMs);
|
|
676
|
+
if (duration !== undefined && duration >= 0) {
|
|
677
|
+
line += ` ${theme.fg("dim", duration < 1000 ? `${Math.round(duration)}ms` : `${(duration / 1000).toFixed(1)}s`)}`;
|
|
678
|
+
}
|
|
679
|
+
const cost = finiteNumber(call.cost);
|
|
680
|
+
if (cost !== undefined && cost >= 0) line += ` ${theme.fg("dim", executionCost(cost))}`;
|
|
681
|
+
if (expanded && typeof call.error === "string" && call.error.length > 0) {
|
|
682
|
+
line += `\n ${theme.fg("error", normalizeDisplayText(call.error).split("\n").join("\n "))}`;
|
|
683
|
+
}
|
|
684
|
+
return line;
|
|
685
|
+
});
|
|
686
|
+
return {
|
|
687
|
+
label: "Calls", slot: "result", component: entriesComponent(entries),
|
|
688
|
+
previewPolicy: { edge: "tail", count: 8, unit: "entries" },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function codemodeView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
693
|
+
const args = snapshot.args;
|
|
694
|
+
const record = asRecord(args);
|
|
695
|
+
const extraEntries = argEntries(args).filter(([key]) => key !== "code");
|
|
696
|
+
const details = asRecord(snapshot.result?.details);
|
|
697
|
+
const calls = details?.calls;
|
|
698
|
+
let total = 0;
|
|
699
|
+
let positiveCosts = 0;
|
|
700
|
+
if (Array.isArray(calls)) {
|
|
701
|
+
for (const call of calls) {
|
|
702
|
+
const cost = finiteNumber(asRecord(call)?.cost);
|
|
703
|
+
if (cost !== undefined && cost >= 0) {
|
|
704
|
+
total += cost;
|
|
705
|
+
if (cost > 0) positiveCosts++;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const output = snapshot.context.isPartial ? ""
|
|
710
|
+
: executionOutputText("codemode", args, snapshot.result, snapshot.context.showImages);
|
|
711
|
+
const fullOutputPath = details?.fullOutputPath;
|
|
712
|
+
const view: ToolView = {
|
|
713
|
+
layout: "framed",
|
|
714
|
+
head: makeHead("codemode", "", [], snapshot),
|
|
715
|
+
sections: sectionsOf(
|
|
716
|
+
executionCodeSection(argValue(record, "code"), "javascript", snapshot.context.state, theme, "Input"),
|
|
717
|
+
extraEntries.length > 0 ? executionArgumentsSection(Object.fromEntries(extraEntries)) : undefined,
|
|
718
|
+
codemodeCallsSection(snapshot.result?.details, snapshot.context.expanded, theme),
|
|
719
|
+
positiveCosts > 1 ? section([theme.fg("muted", `Model calls: ${executionCost(total)}`)], { preview: false }) : undefined,
|
|
720
|
+
section(output.split("\n"), { label: "Output", slot: "result", previewPolicy: EXECUTION_OUTPUT_PREVIEW }),
|
|
721
|
+
typeof fullOutputPath === "string" && fullOutputPath.length > 0
|
|
722
|
+
? section([theme.fg("muted", `Full output: ${normalizeDisplayText(fullOutputPath)}`)], { preview: false }) : undefined,
|
|
723
|
+
),
|
|
724
|
+
expanded: snapshot.context.expanded,
|
|
725
|
+
};
|
|
726
|
+
if (snapshot.context.isError) view.tone = "error";
|
|
727
|
+
return view;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function contextExecutionView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
731
|
+
const args = snapshot.args;
|
|
732
|
+
const record = asRecord(args);
|
|
733
|
+
const known = toolName === "ctx_execute" || toolName === "ctx_execute_file";
|
|
734
|
+
const callSections: ToolSection[] = [];
|
|
735
|
+
if (known) {
|
|
736
|
+
const metadata: string[] = [];
|
|
737
|
+
const fields = toolName === "ctx_execute_file" ? ["path", "language"] : ["language"];
|
|
738
|
+
for (const field of fields) {
|
|
739
|
+
const value = argValue(record, field);
|
|
740
|
+
metadata.push(`${field}: ${value === undefined ? PENDING : value === WRONG_TYPE ? INVALID_ARG : normalizeDisplayText(value)}`);
|
|
741
|
+
}
|
|
742
|
+
callSections.push(section(metadata, { label: "Input", slot: "call", preview: false })!);
|
|
743
|
+
const language = argValue(record, "language");
|
|
744
|
+
callSections.push(executionCodeSection(
|
|
745
|
+
argValue(record, "code"), typeof language === "string" ? language : undefined,
|
|
746
|
+
snapshot.context.state, theme,
|
|
747
|
+
));
|
|
748
|
+
const consumed = new Set([...fields, "code"]);
|
|
749
|
+
const options = argEntries(args).filter(([key]) => !consumed.has(key));
|
|
750
|
+
if (options.length > 0) {
|
|
751
|
+
const lines = options.flatMap(([key, value]) => normalizeDisplayText(
|
|
752
|
+
`${key}: ${jsonText(value, false)}${key === "timeout" && typeof value === "number" ? " ms" : ""}`,
|
|
753
|
+
).split("\n"));
|
|
754
|
+
callSections.push(section(lines, { slot: "call", preview: false })!);
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
const text = args === undefined ? PENDING : normalizeDisplayText(jsonText(args, true));
|
|
758
|
+
const primary = reuseToolSection(snapshot.context.state, theme, "execution-arguments", [toolName, text], () => {
|
|
759
|
+
const input = executionArgumentsSection(args, "Input");
|
|
760
|
+
if (input) {
|
|
761
|
+
input.preview = true;
|
|
762
|
+
input.previewPolicy = EXECUTION_INPUT_PREVIEW;
|
|
763
|
+
}
|
|
764
|
+
return input;
|
|
765
|
+
});
|
|
766
|
+
if (primary) callSections.push(primary);
|
|
767
|
+
}
|
|
768
|
+
const output = executionOutputText(toolName, args, snapshot.result, snapshot.context.showImages);
|
|
769
|
+
const view: ToolView = {
|
|
770
|
+
layout: "framed",
|
|
771
|
+
head: makeHead(normalizeDisplayText(toolName), "", [], snapshot),
|
|
772
|
+
sections: sectionsOf(
|
|
773
|
+
...callSections,
|
|
774
|
+
section(output.split("\n"), { label: "Output", slot: "result", previewPolicy: EXECUTION_OUTPUT_PREVIEW }),
|
|
775
|
+
),
|
|
776
|
+
expanded: snapshot.context.expanded,
|
|
777
|
+
};
|
|
778
|
+
if (snapshot.context.isError) view.tone = "error";
|
|
779
|
+
return view;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/* -------------------------------------------------------------------------- */
|
|
783
|
+
/* Shell (bash / powershell) */
|
|
784
|
+
/* -------------------------------------------------------------------------- */
|
|
785
|
+
|
|
786
|
+
function shellView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
787
|
+
const value = suppliedArgs(snapshot);
|
|
788
|
+
const args = asRecord(value);
|
|
789
|
+
const command = args?.command;
|
|
790
|
+
const isPowerShell = toolName === "powershell";
|
|
791
|
+
const prompt = isPowerShell ? "PS> " : "$ ";
|
|
792
|
+
const commandText = args
|
|
793
|
+
? `${typeof command === "string" ? prompt : ""}${stringInput(command)}`
|
|
794
|
+
: value === undefined ? PENDING : argumentText(value, true);
|
|
795
|
+
const options: string[] = [];
|
|
796
|
+
if (args) {
|
|
797
|
+
const entries = Object.entries(args);
|
|
798
|
+
for (const [key, entry] of entries) {
|
|
799
|
+
if (key !== "command") {
|
|
800
|
+
options.push(`${normalizeDisplayText(key)}: ${key === "timeout" && finiteNumber(entry) !== undefined ? `${entry}s` : argumentText(entry)}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (entries.length === 0) {
|
|
804
|
+
options.push("{}");
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const head = makeHead(isPowerShell ? "PowerShell" : "Bash", "", [], snapshot);
|
|
808
|
+
|
|
809
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
810
|
+
const details = asRecord(snapshot.result?.details);
|
|
811
|
+
const truncation = asRecord(details?.truncation);
|
|
812
|
+
const fullOutputPath = typeof details?.fullOutputPath === "string" ? details.fullOutputPath : undefined;
|
|
813
|
+
|
|
814
|
+
const detailsLines: string[] = [];
|
|
815
|
+
if (truncation?.truncated === true) {
|
|
816
|
+
detailsLines.push(theme.fg("warning", `[Truncated: ${formatSize(finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES)} limit]`));
|
|
817
|
+
}
|
|
818
|
+
if (fullOutputPath) {
|
|
819
|
+
detailsLines.push(theme.fg("muted", `Full output: ${singleLineText(fullOutputPath)}`));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const view: ToolView = {
|
|
823
|
+
layout: "framed",
|
|
824
|
+
head,
|
|
825
|
+
sections: sectionsOf(
|
|
826
|
+
section(commandText.split("\n"), { label: "Command", slot: "call", previewPolicy: COMMAND_PREVIEW }),
|
|
827
|
+
options.length > 0 ? section(options, { slot: "call", preview: false }) : undefined,
|
|
828
|
+
reuseToolSection(snapshot.context.state, theme, "shell-output", [output], () =>
|
|
829
|
+
output.length > 0 ? section(output.split("\n"), { label: "Output", slot: "result" }) : undefined,
|
|
830
|
+
),
|
|
831
|
+
detailsLines.length > 0 ? section(detailsLines, { preview: false, slot: "result" }) : undefined,
|
|
832
|
+
),
|
|
833
|
+
preview: { edge: "tail", count: 10, unit: "visual-lines" },
|
|
834
|
+
expanded: snapshot.context.expanded,
|
|
835
|
+
};
|
|
836
|
+
if (snapshot.context.isError) {
|
|
837
|
+
view.tone = "error";
|
|
838
|
+
}
|
|
839
|
+
return view;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/* -------------------------------------------------------------------------- */
|
|
843
|
+
/* Read */
|
|
844
|
+
/* -------------------------------------------------------------------------- */
|
|
845
|
+
|
|
846
|
+
function readView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
847
|
+
const args = asRecord(suppliedArgs(snapshot));
|
|
848
|
+
const rawPath = argValue(args, "path");
|
|
849
|
+
const head = makeHead("Read", "", [], snapshot);
|
|
850
|
+
|
|
851
|
+
const result = snapshot.result;
|
|
852
|
+
const isError = snapshot.context.isError;
|
|
853
|
+
// No whole-output trim: leading indentation and blank lines belong to the file.
|
|
854
|
+
const output = extractToolText(result, snapshot.context.showImages);
|
|
855
|
+
const lang = !isError && rawPath !== undefined && rawPath !== WRONG_TYPE ? getLanguageFromPath(rawPath) : undefined;
|
|
856
|
+
const bodyLines = trimTrailingEmptyLines(output.split("\n"));
|
|
857
|
+
|
|
858
|
+
let contentSection: ToolSection | undefined;
|
|
859
|
+
if (bodyLines.length > 0) {
|
|
860
|
+
const bodyText = bodyLines.join("\n");
|
|
861
|
+
const state = snapshot.context.state;
|
|
862
|
+
if (lang === "markdown") {
|
|
863
|
+
contentSection = reuseToolSection(state, theme, "read-markdown", [bodyText], () => ({
|
|
864
|
+
component: new Markdown(bodyText, 0, 0, getMarkdownTheme()),
|
|
865
|
+
label: "Output",
|
|
866
|
+
slot: "result",
|
|
867
|
+
preview: true,
|
|
868
|
+
renderedWidth: "padded",
|
|
869
|
+
}));
|
|
870
|
+
} else if (lang) {
|
|
871
|
+
const language = lang;
|
|
872
|
+
contentSection = reuseToolSection(state, theme, "read-code", [language, bodyText], () =>
|
|
873
|
+
section(highlightCode(bodyText, language), { label: "Output", preview: true, slot: "result" }),
|
|
874
|
+
);
|
|
875
|
+
} else {
|
|
876
|
+
const color = isError ? "error" : "toolOutput";
|
|
877
|
+
contentSection = reuseToolSection(state, theme, "read-text", [color, bodyText], () =>
|
|
878
|
+
section(
|
|
879
|
+
bodyLines.map((line) => theme.fg(color, line)),
|
|
880
|
+
{ label: "Output", preview: true, slot: "result" },
|
|
881
|
+
),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const truncation = asRecord(result?.details?.truncation);
|
|
887
|
+
const warnings: string[] = [];
|
|
888
|
+
if (truncation?.truncated === true) {
|
|
889
|
+
if (truncation.firstLineExceedsLimit === true) {
|
|
890
|
+
warnings.push(
|
|
891
|
+
`[First line exceeds ${formatSize(finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES)} limit]`,
|
|
892
|
+
);
|
|
893
|
+
} else if (truncation.truncatedBy === "lines") {
|
|
894
|
+
warnings.push(
|
|
895
|
+
`[Truncated: showing ${finiteNumber(truncation.outputLines) ?? 0} of ${
|
|
896
|
+
finiteNumber(truncation.totalLines) ?? 0
|
|
897
|
+
} lines (${finiteNumber(truncation.maxLines) ?? DEFAULT_MAX_LINES} line limit)]`,
|
|
898
|
+
);
|
|
899
|
+
} else {
|
|
900
|
+
warnings.push(
|
|
901
|
+
`[Truncated: ${finiteNumber(truncation.outputLines) ?? 0} lines shown (${formatSize(
|
|
902
|
+
finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES,
|
|
903
|
+
)} limit)]`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const view: ToolView = {
|
|
909
|
+
layout: "framed",
|
|
910
|
+
head,
|
|
911
|
+
sections: sectionsOf(
|
|
912
|
+
nativeInputSection(snapshot, theme, ["path"]),
|
|
913
|
+
contentSection,
|
|
914
|
+
warnings.length > 0 ? section(warnings.map((line) => theme.fg("warning", line)), { preview: false }) : undefined,
|
|
915
|
+
),
|
|
916
|
+
preview: { edge: "head", count: 10, unit: "visual-lines" },
|
|
917
|
+
expanded: snapshot.context.expanded,
|
|
918
|
+
};
|
|
919
|
+
if (isError) {
|
|
920
|
+
view.tone = "error";
|
|
921
|
+
}
|
|
922
|
+
return view;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/* -------------------------------------------------------------------------- */
|
|
926
|
+
/* Write */
|
|
927
|
+
/* -------------------------------------------------------------------------- */
|
|
928
|
+
|
|
929
|
+
function writeView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
930
|
+
const args = asRecord(suppliedArgs(snapshot));
|
|
931
|
+
const rawPath = argValue(args, "path");
|
|
932
|
+
const content = args?.content;
|
|
933
|
+
const isError = snapshot.context.isError;
|
|
934
|
+
const normalized = typeof content === "string" ? normalizeDisplayText(content) : undefined;
|
|
935
|
+
const literal = normalized !== undefined && normalized.trim().length === 0;
|
|
936
|
+
const text = typeof content === "string" && !literal ? normalized! : `content: ${stringInput(content)}`;
|
|
937
|
+
const lang = !literal && typeof content === "string" && typeof rawPath === "string" ? getLanguageFromPath(rawPath) : undefined;
|
|
938
|
+
const color = content !== undefined && typeof content !== "string" ? "error" : "toolOutput";
|
|
939
|
+
const contentSection = reuseToolSection(
|
|
940
|
+
snapshot.context.state, theme, "write-input", [text, lang, literal, color],
|
|
941
|
+
() => section(lang ? highlightCode(text, lang) : text.split("\n").map((line) => theme.fg(color, line)), {
|
|
942
|
+
slot: "call", previewPolicy: WRITE_PREVIEW,
|
|
943
|
+
}),
|
|
944
|
+
);
|
|
945
|
+
const meta = normalized === undefined ? [] : [`${trimTrailingEmptyLines(normalized.split("\n")).length} lines`];
|
|
946
|
+
const head = makeHead("Write", "", meta, snapshot);
|
|
947
|
+
const resultText = extractToolText(snapshot.result, snapshot.context.showImages);
|
|
948
|
+
const resultSection = resultText.length > 0
|
|
949
|
+
? section(resultText.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line)), {
|
|
950
|
+
label: isError ? "Error" : "Output", slot: "result", preview: !isError, previewPolicy: WRITE_PREVIEW,
|
|
951
|
+
}) : undefined;
|
|
952
|
+
const view: ToolView = {
|
|
953
|
+
layout: "framed",
|
|
954
|
+
head,
|
|
955
|
+
sections: sectionsOf(nativeInputSection(snapshot, theme, ["path"], ["content"], false), contentSection, resultSection),
|
|
956
|
+
preview: WRITE_PREVIEW,
|
|
957
|
+
expanded: snapshot.context.expanded,
|
|
958
|
+
};
|
|
959
|
+
if (isError) {
|
|
960
|
+
view.tone = "error";
|
|
961
|
+
}
|
|
962
|
+
return view;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* -------------------------------------------------------------------------- */
|
|
966
|
+
/* Edit */
|
|
967
|
+
/* -------------------------------------------------------------------------- */
|
|
968
|
+
|
|
969
|
+
/** Show the delivered request, not a reconstructed version of the result diff. */
|
|
970
|
+
function editRequestLines(args: Record<string, unknown> | undefined, theme: Theme): string[] {
|
|
971
|
+
const lines: string[] = [];
|
|
972
|
+
const hasEdits = args !== undefined && Object.hasOwn(args, "edits");
|
|
973
|
+
const edits = args?.edits;
|
|
974
|
+
const requests: unknown[] = [];
|
|
975
|
+
if (hasEdits) {
|
|
976
|
+
if (Array.isArray(edits) && edits.length > 0) {
|
|
977
|
+
requests.push(...edits);
|
|
978
|
+
} else {
|
|
979
|
+
lines.push(`edits: ${edits === undefined ? PENDING : `${INVALID_ARG} (${argumentText(edits, true)})`}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!hasEdits || Object.hasOwn(args!, "oldText") || Object.hasOwn(args!, "newText")) {
|
|
983
|
+
requests.push({ oldText: args?.oldText, newText: args?.newText });
|
|
984
|
+
}
|
|
985
|
+
for (const request of requests) {
|
|
986
|
+
const pair = asRecord(request);
|
|
987
|
+
if (!pair) {
|
|
988
|
+
lines.push(`${INVALID_ARG} (${argumentText(request, true)})`);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
if (typeof pair.oldText === "string" && typeof pair.newText === "string") {
|
|
992
|
+
for (const line of stringInput(pair.oldText).split("\n")) {
|
|
993
|
+
lines.push(theme.fg("toolDiffRemoved", `-${line}`));
|
|
994
|
+
}
|
|
995
|
+
for (const line of stringInput(pair.newText).split("\n")) {
|
|
996
|
+
lines.push(theme.fg("toolDiffAdded", `+${line}`));
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
lines.push(`oldText: ${stringInput(pair.oldText)}`, `newText: ${stringInput(pair.newText)}`);
|
|
1000
|
+
}
|
|
1001
|
+
for (const [key, entry] of Object.entries(pair)) {
|
|
1002
|
+
if (key !== "oldText" && key !== "newText") {
|
|
1003
|
+
lines.push(`${normalizeDisplayText(key)}: ${argumentText(entry)}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return lines;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Key the displayed request fields individually: JSON merges null/NaN and absent/undefined. */
|
|
1011
|
+
function editRequestParts(args: Record<string, unknown> | undefined): PrimaryPart[] {
|
|
1012
|
+
const hasEdits = args !== undefined && Object.hasOwn(args, "edits");
|
|
1013
|
+
const parts: PrimaryPart[] = [
|
|
1014
|
+
hasEdits,
|
|
1015
|
+
args !== undefined && Object.hasOwn(args, "oldText"),
|
|
1016
|
+
args !== undefined && Object.hasOwn(args, "newText"),
|
|
1017
|
+
typeof args?.oldText === "string", typeof args?.newText === "string",
|
|
1018
|
+
argumentText(args?.oldText), argumentText(args?.newText),
|
|
1019
|
+
];
|
|
1020
|
+
if (!hasEdits) {
|
|
1021
|
+
return parts;
|
|
1022
|
+
}
|
|
1023
|
+
const edits = args!.edits;
|
|
1024
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
1025
|
+
parts.push(argumentText(edits, true));
|
|
1026
|
+
return parts;
|
|
1027
|
+
}
|
|
1028
|
+
parts.push(edits.length);
|
|
1029
|
+
for (const request of edits) {
|
|
1030
|
+
const pair = asRecord(request);
|
|
1031
|
+
if (!pair) {
|
|
1032
|
+
parts.push(false, argumentText(request, true));
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const entries = Object.entries(pair);
|
|
1036
|
+
parts.push(true, typeof pair.oldText === "string", typeof pair.newText === "string",
|
|
1037
|
+
argumentText(pair.oldText), argumentText(pair.newText), entries.length);
|
|
1038
|
+
for (const [key, entry] of entries) {
|
|
1039
|
+
if (key !== "oldText" && key !== "newText") {
|
|
1040
|
+
parts.push(normalizeDisplayText(key), argumentText(entry));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return parts;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function editDiffStats(diff: string): { added: number; removed: number } {
|
|
1048
|
+
let added = 0;
|
|
1049
|
+
let removed = 0;
|
|
1050
|
+
for (const line of diff.split("\n")) {
|
|
1051
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
1052
|
+
added++;
|
|
1053
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
1054
|
+
removed++;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return { added, removed };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function editView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1061
|
+
const args = asRecord(suppliedArgs(snapshot));
|
|
1062
|
+
const rawPath = argValue(args, "path");
|
|
1063
|
+
const filePath = rawPath !== undefined && rawPath !== WRONG_TYPE ? rawPath : undefined;
|
|
1064
|
+
const isError = snapshot.context.isError;
|
|
1065
|
+
const details = asRecord(snapshot.result?.details);
|
|
1066
|
+
// A diff and its statistics belong to the final revision; active revisions keep
|
|
1067
|
+
// showing the request preview instead of half-streamed diff data.
|
|
1068
|
+
const finalized = snapshot.result !== undefined && !snapshot.context.isPartial;
|
|
1069
|
+
const resultDiff =
|
|
1070
|
+
!isError && finalized && typeof details?.diff === "string" ? normalizeDisplayText(details.diff) : undefined;
|
|
1071
|
+
|
|
1072
|
+
const meta: string[] = [];
|
|
1073
|
+
if (resultDiff && resultDiff.length > 0) {
|
|
1074
|
+
const stats = editDiffStats(resultDiff);
|
|
1075
|
+
meta.push(`+${stats.added} −${stats.removed}`);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const head = makeHead("Edit", "", meta, snapshot);
|
|
1079
|
+
|
|
1080
|
+
const resultText = extractToolText(snapshot.result, snapshot.context.showImages);
|
|
1081
|
+
|
|
1082
|
+
let primary: ToolSection | undefined;
|
|
1083
|
+
const state = snapshot.context.state;
|
|
1084
|
+
const buildRequest = () => section(editRequestLines(args, theme), { slot: "call", previewPolicy: INPUT_PREVIEW });
|
|
1085
|
+
// The active request and final result share the one retained-primary slot.
|
|
1086
|
+
// Final Input is ordinary; whole-view retention still keeps it stable on ticks/resize.
|
|
1087
|
+
const request = finalized ? buildRequest() : reuseToolSection(state, theme, "edit-request", editRequestParts(args), buildRequest);
|
|
1088
|
+
if (isError) {
|
|
1089
|
+
if (resultText.length > 0) {
|
|
1090
|
+
const buildError = () => section(resultText.split("\n").map((line) => theme.fg("error", line)), {
|
|
1091
|
+
label: "Error", slot: "result", preview: false,
|
|
1092
|
+
});
|
|
1093
|
+
primary = finalized ? reuseToolSection(state, theme, "edit-error", [resultText], buildError) : buildError();
|
|
1094
|
+
}
|
|
1095
|
+
} else if (resultDiff !== undefined && resultDiff.length > 0) {
|
|
1096
|
+
const diff = resultDiff;
|
|
1097
|
+
primary = reuseToolSection(state, theme, "edit-diff", [diff, filePath], () =>
|
|
1098
|
+
section(renderDiff(diff, filePath === undefined ? {} : { filePath }).split("\n"), {
|
|
1099
|
+
label: "Diff",
|
|
1100
|
+
slot: "result",
|
|
1101
|
+
preview: true,
|
|
1102
|
+
previewPolicy: INPUT_PREVIEW,
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
} else if (finalized && resultText.length > 0) {
|
|
1106
|
+
primary = reuseToolSection(state, theme, "edit-text", [resultText], () =>
|
|
1107
|
+
section(resultText.split("\n").map((line) => theme.fg("toolOutput", line)), {
|
|
1108
|
+
label: "Diff",
|
|
1109
|
+
slot: "result",
|
|
1110
|
+
preview: true,
|
|
1111
|
+
previewPolicy: INPUT_PREVIEW,
|
|
1112
|
+
}),
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const view: ToolView = {
|
|
1117
|
+
layout: "framed",
|
|
1118
|
+
head,
|
|
1119
|
+
sections: sectionsOf(nativeInputSection(snapshot, theme, ["path"], ["oldText", "newText", "edits"], false), request, primary),
|
|
1120
|
+
preview: { edge: "head", count: 10, unit: "visual-lines" },
|
|
1121
|
+
expanded: snapshot.context.expanded,
|
|
1122
|
+
};
|
|
1123
|
+
if (isError) {
|
|
1124
|
+
view.tone = "error";
|
|
1125
|
+
}
|
|
1126
|
+
return view;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/* -------------------------------------------------------------------------- */
|
|
1130
|
+
/* Find */
|
|
1131
|
+
/* -------------------------------------------------------------------------- */
|
|
1132
|
+
|
|
1133
|
+
function findView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1134
|
+
const head = makeHead("Find", "", [], snapshot);
|
|
1135
|
+
|
|
1136
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
1137
|
+
const entryLines = output.length > 0 ? trimTrailingEmptyLines(output.split("\n")) : [];
|
|
1138
|
+
|
|
1139
|
+
const details = asRecord(snapshot.result?.details);
|
|
1140
|
+
const truncation = asRecord(details?.truncation);
|
|
1141
|
+
const resultLimit = finiteNumber(details?.resultLimitReached);
|
|
1142
|
+
const warnings: string[] = [];
|
|
1143
|
+
if (resultLimit !== undefined) {
|
|
1144
|
+
warnings.push(`${resultLimit} results limit`);
|
|
1145
|
+
}
|
|
1146
|
+
if (truncation?.truncated === true) {
|
|
1147
|
+
warnings.push(`${formatSize(finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES)} limit`);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Entry previews stay unmarked: the layout selects whole logical entries and wraps
|
|
1151
|
+
// only the selected rows itself, so the padded marker must not apply here.
|
|
1152
|
+
const entrySection: ToolSection | undefined =
|
|
1153
|
+
entryLines.length > 0
|
|
1154
|
+
? reuseToolSection(snapshot.context.state, theme, "find-entries", [output], () => ({
|
|
1155
|
+
component: entriesComponent(entryLines.map((line) => theme.fg("toolOutput", line))),
|
|
1156
|
+
label: "Output",
|
|
1157
|
+
slot: "result",
|
|
1158
|
+
preview: true,
|
|
1159
|
+
}))
|
|
1160
|
+
: undefined;
|
|
1161
|
+
|
|
1162
|
+
const view: ToolView = {
|
|
1163
|
+
layout: "framed",
|
|
1164
|
+
head,
|
|
1165
|
+
sections: sectionsOf(
|
|
1166
|
+
nativeInputSection(snapshot, theme, ["pattern"]),
|
|
1167
|
+
entrySection,
|
|
1168
|
+
warnings.length > 0 ? section([theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)], { preview: false }) : undefined,
|
|
1169
|
+
),
|
|
1170
|
+
preview: { edge: "head", count: 5, unit: "entries" },
|
|
1171
|
+
expanded: snapshot.context.expanded,
|
|
1172
|
+
};
|
|
1173
|
+
if (snapshot.context.isError) {
|
|
1174
|
+
view.tone = "error";
|
|
1175
|
+
}
|
|
1176
|
+
return view;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/* -------------------------------------------------------------------------- */
|
|
1180
|
+
/* Grep */
|
|
1181
|
+
/* -------------------------------------------------------------------------- */
|
|
1182
|
+
|
|
1183
|
+
function grepView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1184
|
+
const head = makeHead("Grep", "", [], snapshot);
|
|
1185
|
+
|
|
1186
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
1187
|
+
const outputLines = output.length > 0 ? output.split("\n") : [];
|
|
1188
|
+
|
|
1189
|
+
const details = asRecord(snapshot.result?.details);
|
|
1190
|
+
const truncation = asRecord(details?.truncation);
|
|
1191
|
+
const matchLimit = finiteNumber(details?.matchLimitReached);
|
|
1192
|
+
const warnings: string[] = [];
|
|
1193
|
+
if (matchLimit !== undefined) {
|
|
1194
|
+
warnings.push(`${matchLimit} matches limit`);
|
|
1195
|
+
}
|
|
1196
|
+
if (truncation?.truncated === true) {
|
|
1197
|
+
warnings.push(`${formatSize(finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES)} limit`);
|
|
1198
|
+
}
|
|
1199
|
+
if (details?.linesTruncated === true) {
|
|
1200
|
+
warnings.push("some lines truncated");
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const view: ToolView = {
|
|
1204
|
+
layout: "framed",
|
|
1205
|
+
head,
|
|
1206
|
+
sections: sectionsOf(
|
|
1207
|
+
nativeInputSection(snapshot, theme, ["pattern"]),
|
|
1208
|
+
reuseToolSection(snapshot.context.state, theme, "grep-output", [output], () =>
|
|
1209
|
+
section(outputLines.map((line) => theme.fg("toolOutput", line)), { label: "Output", preview: true, slot: "result" }),
|
|
1210
|
+
),
|
|
1211
|
+
warnings.length > 0 ? section([theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)], { preview: false }) : undefined,
|
|
1212
|
+
),
|
|
1213
|
+
preview: { edge: "head", count: 10, unit: "visual-lines" },
|
|
1214
|
+
expanded: snapshot.context.expanded,
|
|
1215
|
+
};
|
|
1216
|
+
if (snapshot.context.isError) {
|
|
1217
|
+
view.tone = "error";
|
|
1218
|
+
}
|
|
1219
|
+
return view;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/* -------------------------------------------------------------------------- */
|
|
1223
|
+
/* Ls */
|
|
1224
|
+
/* -------------------------------------------------------------------------- */
|
|
1225
|
+
|
|
1226
|
+
function lsView(snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1227
|
+
const head = makeHead("Ls", "", [], snapshot);
|
|
1228
|
+
|
|
1229
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
1230
|
+
const outputLines = output.length > 0 ? output.split("\n") : [];
|
|
1231
|
+
|
|
1232
|
+
const details = asRecord(snapshot.result?.details);
|
|
1233
|
+
const truncation = asRecord(details?.truncation);
|
|
1234
|
+
const entryLimit = finiteNumber(details?.entryLimitReached);
|
|
1235
|
+
const warnings: string[] = [];
|
|
1236
|
+
if (entryLimit !== undefined) {
|
|
1237
|
+
warnings.push(`${entryLimit} entries limit`);
|
|
1238
|
+
}
|
|
1239
|
+
if (truncation?.truncated === true) {
|
|
1240
|
+
warnings.push(`${formatSize(finiteNumber(truncation.maxBytes) ?? DEFAULT_MAX_BYTES)} limit`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const view: ToolView = {
|
|
1244
|
+
layout: "framed",
|
|
1245
|
+
head,
|
|
1246
|
+
sections: sectionsOf(
|
|
1247
|
+
nativeInputSection(snapshot, theme, []),
|
|
1248
|
+
reuseToolSection(snapshot.context.state, theme, "ls-output", [output], () =>
|
|
1249
|
+
section(outputLines.map((line) => theme.fg("toolOutput", line)), { label: "Output", preview: true, slot: "result" }),
|
|
1250
|
+
),
|
|
1251
|
+
warnings.length > 0 ? section([theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)], { preview: false }) : undefined,
|
|
1252
|
+
),
|
|
1253
|
+
preview: { edge: "head", count: 10, unit: "visual-lines" },
|
|
1254
|
+
expanded: snapshot.context.expanded,
|
|
1255
|
+
};
|
|
1256
|
+
if (snapshot.context.isError) {
|
|
1257
|
+
view.tone = "error";
|
|
1258
|
+
}
|
|
1259
|
+
return view;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/* -------------------------------------------------------------------------- */
|
|
1263
|
+
/* MCP */
|
|
1264
|
+
/* -------------------------------------------------------------------------- */
|
|
1265
|
+
|
|
1266
|
+
interface McpIdentity {
|
|
1267
|
+
target: string;
|
|
1268
|
+
namespace?: string;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function mcpIdentity(toolName: string, snapshot: ToolSnapshot): McpIdentity {
|
|
1272
|
+
const details = asRecord(snapshot.result?.details);
|
|
1273
|
+
const server = typeof details?.server === "string" ? details.server : undefined;
|
|
1274
|
+
const tool = typeof details?.tool === "string" ? details.tool : undefined;
|
|
1275
|
+
if (server && tool) {
|
|
1276
|
+
return { target: `${server}/${tool}` };
|
|
1277
|
+
}
|
|
1278
|
+
const namespace = snapshot.toolInfo?.namespace?.name;
|
|
1279
|
+
if (namespace && namespace.length > 0) {
|
|
1280
|
+
const suffixPrefix = `${namespace}__`;
|
|
1281
|
+
if (toolName.startsWith(suffixPrefix)) {
|
|
1282
|
+
return { target: `${namespace}/${toolName.slice(suffixPrefix.length)}`, namespace };
|
|
1283
|
+
}
|
|
1284
|
+
return { target: toolName, namespace };
|
|
1285
|
+
}
|
|
1286
|
+
return { target: toolName };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function mcpMeta(identity: McpIdentity, snapshot: ToolSnapshot): string[] {
|
|
1290
|
+
const meta: string[] = [];
|
|
1291
|
+
if (identity.namespace && !identity.target.startsWith(identity.namespace)) {
|
|
1292
|
+
meta.push(singleLineText(identity.namespace));
|
|
1293
|
+
}
|
|
1294
|
+
const exposure = snapshot.toolInfo?.exposure;
|
|
1295
|
+
if (exposure !== undefined && exposure !== "direct") {
|
|
1296
|
+
meta.push(exposure);
|
|
1297
|
+
}
|
|
1298
|
+
const annotations = snapshot.toolInfo?.annotations;
|
|
1299
|
+
if (annotations) {
|
|
1300
|
+
const hints: string[] = [];
|
|
1301
|
+
if (annotations.readOnlyHint === true) {
|
|
1302
|
+
hints.push("read-only");
|
|
1303
|
+
}
|
|
1304
|
+
if (annotations.destructiveHint === true) {
|
|
1305
|
+
hints.push("destructive");
|
|
1306
|
+
}
|
|
1307
|
+
if (annotations.idempotentHint === true) {
|
|
1308
|
+
hints.push("idempotent");
|
|
1309
|
+
}
|
|
1310
|
+
if (annotations.openWorldHint === true) {
|
|
1311
|
+
hints.push("open-world");
|
|
1312
|
+
}
|
|
1313
|
+
if (hints.length > 0) {
|
|
1314
|
+
meta.push(`hints: ${hints.join(", ")}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return meta;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** Stringify untrusted argument values without throwing on bigint or cyclic input. */
|
|
1321
|
+
function jsonText(value: unknown, pretty: boolean): string {
|
|
1322
|
+
if (pretty && typeof value === "string") {
|
|
1323
|
+
return value;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
1327
|
+
return text ?? String(value);
|
|
1328
|
+
} catch {
|
|
1329
|
+
// Preserve delivered fields on the exceptional bigint/cycle path as well.
|
|
1330
|
+
const ancestors: object[] = [];
|
|
1331
|
+
try {
|
|
1332
|
+
const text = JSON.stringify(value, function (_key, entry: unknown): unknown {
|
|
1333
|
+
if (typeof entry === "bigint") {
|
|
1334
|
+
return String(entry);
|
|
1335
|
+
}
|
|
1336
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1337
|
+
return entry;
|
|
1338
|
+
}
|
|
1339
|
+
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
|
|
1340
|
+
ancestors.pop();
|
|
1341
|
+
}
|
|
1342
|
+
if (ancestors.includes(entry)) {
|
|
1343
|
+
return "[Circular]";
|
|
1344
|
+
}
|
|
1345
|
+
ancestors.push(entry);
|
|
1346
|
+
return entry;
|
|
1347
|
+
}, pretty ? 2 : undefined);
|
|
1348
|
+
return text ?? String(value);
|
|
1349
|
+
} catch {
|
|
1350
|
+
return String(value);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function argEntries(args: unknown): Array<[string, unknown]> {
|
|
1356
|
+
if (args === undefined || args === null) {
|
|
1357
|
+
return [];
|
|
1358
|
+
}
|
|
1359
|
+
if (typeof args === "object" && !Array.isArray(args)) {
|
|
1360
|
+
return Object.entries(args as Record<string, unknown>);
|
|
1361
|
+
}
|
|
1362
|
+
return [["args", args]];
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
function mcpView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1367
|
+
const identity = mcpIdentity(toolName, snapshot);
|
|
1368
|
+
const head = makeHead("MCP", normalizeDisplayText(identity.target), mcpMeta(identity, snapshot), snapshot);
|
|
1369
|
+
|
|
1370
|
+
const isError = snapshot.context.isError;
|
|
1371
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
1372
|
+
const details = asRecord(snapshot.result?.details);
|
|
1373
|
+
const fullOutputPath = typeof details?.fullOutputPath === "string" ? details.fullOutputPath : undefined;
|
|
1374
|
+
|
|
1375
|
+
const view: ToolView = {
|
|
1376
|
+
layout: "framed",
|
|
1377
|
+
head,
|
|
1378
|
+
sections: sectionsOf(
|
|
1379
|
+
fullArgumentsSection(suppliedArgs(snapshot)),
|
|
1380
|
+
reuseToolSection(snapshot.context.state, theme, "mcp-output", [isError ? "error" : "toolOutput", output], () => {
|
|
1381
|
+
const outputLines =
|
|
1382
|
+
output.length > 0 ? output.split("\n").map((line) => theme.fg(isError ? "error" : "toolOutput", line)) : [];
|
|
1383
|
+
return outputLines.length > 0
|
|
1384
|
+
? section(outputLines, { label: "Output", slot: "result", preview: true })
|
|
1385
|
+
: undefined;
|
|
1386
|
+
}),
|
|
1387
|
+
fullOutputPath !== undefined && fullOutputPath.length > 0
|
|
1388
|
+
? section([theme.fg("muted", `Full output: ${singleLineText(fullOutputPath)}`)], { slot: "result", preview: false })
|
|
1389
|
+
: undefined,
|
|
1390
|
+
),
|
|
1391
|
+
preview: { edge: "head", count: 5, unit: "visual-lines" },
|
|
1392
|
+
expanded: snapshot.context.expanded,
|
|
1393
|
+
};
|
|
1394
|
+
if (isError) {
|
|
1395
|
+
view.tone = "error";
|
|
1396
|
+
}
|
|
1397
|
+
return view;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/* -------------------------------------------------------------------------- */
|
|
1401
|
+
/* MCP resources */
|
|
1402
|
+
/* -------------------------------------------------------------------------- */
|
|
1403
|
+
|
|
1404
|
+
function mcpResourceView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1405
|
+
const title = MCP_RESOURCE_TITLES[toolName] ?? "MCP Resource";
|
|
1406
|
+
const head = makeHead(title, "", [], snapshot);
|
|
1407
|
+
|
|
1408
|
+
const output = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
1409
|
+
const view: ToolView = {
|
|
1410
|
+
layout: "framed",
|
|
1411
|
+
head,
|
|
1412
|
+
sections: sectionsOf(
|
|
1413
|
+
nativeInputSection(snapshot, theme, toolName === "read_mcp_resource" ? ["server", "uri"] : []),
|
|
1414
|
+
reuseToolSection(
|
|
1415
|
+
snapshot.context.state,
|
|
1416
|
+
theme,
|
|
1417
|
+
"mcp-resource-output",
|
|
1418
|
+
[snapshot.context.isError ? "error" : "toolOutput", output],
|
|
1419
|
+
() => {
|
|
1420
|
+
const outputLines =
|
|
1421
|
+
output.length > 0
|
|
1422
|
+
? output.split("\n").map((line) => theme.fg(snapshot.context.isError ? "error" : "toolOutput", line))
|
|
1423
|
+
: [];
|
|
1424
|
+
return outputLines.length > 0
|
|
1425
|
+
? section(outputLines, { label: "Output", slot: "result", preview: true })
|
|
1426
|
+
: undefined;
|
|
1427
|
+
},
|
|
1428
|
+
),
|
|
1429
|
+
),
|
|
1430
|
+
preview: { edge: "head", count: 5, unit: "visual-lines" },
|
|
1431
|
+
expanded: snapshot.context.expanded,
|
|
1432
|
+
};
|
|
1433
|
+
if (snapshot.context.isError) {
|
|
1434
|
+
view.tone = "error";
|
|
1435
|
+
}
|
|
1436
|
+
return view;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function sourceIsMcp(snapshot: ToolSnapshot): boolean {
|
|
1440
|
+
const path = snapshot.toolInfo?.sourceInfo?.path;
|
|
1441
|
+
return (
|
|
1442
|
+
path === "builtin:mcp" ||
|
|
1443
|
+
path === undefined ||
|
|
1444
|
+
snapshot.presentation === "mcp" ||
|
|
1445
|
+
snapshot.presentation === "mcp-unresolved"
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/* -------------------------------------------------------------------------- */
|
|
1450
|
+
/* Dispatch */
|
|
1451
|
+
/* -------------------------------------------------------------------------- */
|
|
1452
|
+
|
|
1453
|
+
const BUILTIN_PRODUCERS: Record<string, (snapshot: ToolSnapshot, theme: Theme) => ToolView> = {
|
|
1454
|
+
bash: (snapshot, theme) => shellView("bash", snapshot, theme),
|
|
1455
|
+
powershell: (snapshot, theme) => shellView("powershell", snapshot, theme),
|
|
1456
|
+
read: readView,
|
|
1457
|
+
write: writeView,
|
|
1458
|
+
edit: editView,
|
|
1459
|
+
find: findView,
|
|
1460
|
+
grep: grepView,
|
|
1461
|
+
ls: lsView,
|
|
1462
|
+
};
|
|
1463
|
+
|
|
1464
|
+
/** Build the semantic view for one render revision. */
|
|
1465
|
+
export function createToolView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
1466
|
+
const isMcpResource = MCP_RESOURCE_TITLES[toolName] !== undefined && sourceIsMcp(snapshot);
|
|
1467
|
+
switch (snapshot.presentation) {
|
|
1468
|
+
case "execution":
|
|
1469
|
+
return executionView(toolName, snapshot, theme);
|
|
1470
|
+
case "generic":
|
|
1471
|
+
case "web":
|
|
1472
|
+
return genericView(toolName, snapshot, theme);
|
|
1473
|
+
case "mcp":
|
|
1474
|
+
case "mcp-unresolved":
|
|
1475
|
+
return isMcpResource ? mcpResourceView(toolName, snapshot, theme) : mcpView(toolName, snapshot, theme);
|
|
1476
|
+
default:
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
if (isMcpResource) {
|
|
1480
|
+
return mcpResourceView(toolName, snapshot, theme);
|
|
1481
|
+
}
|
|
1482
|
+
const producer = BUILTIN_PRODUCERS[toolName];
|
|
1483
|
+
return producer ? producer(snapshot, theme) : genericView(toolName, snapshot, theme);
|
|
1484
|
+
}
|