@oh-my-pi/pi-coding-agent 13.3.13 → 13.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +97 -7
  2. package/examples/sdk/README.md +22 -0
  3. package/package.json +7 -7
  4. package/src/capability/index.ts +1 -11
  5. package/src/commit/analysis/index.ts +4 -4
  6. package/src/config/settings-schema.ts +18 -15
  7. package/src/config/settings.ts +2 -20
  8. package/src/discovery/index.ts +1 -11
  9. package/src/exa/index.ts +1 -10
  10. package/src/extensibility/custom-commands/index.ts +2 -15
  11. package/src/extensibility/custom-tools/index.ts +3 -18
  12. package/src/extensibility/custom-tools/loader.ts +28 -5
  13. package/src/extensibility/custom-tools/types.ts +18 -1
  14. package/src/extensibility/extensions/index.ts +9 -130
  15. package/src/extensibility/extensions/types.ts +2 -1
  16. package/src/extensibility/hooks/index.ts +3 -14
  17. package/src/extensibility/plugins/index.ts +6 -31
  18. package/src/index.ts +28 -220
  19. package/src/internal-urls/docs-index.generated.ts +3 -2
  20. package/src/internal-urls/index.ts +11 -16
  21. package/src/mcp/index.ts +11 -37
  22. package/src/mcp/tool-bridge.ts +3 -42
  23. package/src/mcp/transports/index.ts +2 -2
  24. package/src/modes/components/extensions/index.ts +3 -3
  25. package/src/modes/components/index.ts +35 -40
  26. package/src/modes/interactive-mode.ts +4 -1
  27. package/src/modes/rpc/rpc-mode.ts +1 -7
  28. package/src/modes/theme/theme.ts +11 -10
  29. package/src/modes/types.ts +1 -1
  30. package/src/patch/index.ts +4 -20
  31. package/src/prompts/system/system-prompt.md +18 -4
  32. package/src/prompts/tools/ast-edit.md +33 -0
  33. package/src/prompts/tools/ast-grep.md +34 -0
  34. package/src/prompts/tools/bash.md +2 -2
  35. package/src/prompts/tools/hashline.md +1 -0
  36. package/src/prompts/tools/resolve.md +8 -0
  37. package/src/sdk.ts +27 -7
  38. package/src/session/agent-session.ts +25 -36
  39. package/src/session/session-manager.ts +0 -30
  40. package/src/slash-commands/builtin-registry.ts +4 -2
  41. package/src/stt/index.ts +3 -3
  42. package/src/task/types.ts +2 -2
  43. package/src/tools/ast-edit.ts +480 -0
  44. package/src/tools/ast-grep.ts +435 -0
  45. package/src/tools/bash.ts +3 -2
  46. package/src/tools/gemini-image.ts +3 -3
  47. package/src/tools/grep.ts +26 -8
  48. package/src/tools/index.ts +55 -57
  49. package/src/tools/pending-action.ts +33 -0
  50. package/src/tools/render-utils.ts +10 -0
  51. package/src/tools/renderers.ts +6 -4
  52. package/src/tools/resolve.ts +156 -0
  53. package/src/tools/submit-result.ts +1 -1
  54. package/src/web/search/index.ts +6 -4
  55. package/src/web/search/providers/anthropic.ts +2 -2
  56. package/src/web/search/providers/base.ts +3 -0
  57. package/src/web/search/providers/exa.ts +11 -5
  58. package/src/web/search/providers/gemini.ts +112 -24
  59. package/src/patch/normative.ts +0 -72
  60. package/src/prompts/tools/ast-find.md +0 -20
  61. package/src/prompts/tools/ast-replace.md +0 -21
  62. package/src/tools/ast-find.ts +0 -316
  63. package/src/tools/ast-replace.ts +0 -294
