@giladbarnea/pi-user-agents 0.0.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/INTERNALS.md +65 -0
- package/PARSER_SPEC.md +264 -0
- package/README.md +127 -0
- package/agent-viewer.ts +653 -0
- package/autocomplete.ts +576 -0
- package/command-line.ts +746 -0
- package/context-meter.ts +41 -0
- package/editor-coloring.ts +160 -0
- package/index.ts +74 -0
- package/package.json +44 -0
- package/runner.ts +591 -0
- package/shared.ts +214 -0
- package/tool-render/FORK.md +108 -0
- package/tool-render/ansi.ts +77 -0
- package/tool-render/constants.ts +48 -0
- package/tool-render/diff.ts +957 -0
- package/tool-render/index.ts +137 -0
- package/tool-render/text.ts +212 -0
- package/tool-render/theme.ts +71 -0
- package/tool-render/tools.ts +502 -0
- package/transcript.ts +303 -0
- package/widget.ts +523 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { padVisible, truncateAnsi, visibleWidth, wrapTextWithAnsi } from "./ansi.js";
|
|
4
|
+
import { CAPS, GLYPHS } from "./constants.js";
|
|
5
|
+
import {
|
|
6
|
+
buildStructuredDiff,
|
|
7
|
+
diffSummary,
|
|
8
|
+
editOperationsFromArgs,
|
|
9
|
+
parseUnifiedDiffOutput,
|
|
10
|
+
renderStructuredDiff,
|
|
11
|
+
type StructuredDiff,
|
|
12
|
+
} from "./diff.js";
|
|
13
|
+
import {
|
|
14
|
+
borderMuted,
|
|
15
|
+
type RenderTheme,
|
|
16
|
+
stackPrefix,
|
|
17
|
+
toolLabel,
|
|
18
|
+
treeConnector,
|
|
19
|
+
} from "./theme.js";
|
|
20
|
+
import {
|
|
21
|
+
clipLine,
|
|
22
|
+
formatArguments,
|
|
23
|
+
lineCount,
|
|
24
|
+
plural,
|
|
25
|
+
preview,
|
|
26
|
+
readCallText,
|
|
27
|
+
readOnlyCallText,
|
|
28
|
+
readResultSummary,
|
|
29
|
+
renderPathListPreview,
|
|
30
|
+
renderToolPathText,
|
|
31
|
+
resultTruncated,
|
|
32
|
+
splitTerminalLines,
|
|
33
|
+
textContent,
|
|
34
|
+
type ToolResultLike,
|
|
35
|
+
truncateText,
|
|
36
|
+
} from "./text.js";
|
|
37
|
+
|
|
38
|
+
export const FIRST_CLASS_TOOLS = ["read", "edit", "write", "grep", "find", "ls", "bash"] as const;
|
|
39
|
+
export type FirstClassTool = (typeof FIRST_CLASS_TOOLS)[number];
|
|
40
|
+
|
|
41
|
+
export function isFirstClassTool(name: string): name is FirstClassTool {
|
|
42
|
+
return (FIRST_CLASS_TOOLS as readonly string[]).includes(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ToolArgs = Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
/** Every renderer receives the same inputs. `result` is absent for a call that never finished. */
|
|
48
|
+
type ToolRenderer = (
|
|
49
|
+
args: ToolArgs,
|
|
50
|
+
result: ToolResultLike | undefined,
|
|
51
|
+
theme: RenderTheme,
|
|
52
|
+
width: number,
|
|
53
|
+
) => string[];
|
|
54
|
+
|
|
55
|
+
// --- shared building blocks --------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function separator(theme: RenderTheme): string {
|
|
58
|
+
return theme.fg("dim", " · ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function headline(call: string, summary: string, theme: RenderTheme): string {
|
|
62
|
+
return `${stackPrefix(theme)}${call}${separator(theme)}${summary}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorText(result: ToolResultLike, theme: RenderTheme, fallback: string): string {
|
|
66
|
+
return theme.fg("error", splitTerminalLines(textContent(result))[0] || fallback);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wrapBlock(text: string, width: number): string[] {
|
|
70
|
+
return splitTerminalLines(text).flatMap((line) => {
|
|
71
|
+
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
|
|
72
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Indented preview rows under a headline, matching the source extension's tree stem. */
|
|
77
|
+
function previewRows(
|
|
78
|
+
content: string,
|
|
79
|
+
limit: number,
|
|
80
|
+
noun: string,
|
|
81
|
+
theme: RenderTheme,
|
|
82
|
+
width: number,
|
|
83
|
+
): string[] {
|
|
84
|
+
const total = lineCount(content);
|
|
85
|
+
if (total === 0) return [];
|
|
86
|
+
const stem = treeConnector(theme, "│");
|
|
87
|
+
const stemWidth = visibleWidth(stem);
|
|
88
|
+
const rows = splitTerminalLines(preview(content, limit)).map((line) =>
|
|
89
|
+
`${stem}${theme.fg("dim", truncateAnsi(line, Math.max(1, width - stemWidth)))}`,
|
|
90
|
+
);
|
|
91
|
+
if (total > limit)
|
|
92
|
+
rows.push(`${stem}${theme.fg("muted", `${GLYPHS.ellipsis} ${total - limit} more ${noun}`)}`);
|
|
93
|
+
return rows;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A call whose result never arrived: the agent was aborted or is still in flight. */
|
|
97
|
+
function unfinished(call: string, theme: RenderTheme): string {
|
|
98
|
+
return headline(call, theme.fg("muted", "no result"), theme);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- read --------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const renderRead: ToolRenderer = (args, result, theme, width) => {
|
|
104
|
+
const call = readCallText(args, theme);
|
|
105
|
+
if (!result) return wrapBlock(unfinished(call, theme), width);
|
|
106
|
+
if (result.isError) return wrapBlock(headline(call, errorText(result, theme, "read failed"), theme), width);
|
|
107
|
+
const content = textContent(result);
|
|
108
|
+
return [
|
|
109
|
+
...wrapBlock(headline(call, readResultSummary(result, args, theme), theme), width),
|
|
110
|
+
...previewRows(content, CAPS.readPreviewLines, "line(s)", theme, width),
|
|
111
|
+
];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// --- edit and write ----------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function argumentPath(args: ToolArgs): string {
|
|
117
|
+
const value = args.path ?? args.file_path ?? "";
|
|
118
|
+
return typeof value === "string" ? value : String(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Prefer the patch Pi actually applied over the arguments the model requested.
|
|
123
|
+
*
|
|
124
|
+
* Arguments only describe the intended edit. A partially applied edit makes them diverge from
|
|
125
|
+
* what is on disk, and they carry no real line numbers.
|
|
126
|
+
*/
|
|
127
|
+
function editDiff(args: ToolArgs, result: ToolResultLike): StructuredDiff | undefined {
|
|
128
|
+
const patch = (result.details as { patch?: unknown } | undefined)?.patch;
|
|
129
|
+
if (typeof patch === "string" && patch.trim()) {
|
|
130
|
+
const files = parseUnifiedDiffOutput(patch);
|
|
131
|
+
if (files?.[0]) return files[0].diff;
|
|
132
|
+
}
|
|
133
|
+
const operations = editOperationsFromArgs(args);
|
|
134
|
+
if (operations.length === 0) return undefined;
|
|
135
|
+
const merged = operations.reduce<StructuredDiff | undefined>((accumulated, operation) => {
|
|
136
|
+
const next = buildStructuredDiff(operation.oldText, operation.newText);
|
|
137
|
+
if (!accumulated) return next;
|
|
138
|
+
return {
|
|
139
|
+
additions: accumulated.additions + next.additions,
|
|
140
|
+
hunks: (accumulated.hunks ?? 0) + (next.hunks ?? 0),
|
|
141
|
+
lines: [...accumulated.lines, ...next.lines],
|
|
142
|
+
removals: accumulated.removals + next.removals,
|
|
143
|
+
};
|
|
144
|
+
}, undefined);
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderMutation(
|
|
149
|
+
call: string,
|
|
150
|
+
diff: StructuredDiff | undefined,
|
|
151
|
+
fallbackSummary: string,
|
|
152
|
+
theme: RenderTheme,
|
|
153
|
+
width: number,
|
|
154
|
+
path: string,
|
|
155
|
+
): string[] {
|
|
156
|
+
const summary = diff ? diffSummary(diff, theme) : theme.fg("success", fallbackSummary);
|
|
157
|
+
const lines = wrapBlock(headline(call, summary, theme), width);
|
|
158
|
+
if (diff) lines.push(...renderStructuredDiff(diff, theme, width, path));
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const renderEdit: ToolRenderer = (args, result, theme, width) => {
|
|
163
|
+
const path = argumentPath(args);
|
|
164
|
+
const call = `${toolLabel(theme, "Edit ")}${renderToolPathText(path, theme)}`;
|
|
165
|
+
if (!result) return wrapBlock(unfinished(call, theme), width);
|
|
166
|
+
if (result.isError)
|
|
167
|
+
return wrapBlock(headline(call, errorText(result, theme, "edit failed"), theme), width);
|
|
168
|
+
return renderMutation(call, editDiff(args, result), "applied", theme, width, path);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const renderWrite: ToolRenderer = (args, result, theme, width) => {
|
|
172
|
+
const path = argumentPath(args);
|
|
173
|
+
const content = typeof args.content === "string" ? args.content : "";
|
|
174
|
+
const call = `${toolLabel(theme, "Write ")}${renderToolPathText(path, theme)} ${theme.fg(
|
|
175
|
+
"dim",
|
|
176
|
+
`· ${plural(lineCount(content), "line")}`,
|
|
177
|
+
)}`;
|
|
178
|
+
if (!result) return wrapBlock(unfinished(call, theme), width);
|
|
179
|
+
if (result.isError)
|
|
180
|
+
return wrapBlock(headline(call, errorText(result, theme, "write failed"), theme), width);
|
|
181
|
+
const diff = content ? buildStructuredDiff("", content) : undefined;
|
|
182
|
+
return renderMutation(call, diff, "written", theme, width, path);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// --- grep, find, and ls ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function readOnlyRenderer(toolName: "grep" | "find" | "ls"): ToolRenderer {
|
|
188
|
+
return (args, result, theme, width) => {
|
|
189
|
+
const call = readOnlyCallText(toolName, args, theme);
|
|
190
|
+
if (!result) return wrapBlock(unfinished(call, theme), width);
|
|
191
|
+
if (result.isError)
|
|
192
|
+
return wrapBlock(headline(call, errorText(result, theme, `${toolName} failed`), theme), width);
|
|
193
|
+
const output = textContent(result);
|
|
194
|
+
const count = output.trim() ? lineCount(output) : 0;
|
|
195
|
+
const emptyLabel =
|
|
196
|
+
toolName === "grep" ? "no matches" : toolName === "ls" ? "empty" : "no files";
|
|
197
|
+
const countLabel =
|
|
198
|
+
toolName === "grep"
|
|
199
|
+
? `${count} match${count === 1 ? "" : "es"}`
|
|
200
|
+
: toolName === "ls"
|
|
201
|
+
? `${count} entr${count === 1 ? "y" : "ies"}`
|
|
202
|
+
: plural(count, "file");
|
|
203
|
+
let summary =
|
|
204
|
+
count === 0 ? theme.fg("muted", emptyLabel) : theme.fg("success", countLabel);
|
|
205
|
+
if (resultTruncated(result)) summary += theme.fg("warning", " · truncated");
|
|
206
|
+
const lines = wrapBlock(headline(call, summary, theme), width);
|
|
207
|
+
if (!output.trim()) return lines;
|
|
208
|
+
if (toolName === "grep")
|
|
209
|
+
lines.push(...previewRows(output, CAPS.searchPreviewLines, "result line(s)", theme, width));
|
|
210
|
+
else
|
|
211
|
+
lines.push(
|
|
212
|
+
...splitTerminalLines(renderPathListPreview(output, toolName, theme)).map((line) =>
|
|
213
|
+
truncateAnsi(line, width),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
return lines;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- bash --------------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
const FRAME = { bl: "└", br: "┘", h: "─", tl: "┌", tr: "┐", v: "│" } as const;
|
|
223
|
+
const ANSI_RESET = "\x1b[0m";
|
|
224
|
+
|
|
225
|
+
function frameTop(theme: RenderTheme, width: number, caption?: string): string {
|
|
226
|
+
const border = (text: string) => borderMuted(theme, text);
|
|
227
|
+
if (!caption)
|
|
228
|
+
return `${border(FRAME.tl)}${border(FRAME.h.repeat(Math.max(1, width - 2)))}${border(FRAME.tr)}`;
|
|
229
|
+
const maxCaption = Math.max(1, width - 6);
|
|
230
|
+
const safeCaption =
|
|
231
|
+
visibleWidth(caption) > maxCaption ? truncateAnsi(caption, maxCaption) : caption;
|
|
232
|
+
const fill = Math.max(1, width - 5 - visibleWidth(safeCaption));
|
|
233
|
+
return `${border(FRAME.tl)}${border(FRAME.h)} ${safeCaption} ${border(FRAME.h.repeat(fill))}${border(FRAME.tr)}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function frameBottom(theme: RenderTheme, width: number): string {
|
|
237
|
+
const border = (text: string) => borderMuted(theme, text);
|
|
238
|
+
return `${border(FRAME.bl)}${border(FRAME.h.repeat(Math.max(1, width - 2)))}${border(FRAME.br)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function frameRow(theme: RenderTheme, inner: string, innerWidth: number): string {
|
|
242
|
+
const border = (text: string) => borderMuted(theme, text);
|
|
243
|
+
return `${border(FRAME.v)} ${padVisible(truncateAnsi(inner, innerWidth), innerWidth)}${ANSI_RESET} ${border(FRAME.v)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function countPipelines(command: string): number {
|
|
247
|
+
let count = 0;
|
|
248
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
249
|
+
if (command[index] !== "|") continue;
|
|
250
|
+
if (command[index + 1] === "|") {
|
|
251
|
+
index += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
count += 1;
|
|
255
|
+
}
|
|
256
|
+
return count;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function commandFacts(command: string, timeout: number | undefined): string[] {
|
|
260
|
+
const facts: string[] = [];
|
|
261
|
+
const logicalLines = splitTerminalLines(command);
|
|
262
|
+
if (logicalLines.length > 1) facts.push(plural(logicalLines.length, "line"));
|
|
263
|
+
const pipelines = countPipelines(command);
|
|
264
|
+
if (pipelines > 0) facts.push(plural(pipelines, "pipe"));
|
|
265
|
+
if (/(^|[^<])<<-?/.test(command)) facts.push("heredoc");
|
|
266
|
+
if (timeout !== undefined) facts.push(`timeout ${timeout}s`);
|
|
267
|
+
return facts;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type BashStatus =
|
|
271
|
+
| { kind: "ok" }
|
|
272
|
+
| { kind: "exit"; code: number }
|
|
273
|
+
| { kind: "aborted" }
|
|
274
|
+
| { kind: "timeout"; seconds: number }
|
|
275
|
+
| { kind: "failed" };
|
|
276
|
+
|
|
277
|
+
type ParsedBashResult = {
|
|
278
|
+
body: string;
|
|
279
|
+
shown: number;
|
|
280
|
+
status: BashStatus;
|
|
281
|
+
total: number;
|
|
282
|
+
truncated: boolean;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const EXIT_PATTERN = /\n*Command exited with code (\d+)\s*$/;
|
|
286
|
+
const ABORT_PATTERN = /\n*Command aborted\s*$/;
|
|
287
|
+
const TIMEOUT_PATTERN = /\n*Command timed out after (\d+) seconds\s*$/;
|
|
288
|
+
const TRUNCATION_FOOTER_PATTERN = /\n*\[Showing [^\]]*\]\s*$/;
|
|
289
|
+
|
|
290
|
+
/** Pull the trailing status and truncation footers out of Pi's stored bash output. */
|
|
291
|
+
export function parseBashResult(result: ToolResultLike): ParsedBashResult {
|
|
292
|
+
let text = textContent(result).replace(/\s+$/, "");
|
|
293
|
+
let status: BashStatus = { kind: "ok" };
|
|
294
|
+
|
|
295
|
+
const exitMatch = EXIT_PATTERN.exec(text);
|
|
296
|
+
const abortMatch = exitMatch ? null : ABORT_PATTERN.exec(text);
|
|
297
|
+
const timeoutMatch = exitMatch || abortMatch ? null : TIMEOUT_PATTERN.exec(text);
|
|
298
|
+
if (exitMatch) {
|
|
299
|
+
const code = Number.parseInt(exitMatch[1]!, 10);
|
|
300
|
+
status = code === 0 ? { kind: "ok" } : { kind: "exit", code };
|
|
301
|
+
text = text.slice(0, exitMatch.index).replace(/\s+$/, "");
|
|
302
|
+
} else if (abortMatch) {
|
|
303
|
+
status = { kind: "aborted" };
|
|
304
|
+
text = text.slice(0, abortMatch.index).replace(/\s+$/, "");
|
|
305
|
+
} else if (timeoutMatch) {
|
|
306
|
+
status = { kind: "timeout", seconds: Number.parseInt(timeoutMatch[1]!, 10) };
|
|
307
|
+
text = text.slice(0, timeoutMatch.index).replace(/\s+$/, "");
|
|
308
|
+
} else if (result.isError) {
|
|
309
|
+
status = { kind: "failed" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let truncated = false;
|
|
313
|
+
let shown = 0;
|
|
314
|
+
let total = 0;
|
|
315
|
+
const footerMatch = TRUNCATION_FOOTER_PATTERN.exec(text);
|
|
316
|
+
if (footerMatch) {
|
|
317
|
+
text = text.slice(0, footerMatch.index).replace(/\s+$/, "");
|
|
318
|
+
truncated = true;
|
|
319
|
+
const range = /Showing lines (\d+)-(\d+) of (\d+)/.exec(footerMatch[0]);
|
|
320
|
+
if (range) {
|
|
321
|
+
shown = Number.parseInt(range[2]!, 10) - Number.parseInt(range[1]!, 10) + 1;
|
|
322
|
+
total = Number.parseInt(range[3]!, 10);
|
|
323
|
+
} else {
|
|
324
|
+
const totalMatch = /of (\d+)/.exec(footerMatch[0]);
|
|
325
|
+
if (totalMatch) total = Number.parseInt(totalMatch[1]!, 10);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const truncation = (result.details as { truncation?: { outputLines?: number; totalLines?: number; truncated?: boolean } } | undefined)?.truncation;
|
|
329
|
+
if (truncation?.truncated) {
|
|
330
|
+
truncated = true;
|
|
331
|
+
shown = truncation.outputLines ?? shown;
|
|
332
|
+
total = truncation.totalLines ?? total;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const bodyLines = text ? text.split("\n").length : 0;
|
|
336
|
+
if (!truncated) {
|
|
337
|
+
shown = bodyLines;
|
|
338
|
+
total = bodyLines;
|
|
339
|
+
} else if (!shown) {
|
|
340
|
+
shown = bodyLines;
|
|
341
|
+
}
|
|
342
|
+
return { body: text, shown, status, total, truncated };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function bashStatusCaption(parsed: ParsedBashResult, theme: RenderTheme): string {
|
|
346
|
+
const parts: string[] = [];
|
|
347
|
+
if (parsed.status.kind === "ok") parts.push(theme.fg("success", GLYPHS.ok));
|
|
348
|
+
else if (parsed.status.kind === "exit")
|
|
349
|
+
parts.push(theme.fg("error", `${GLYPHS.fail} exit ${parsed.status.code}`));
|
|
350
|
+
else if (parsed.status.kind === "aborted")
|
|
351
|
+
parts.push(theme.fg("error", `${GLYPHS.fail} aborted`));
|
|
352
|
+
else if (parsed.status.kind === "timeout")
|
|
353
|
+
parts.push(theme.fg("error", `${GLYPHS.fail} timeout ${parsed.status.seconds}s`));
|
|
354
|
+
else parts.push(theme.fg("error", `${GLYPHS.fail} failed`));
|
|
355
|
+
|
|
356
|
+
if (parsed.truncated && parsed.total > 0) {
|
|
357
|
+
parts.push(theme.fg("muted", `${parsed.shown}/${parsed.total} lines`));
|
|
358
|
+
parts.push(theme.fg("warning", "truncated"));
|
|
359
|
+
} else if (parsed.shown > 0) {
|
|
360
|
+
parts.push(theme.fg("muted", plural(parsed.shown, "line")));
|
|
361
|
+
}
|
|
362
|
+
return parts.join(theme.fg("dim", GLYPHS.dot));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Draw rows until the budget runs out, and report how many source lines went undrawn.
|
|
367
|
+
*
|
|
368
|
+
* A cap on source lines is not a cap at all: one line of minified output or one long command
|
|
369
|
+
* wraps into many rows at overlay width.
|
|
370
|
+
*/
|
|
371
|
+
function fillRows(
|
|
372
|
+
sourceLines: string[],
|
|
373
|
+
budget: number,
|
|
374
|
+
segmentsFor: (line: string, index: number) => string[],
|
|
375
|
+
): { hidden: number; rows: string[] } {
|
|
376
|
+
const rows: string[] = [];
|
|
377
|
+
let consumed = 0;
|
|
378
|
+
for (; consumed < sourceLines.length && rows.length < budget; consumed += 1) {
|
|
379
|
+
for (const segment of segmentsFor(sourceLines[consumed]!, consumed)) {
|
|
380
|
+
if (rows.length >= budget) break;
|
|
381
|
+
rows.push(segment);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { hidden: sourceLines.length - consumed, rows };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function commandFrame(command: string, theme: RenderTheme, width: number): string[] {
|
|
388
|
+
const highlighted = highlightCode(command, "bash");
|
|
389
|
+
const lines = highlighted.length > 0 ? [...highlighted] : [command];
|
|
390
|
+
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
391
|
+
const innerWidth = Math.max(1, width - 4);
|
|
392
|
+
const numWidth = Math.max(2, String(lines.length).length);
|
|
393
|
+
const codeWidth = Math.max(1, innerWidth - numWidth - 1);
|
|
394
|
+
const { hidden, rows } = fillRows(lines, CAPS.bashCommandRows, (line, index) => {
|
|
395
|
+
const wrapped = wrapTextWithAnsi(line || " ", codeWidth);
|
|
396
|
+
return (wrapped.length > 0 ? wrapped : [""]).map((segment, segmentIndex) => {
|
|
397
|
+
const gutter =
|
|
398
|
+
segmentIndex === 0
|
|
399
|
+
? theme.fg("muted", String(index + 1).padStart(numWidth))
|
|
400
|
+
: " ".repeat(numWidth);
|
|
401
|
+
return frameRow(theme, `${gutter} ${segment}`, innerWidth);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
if (hidden > 0)
|
|
405
|
+
rows.push(
|
|
406
|
+
frameRow(theme, theme.fg("muted", `${GLYPHS.ellipsis} ${hidden} more command lines`), innerWidth),
|
|
407
|
+
);
|
|
408
|
+
return [frameTop(theme, width), ...rows, frameBottom(theme, width)];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const renderBash: ToolRenderer = (args, result, theme, width) => {
|
|
412
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
413
|
+
const timeout = typeof args.timeout === "number" ? args.timeout : undefined;
|
|
414
|
+
const firstLine = splitTerminalLines(command)[0]?.trim() || GLYPHS.ellipsis;
|
|
415
|
+
const facts = commandFacts(command, timeout);
|
|
416
|
+
const factsText = facts.length > 0 ? theme.fg("dim", `${GLYPHS.dot}${facts.join(GLYPHS.dot)}`) : "";
|
|
417
|
+
const call = `${stackPrefix(theme)}${toolLabel(theme, "Bash ")}${theme.fg(
|
|
418
|
+
"accent",
|
|
419
|
+
truncateText(firstLine, CAPS.commandPreviewChars),
|
|
420
|
+
)}${factsText}`;
|
|
421
|
+
|
|
422
|
+
if (!result) return [truncateAnsi(`${call}${separator(theme)}${theme.fg("muted", "no result")}`, width)];
|
|
423
|
+
|
|
424
|
+
const lines = [truncateAnsi(call, width)];
|
|
425
|
+
const isMultiLine = splitTerminalLines(command).length > 1;
|
|
426
|
+
if (isMultiLine || visibleWidth(call) > width) lines.push(...commandFrame(command, theme, width));
|
|
427
|
+
|
|
428
|
+
const parsed = parseBashResult(result);
|
|
429
|
+
const caption = bashStatusCaption(parsed, theme);
|
|
430
|
+
if (!parsed.body.trim()) {
|
|
431
|
+
lines.push(`${caption}${theme.fg("dim", GLYPHS.dot)}${theme.fg("muted", "no output")}`);
|
|
432
|
+
return lines;
|
|
433
|
+
}
|
|
434
|
+
const innerWidth = Math.max(1, width - 4);
|
|
435
|
+
const { hidden, rows } = fillRows(
|
|
436
|
+
splitTerminalLines(parsed.body),
|
|
437
|
+
CAPS.bashOutputRows,
|
|
438
|
+
(bodyLine) =>
|
|
439
|
+
wrapTextWithAnsi(theme.fg("toolOutput", clipLine(bodyLine)), innerWidth).map((segment) =>
|
|
440
|
+
frameRow(theme, segment, innerWidth),
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
lines.push(frameTop(theme, width, caption), ...rows);
|
|
444
|
+
if (hidden > 0)
|
|
445
|
+
lines.push(
|
|
446
|
+
frameRow(theme, theme.fg("muted", `${GLYPHS.ellipsis} ${hidden} more output lines`), innerWidth),
|
|
447
|
+
);
|
|
448
|
+
lines.push(frameBottom(theme, width));
|
|
449
|
+
return lines;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// --- everything else ---------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The shape used for MCP tools, custom tools, and any tool added later.
|
|
456
|
+
*
|
|
457
|
+
* It deliberately has no per-tool branches: without a known visual contract, guessing at a
|
|
458
|
+
* tool's semantics produces worse output than showing its arguments plainly.
|
|
459
|
+
*/
|
|
460
|
+
export function renderGenericTool(
|
|
461
|
+
toolName: string,
|
|
462
|
+
args: ToolArgs,
|
|
463
|
+
result: ToolResultLike | undefined,
|
|
464
|
+
theme: RenderTheme,
|
|
465
|
+
width: number,
|
|
466
|
+
): string[] {
|
|
467
|
+
const readableArguments = formatArguments(args, CAPS.genericArgumentChars);
|
|
468
|
+
const call = `${toolLabel(theme, `${toolName} `)}${
|
|
469
|
+
readableArguments ? theme.fg("accent", readableArguments) : ""
|
|
470
|
+
}`;
|
|
471
|
+
if (!result) return wrapBlock(unfinished(call, theme), width);
|
|
472
|
+
const output = textContent(result);
|
|
473
|
+
if (result.isError)
|
|
474
|
+
return wrapBlock(headline(call, errorText(result, theme, `${toolName} failed`), theme), width);
|
|
475
|
+
const count = output.trim() ? lineCount(output) : 0;
|
|
476
|
+
const summary =
|
|
477
|
+
count === 0 ? theme.fg("muted", "no output") : theme.fg("success", plural(count, "line"));
|
|
478
|
+
return [
|
|
479
|
+
...wrapBlock(headline(call, summary, theme), width),
|
|
480
|
+
...previewRows(output, CAPS.genericResultLines, "line(s)", theme, width),
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const RENDERERS: Record<FirstClassTool, ToolRenderer> = {
|
|
485
|
+
bash: renderBash,
|
|
486
|
+
edit: renderEdit,
|
|
487
|
+
find: readOnlyRenderer("find"),
|
|
488
|
+
grep: readOnlyRenderer("grep"),
|
|
489
|
+
ls: readOnlyRenderer("ls"),
|
|
490
|
+
read: renderRead,
|
|
491
|
+
write: renderWrite,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
export function renderFirstClassTool(
|
|
495
|
+
toolName: FirstClassTool,
|
|
496
|
+
args: ToolArgs,
|
|
497
|
+
result: ToolResultLike | undefined,
|
|
498
|
+
theme: RenderTheme,
|
|
499
|
+
width: number,
|
|
500
|
+
): string[] {
|
|
501
|
+
return RENDERERS[toolName](args, result, theme, width);
|
|
502
|
+
}
|