@@ -0,0 +1,480 @@
1
+ import * as path from "node:path";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
4
+ import type { Component } from "@oh-my-pi/pi-tui";
5
+ import { Text } from "@oh-my-pi/pi-tui";
6
+ import { untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { type Static, Type } from "@sinclair/typebox";
8
+ import { renderPromptTemplate } from "../config/prompt-templates";
9
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
+ import type { Theme } from "../modes/theme/theme";
11
+ import { computeLineHash } from "../patch/hashline";
12
+ import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
13
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
+ import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
+ import type { ToolSession } from ".";
16
+ import type { OutputMeta } from "./output-meta";
17
+ import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
18
+ import {
19
+ formatCount,
20
+ formatEmptyMessage,
21
+ formatErrorMessage,
22
+ formatParseErrors,
23
+ PARSE_ERRORS_LIMIT,
24
+ PREVIEW_LIMITS,
25
+ } from "./render-utils";
26
+ import { ToolError } from "./tool-errors";
27
+ import { toolResult } from "./tool-result";
28
+
29
+ const astEditOpSchema = Type.Object({
30
+ pat: Type.String({ description: "AST pattern to match" }),
31
+ out: Type.String({ description: "Replacement template" }),
32
+ });
33
+
34
+ const astEditSchema = Type.Object({
35
+ ops: Type.Array(astEditOpSchema, {
36
+ description: "Rewrite ops as [{ pat, out }]",
37
+ }),
38
+ lang: Type.Optional(Type.String({ description: "Language override" })),
39
+ path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
40
+ selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
41
+ limit: Type.Optional(Type.Number({ description: "Max total replacements" })),
42
+ });
43
+
44
+ export interface AstEditToolDetails {
45
+ totalReplacements: number;
46
+ filesTouched: number;
47
+ filesSearched: number;
48
+ applied: boolean;
49
+ limitReached: boolean;
50
+ parseErrors?: string[];
51
+ scopePath?: string;
52
+ files?: string[];
53
+ fileReplacements?: Array<{ path: string; count: number }>;
54
+ meta?: OutputMeta;
55
+ }
56
+
57
+ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
58
+ readonly name = "ast_edit";
59
+ readonly label = "AST Edit";
60
+ readonly description: string;
61
+ readonly parameters = astEditSchema;
62
+ readonly strict = true;
63
+ readonly deferrable = true;
64
+ constructor(private readonly session: ToolSession) {
65
+ this.description = renderPromptTemplate(astEditDescription);
66
+ }
67
+
68
+ async execute(
69
+ _toolCallId: string,
70
+ params: Static<typeof astEditSchema>,
71
+ signal?: AbortSignal,
72
+ _onUpdate?: AgentToolUpdateCallback<AstEditToolDetails>,
73
+ _context?: AgentToolContext,
74
+ ): Promise<AgentToolResult<AstEditToolDetails>> {
75
+ return untilAborted(signal, async () => {
76
+ const ops = params.ops.map((entry, index) => {
77
+ if (entry.pat.length === 0) {
78
+ throw new ToolError(`\`ops[${index}].pat\` must be a non-empty pattern`);
79
+ }
80
+ return [entry.pat, entry.out] as const;
81
+ });
82
+ if (ops.length === 0) {
83
+ throw new ToolError("`ops` must include at least one op entry");
84
+ }
85
+ const seenPatterns = new Set<string>();
86
+ for (const [pat] of ops) {
87
+ if (seenPatterns.has(pat)) {
88
+ throw new ToolError(`Duplicate rewrite pattern: ${pat}`);
89
+ }
90
+ seenPatterns.add(pat);
91
+ }
92
+ const normalizedRewrites = Object.fromEntries(ops);
93
+ const maxReplacements = params.limit !== undefined ? Math.floor(params.limit) : undefined;
94
+ if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
95
+ throw new ToolError("limit must be a positive number");
96
+ }
97
+ const maxFiles = parseInt(process.env.PI_MAX_AST_FILES ?? "", 10) || 1000;
98
+
99
+ let searchPath: string | undefined;
100
+ let globFilter: string | undefined;
101
+ const rawPath = params.path?.trim();
102
+ if (rawPath) {
103
+ const internalRouter = this.session.internalRouter;
104
+ if (internalRouter?.canHandle(rawPath)) {
105
+ if (hasGlobPathChars(rawPath)) {
106
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
107
+ }
108
+ const resource = await internalRouter.resolve(rawPath);
109
+ if (!resource.sourcePath) {
110
+ throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
111
+ }
112
+ searchPath = resource.sourcePath;
113
+ } else {
114
+ const parsedPath = parseSearchPath(rawPath);
115
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
116
+ globFilter = parsedPath.glob;
117
+ }
118
+ }
119
+
120
+ const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
121
+ const scopePath = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/") || ".";
122
+ let isDirectory: boolean;
123
+ try {
124
+ const stat = await Bun.file(resolvedSearchPath).stat();
125
+ isDirectory = stat.isDirectory();
126
+ } catch {
127
+ throw new ToolError(`Path not found: ${resolvedSearchPath}`);
128
+ }
129
+
130
+ const result = await astEdit({
131
+ rewrites: normalizedRewrites,
132
+ lang: params.lang?.trim(),
133
+ path: resolvedSearchPath,
134
+ glob: globFilter,
135
+ selector: params.selector?.trim(),
136
+ dryRun: true,
137
+ maxReplacements,
138
+ maxFiles,
139
+ failOnParseError: false,
140
+ signal,
141
+ });
142
+
143
+ const formatPath = (filePath: string): string => {
144
+ const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
145
+ if (isDirectory) {
146
+ return cleanPath.replace(/\\/g, "/");
147
+ }
148
+ return path.basename(cleanPath);
149
+ };
150
+
151
+ const files = new Set<string>();
152
+ const fileList: string[] = [];
153
+ const fileReplacementCounts = new Map<string, number>();
154
+ const changesByFile = new Map<string, AstReplaceChange[]>();
155
+ const recordFile = (relativePath: string) => {
156
+ if (!files.has(relativePath)) {
157
+ files.add(relativePath);
158
+ fileList.push(relativePath);
159
+ }
160
+ };
161
+ for (const fileChange of result.fileChanges) {
162
+ const relativePath = formatPath(fileChange.path);
163
+ recordFile(relativePath);
164
+ fileReplacementCounts.set(relativePath, (fileReplacementCounts.get(relativePath) ?? 0) + fileChange.count);
165
+ }
166
+ for (const change of result.changes) {
167
+ const relativePath = formatPath(change.path);
168
+ recordFile(relativePath);
169
+ if (!changesByFile.has(relativePath)) {
170
+ changesByFile.set(relativePath, []);
171
+ }
172
+ changesByFile.get(relativePath)!.push(change);
173
+ }
174
+
175
+ const baseDetails: AstEditToolDetails = {
176
+ totalReplacements: result.totalReplacements,
177
+ filesTouched: result.filesTouched,
178
+ filesSearched: result.filesSearched,
179
+ applied: result.applied,
180
+ limitReached: result.limitReached,
181
+ parseErrors: result.parseErrors,
182
+ scopePath,
183
+ files: fileList,
184
+ fileReplacements: [],
185
+ };
186
+
187
+ if (result.totalReplacements === 0) {
188
+ const parseMessage = result.parseErrors?.length
189
+ ? `\n${formatParseErrors(result.parseErrors).join("\n")}`
190
+ : "";
191
+ return toolResult(baseDetails).text(`No replacements made${parseMessage}`).done();
192
+ }
193
+
194
+ const useHashLines = resolveFileDisplayMode(this.session).hashLines;
195
+ const outputLines: string[] = [];
196
+ const renderChangesForFile = (relativePath: string) => {
197
+ const fileChanges = changesByFile.get(relativePath) ?? [];
198
+ const lineWidth =
199
+ fileChanges.length > 0 ? Math.max(...fileChanges.map(change => change.startLine.toString().length)) : 1;
200
+ for (const change of fileChanges) {
201
+ const beforeFirstLine = change.before.split("\n", 1)[0] ?? "";
202
+ const afterFirstLine = change.after.split("\n", 1)[0] ?? "";
203
+ const beforeLine = beforeFirstLine.slice(0, 120);
204
+ const afterLine = afterFirstLine.slice(0, 120);
205
+ const beforeRef = useHashLines
206
+ ? `${change.startLine}#${computeLineHash(change.startLine, beforeFirstLine)}`
207
+ : `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
208
+ const afterRef = useHashLines
209
+ ? `${change.startLine}#${computeLineHash(change.startLine, afterFirstLine)}`
210
+ : `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
211
+ const lineSeparator = useHashLines ? ":" : " ";
212
+ outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
213
+ outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
214
+ }
215
+ };
216
+
217
+ if (isDirectory) {
218
+ const filesByDirectory = new Map<string, string[]>();
219
+ for (const relativePath of fileList) {
220
+ const directory = path.dirname(relativePath).replace(/\\/g, "/");
221
+ if (!filesByDirectory.has(directory)) {
222
+ filesByDirectory.set(directory, []);
223
+ }
224
+ filesByDirectory.get(directory)!.push(relativePath);
225
+ }
226
+ for (const [directory, directoryFiles] of filesByDirectory) {
227
+ if (directory === ".") {
228
+ for (const relativePath of directoryFiles) {
229
+ if (outputLines.length > 0) {
230
+ outputLines.push("");
231
+ }
232
+ const count = fileReplacementCounts.get(relativePath) ?? 0;
233
+ outputLines.push(`# ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
234
+ renderChangesForFile(relativePath);
235
+ }
236
+ continue;
237
+ }
238
+ if (outputLines.length > 0) {
239
+ outputLines.push("");
240
+ }
241
+ outputLines.push(`# ${directory}`);
242
+ for (const relativePath of directoryFiles) {
243
+ const count = fileReplacementCounts.get(relativePath) ?? 0;
244
+ outputLines.push(`## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
245
+ renderChangesForFile(relativePath);
246
+ }
247
+ }
248
+ } else {
249
+ for (const relativePath of fileList) {
250
+ renderChangesForFile(relativePath);
251
+ }
252
+ }
253
+
254
+ const fileReplacements = fileList.map(filePath => ({
255
+ path: filePath,
256
+ count: fileReplacementCounts.get(filePath) ?? 0,
257
+ }));
258
+ if (result.limitReached) {
259
+ outputLines.push("", "Limit reached; narrow path or increase limit.");
260
+ }
261
+ if (result.parseErrors?.length) {
262
+ outputLines.push("", ...formatParseErrors(result.parseErrors));
263
+ }
264
+
265
+ // Register pending action so `resolve` can apply or discard these previewed changes
266
+ if (!result.applied && result.totalReplacements > 0) {
267
+ const previewReplacementPlural = result.totalReplacements !== 1 ? "s" : "";
268
+ const previewFilePlural = result.filesTouched !== 1 ? "s" : "";
269
+ this.session.pendingActionStore?.push({
270
+ label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
271
+ sourceToolName: this.name,
272
+ apply: async (_reason: string) => {
273
+ const applyResult = await astEdit({
274
+ rewrites: normalizedRewrites,
275
+ lang: params.lang?.trim(),
276
+ path: resolvedSearchPath,
277
+ glob: globFilter,
278
+ selector: params.selector?.trim(),
279
+ dryRun: false,
280
+ maxReplacements,
281
+ maxFiles,
282
+ failOnParseError: false,
283
+ });
284
+ const appliedDetails: AstEditToolDetails = {
285
+ totalReplacements: applyResult.totalReplacements,
286
+ filesTouched: applyResult.filesTouched,
287
+ filesSearched: applyResult.filesSearched,
288
+ applied: applyResult.applied,
289
+ limitReached: applyResult.limitReached,
290
+ parseErrors: applyResult.parseErrors,
291
+ scopePath,
292
+ files: fileList,
293
+ fileReplacements,
294
+ };
295
+ const appliedReplacementPlural = applyResult.totalReplacements !== 1 ? "s" : "";
296
+ const appliedFilePlural = applyResult.filesTouched !== 1 ? "s" : "";
297
+ const text = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
298
+ return toolResult(appliedDetails).text(text).done();
299
+ },
300
+ });
301
+ }
302
+
303
+ const details: AstEditToolDetails = {
304
+ ...baseDetails,
305
+ fileReplacements,
306
+ };
307
+ return toolResult(details).text(outputLines.join("\n")).done();
308
+ });
309
+ }
310
+ }
311
+
312
+ // =============================================================================
313
+ // TUI Renderer
314
+ // =============================================================================
315
+
316
+ interface AstEditRenderArgs {
317
+ ops?: Array<{ pat?: string; out?: string }>;
318
+ lang?: string;
319
+ path?: string;
320
+ selector?: string;
321
+ limit?: number;
322
+ }
323
+
324
+ const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
325
+
326
+ export const astEditToolRenderer = {
327
+ inline: true,
328
+ renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
329
+ const meta: string[] = [];
330
+ if (args.lang) meta.push(`lang:${args.lang}`);
331
+ if (args.path) meta.push(`in ${args.path}`);
332
+ if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
333
+ const rewriteCount = args.ops?.length ?? 0;
334
+ if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
335
+
336
+ const description = rewriteCount === 1 ? args.ops?.[0]?.pat : rewriteCount ? `${rewriteCount} rewrites` : "?";
337
+ const text = renderStatusLine({ icon: "pending", title: "AST Edit", description, meta }, uiTheme);
338
+ return new Text(text, 0, 0);
339
+ },
340
+
341
+ renderResult(
342
+ result: { content: Array<{ type: string; text?: string }>; details?: AstEditToolDetails; isError?: boolean },
343
+ options: RenderResultOptions,
344
+ uiTheme: Theme,
345
+ args?: AstEditRenderArgs,
346
+ ): Component {
347
+ const details = result.details;
348
+
349
+ if (result.isError) {
350
+ const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
351
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
352
+ }
353
+
354
+ const totalReplacements = details?.totalReplacements ?? 0;
355
+ const filesTouched = details?.filesTouched ?? 0;
356
+ const filesSearched = details?.filesSearched ?? 0;
357
+ const limitReached = details?.limitReached ?? false;
358
+
359
+ if (totalReplacements === 0) {
360
+ const rewriteCount = args?.ops?.length ?? 0;
361
+ const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
362
+ const meta = ["0 replacements"];
363
+ if (details?.scopePath) meta.push(`in ${details.scopePath}`);
364
+ if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
365
+ const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
366
+ const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
367
+ if (details?.parseErrors?.length) {
368
+ const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
369
+ for (const err of capped) {
370
+ lines.push(uiTheme.fg("warning", ` - ${err}`));
371
+ }
372
+ if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
373
+ lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
374
+ }
375
+ }
376
+ return new Text(lines.join("\n"), 0, 0);
377
+ }
378
+
379
+ const summaryParts = [formatCount("replacement", totalReplacements), formatCount("file", filesTouched)];
380
+ const meta = [...summaryParts];
381
+ if (details?.scopePath) meta.push(`in ${details.scopePath}`);
382
+ meta.push(`searched ${filesSearched}`);
383
+ if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
384
+ const rewriteCount = args?.ops?.length ?? 0;
385
+ const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
386
+
387
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
388
+ const rawLines = textContent.split("\n");
389
+ const hasSeparators = rawLines.some(line => line.trim().length === 0);
390
+ const allGroups: string[][] = [];
391
+ if (hasSeparators) {
392
+ let current: string[] = [];
393
+ for (const line of rawLines) {
394
+ if (line.trim().length === 0) {
395
+ if (current.length > 0) {
396
+ allGroups.push(current);
397
+ current = [];
398
+ }
399
+ continue;
400
+ }
401
+ current.push(line);
402
+ }
403
+ if (current.length > 0) allGroups.push(current);
404
+ } else {
405
+ const nonEmpty = rawLines.filter(line => line.trim().length > 0);
406
+ if (nonEmpty.length > 0) {
407
+ allGroups.push(nonEmpty);
408
+ }
409
+ }
410
+ const changeGroups = allGroups.filter(
411
+ group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
412
+ );
413
+
414
+ const getCollapsedChangeLimit = (groups: string[][], maxLines: number): number => {
415
+ if (groups.length === 0) return 0;
416
+ let usedLines = 0;
417
+ let count = 0;
418
+ for (const group of groups) {
419
+ if (count > 0 && usedLines + group.length > maxLines) break;
420
+ usedLines += group.length;
421
+ count += 1;
422
+ if (usedLines >= maxLines) break;
423
+ }
424
+ return count;
425
+ };
426
+ const badge = { label: "proposed", color: "warning" as const };
427
+ const header = renderStatusLine(
428
+ { icon: limitReached ? "warning" : "success", title: "AST Edit", description, badge, meta },
429
+ uiTheme,
430
+ );
431
+
432
+ const extraLines: string[] = [];
433
+ if (limitReached) {
434
+ extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
435
+ }
436
+ if (details?.parseErrors?.length) {
437
+ const total = details.parseErrors.length;
438
+ const label =
439
+ total > PARSE_ERRORS_LIMIT
440
+ ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
441
+ : `${total} parse issue${total !== 1 ? "s" : ""}`;
442
+ extraLines.push(uiTheme.fg("warning", label));
443
+ }
444
+ let cached: RenderCache | undefined;
445
+ return {
446
+ render(width: number): string[] {
447
+ const { expanded } = options;
448
+ const key = new Hasher().bool(expanded).u32(width).digest();
449
+ if (cached?.key === key) return cached.lines;
450
+ const maxCollapsed = expanded
451
+ ? changeGroups.length
452
+ : getCollapsedChangeLimit(changeGroups, COLLAPSED_CHANGE_LIMIT);
453
+ const changeLines = renderTreeList(
454
+ {
455
+ items: changeGroups,
456
+ expanded,
457
+ maxCollapsed,
458
+ itemType: "change",
459
+ renderItem: group =>
460
+ group.map(line => {
461
+ if (line.startsWith("## ")) return uiTheme.fg("dim", line);
462
+ if (line.startsWith("# ")) return uiTheme.fg("accent", line);
463
+ if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
464
+ if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
465
+ return uiTheme.fg("toolOutput", line);
466
+ }),
467
+ },
468
+ uiTheme,
469
+ );
470
+ const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
471
+ cached = { key, lines: rendered };
472
+ return rendered;
473
+ },
474
+ invalidate() {
475
+ cached = undefined;
476
+ },
477
+ };
478
+ },
479
+ mergeCallAndResult: true,
480
+ };