@oh-my-pi/pi-coding-agent 3.14.0 → 3.15.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 (148) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/docs/theme.md +38 -5
  3. package/examples/sdk/11-sessions.ts +2 -2
  4. package/package.json +7 -4
  5. package/src/cli/file-processor.ts +51 -2
  6. package/src/cli/plugin-cli.ts +25 -19
  7. package/src/cli/update-cli.ts +4 -3
  8. package/src/core/agent-session.ts +31 -4
  9. package/src/core/compaction/branch-summarization.ts +4 -32
  10. package/src/core/compaction/compaction.ts +6 -84
  11. package/src/core/compaction/utils.ts +2 -3
  12. package/src/core/custom-tools/types.ts +2 -0
  13. package/src/core/export-html/index.ts +1 -1
  14. package/src/core/hooks/tool-wrapper.ts +0 -1
  15. package/src/core/hooks/types.ts +2 -2
  16. package/src/core/plugins/doctor.ts +9 -1
  17. package/src/core/sdk.ts +2 -1
  18. package/src/core/session-manager.ts +518 -40
  19. package/src/core/settings-manager.ts +174 -0
  20. package/src/core/system-prompt.ts +9 -14
  21. package/src/core/title-generator.ts +2 -8
  22. package/src/core/tools/ask.ts +19 -37
  23. package/src/core/tools/bash.ts +2 -37
  24. package/src/core/tools/edit.ts +2 -9
  25. package/src/core/tools/exa/render.ts +52 -48
  26. package/src/core/tools/find.ts +10 -8
  27. package/src/core/tools/grep.ts +45 -17
  28. package/src/core/tools/ls.ts +22 -2
  29. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  30. package/src/core/tools/lsp/clients/index.ts +49 -0
  31. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  32. package/src/core/tools/lsp/config.ts +3 -0
  33. package/src/core/tools/lsp/index.ts +107 -55
  34. package/src/core/tools/lsp/render.ts +192 -79
  35. package/src/core/tools/lsp/types.ts +27 -0
  36. package/src/core/tools/lsp/utils.ts +62 -22
  37. package/src/core/tools/notebook.ts +9 -1
  38. package/src/core/tools/output.ts +37 -14
  39. package/src/core/tools/read.ts +349 -34
  40. package/src/core/tools/renderers.ts +290 -89
  41. package/src/core/tools/review.ts +12 -5
  42. package/src/core/tools/task/agents.ts +5 -5
  43. package/src/core/tools/task/commands.ts +3 -3
  44. package/src/core/tools/task/executor.ts +33 -1
  45. package/src/core/tools/task/index.ts +93 -6
  46. package/src/core/tools/task/render.ts +147 -66
  47. package/src/core/tools/task/types.ts +14 -9
  48. package/src/core/tools/web-fetch.ts +242 -103
  49. package/src/core/tools/web-search/index.ts +64 -20
  50. package/src/core/tools/web-search/providers/exa.ts +68 -172
  51. package/src/core/tools/web-search/render.ts +264 -74
  52. package/src/core/tools/write.ts +2 -8
  53. package/src/main.ts +10 -6
  54. package/src/modes/cleanup.ts +23 -0
  55. package/src/modes/index.ts +9 -4
  56. package/src/modes/interactive/components/bash-execution.ts +6 -3
  57. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  58. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  60. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  61. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  62. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  63. package/src/modes/interactive/components/hook-message.ts +2 -2
  64. package/src/modes/interactive/components/hook-selector.ts +1 -1
  65. package/src/modes/interactive/components/model-selector.ts +22 -9
  66. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  67. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  68. package/src/modes/interactive/components/session-selector.ts +9 -6
  69. package/src/modes/interactive/components/settings-defs.ts +285 -1
  70. package/src/modes/interactive/components/settings-selector.ts +176 -3
  71. package/src/modes/interactive/components/status-line/index.ts +4 -0
  72. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  73. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  74. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  75. package/src/modes/interactive/components/status-line/types.ts +81 -0
  76. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  77. package/src/modes/interactive/components/status-line.ts +170 -223
  78. package/src/modes/interactive/components/tool-execution.ts +446 -211
  79. package/src/modes/interactive/components/tree-selector.ts +17 -6
  80. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  81. package/src/modes/interactive/components/welcome.ts +27 -19
  82. package/src/modes/interactive/interactive-mode.ts +98 -13
  83. package/src/modes/interactive/theme/dark.json +3 -2
  84. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  85. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  86. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  87. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  88. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  89. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  90. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  91. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  92. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  93. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  94. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  95. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  96. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  97. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  98. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  99. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  100. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  101. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  102. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  103. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  104. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  105. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  106. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  107. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  108. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  111. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  112. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  114. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  115. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  116. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  117. package/src/modes/interactive/theme/light.json +3 -2
  118. package/src/modes/interactive/theme/theme-schema.json +120 -4
  119. package/src/modes/interactive/theme/theme.ts +1228 -14
  120. package/src/prompts/branch-summary-preamble.md +3 -0
  121. package/src/prompts/branch-summary.md +28 -0
  122. package/src/prompts/compaction-summary.md +34 -0
  123. package/src/prompts/compaction-turn-prefix.md +16 -0
  124. package/src/prompts/compaction-update-summary.md +41 -0
  125. package/src/prompts/init.md +30 -0
  126. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  127. package/src/prompts/summarization-system.md +3 -0
  128. package/src/prompts/system-prompt.md +27 -0
  129. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  130. package/src/prompts/title-system.md +8 -0
  131. package/src/prompts/tools/ask.md +24 -0
  132. package/src/prompts/tools/bash.md +23 -0
  133. package/src/prompts/tools/edit.md +9 -0
  134. package/src/prompts/tools/find.md +6 -0
  135. package/src/prompts/tools/grep.md +12 -0
  136. package/src/prompts/tools/lsp.md +14 -0
  137. package/src/prompts/tools/output.md +23 -0
  138. package/src/prompts/tools/read.md +25 -0
  139. package/src/prompts/tools/web-fetch.md +8 -0
  140. package/src/prompts/tools/web-search.md +10 -0
  141. package/src/prompts/tools/write.md +10 -0
  142. package/src/commands/init.md +0 -20
  143. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  144. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  146. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  148. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -22,6 +22,289 @@ import { truncateToVisualLines } from "./visual-truncate";
22
22
 
23
23
  // Preview line limit for bash when not expanded
24
24
  const BASH_PREVIEW_LINES = 5;
25
+ const GENERIC_PREVIEW_LINES = 6;
26
+ const GENERIC_ARG_PREVIEW = 6;
27
+ const GENERIC_VALUE_MAX = 80;
28
+ const EDIT_DIFF_PREVIEW_HUNKS = 2;
29
+ const EDIT_DIFF_PREVIEW_LINES = 24;
30
+
31
+ function wrapBrackets(text: string): string {
32
+ return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
33
+ }
34
+
35
+ function countLines(text: string): number {
36
+ if (!text) return 0;
37
+ return text.split("\n").length;
38
+ }
39
+
40
+ function formatMetadataLine(lineCount: number | null, language: string | undefined): string {
41
+ const icon = theme.getLangIcon(language);
42
+ if (lineCount !== null) {
43
+ return theme.fg("dim", `${icon} ${lineCount} lines`);
44
+ }
45
+ return theme.fg("dim", `${icon}`);
46
+ }
47
+
48
+ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
49
+ const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
50
+
51
+ function getFileType(filePath: string): "image" | "binary" | "text" {
52
+ const ext = filePath.split(".").pop()?.toLowerCase();
53
+ if (!ext) return "text";
54
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
55
+ if (BINARY_EXTENSIONS.has(ext)) return "binary";
56
+ return "text";
57
+ }
58
+
59
+ function formatDiffStats(added: number, removed: number, hunks: number): string {
60
+ const parts: string[] = [];
61
+ if (added > 0) parts.push(theme.fg("success", `+${added}`));
62
+ if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
63
+ if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
64
+ return parts.join(theme.fg("dim", " / "));
65
+ }
66
+
67
+ type DiffStats = {
68
+ added: number;
69
+ removed: number;
70
+ hunks: number;
71
+ lines: number;
72
+ };
73
+
74
+ function getDiffStats(diffText: string): DiffStats {
75
+ const lines = diffText ? diffText.split("\n") : [];
76
+ let added = 0;
77
+ let removed = 0;
78
+ let hunks = 0;
79
+ let inHunk = false;
80
+
81
+ for (const line of lines) {
82
+ const isAdded = line.startsWith("+");
83
+ const isRemoved = line.startsWith("-");
84
+ const isChange = isAdded || isRemoved;
85
+
86
+ if (isAdded) added++;
87
+ if (isRemoved) removed++;
88
+
89
+ if (isChange && !inHunk) {
90
+ hunks++;
91
+ inHunk = true;
92
+ } else if (!isChange) {
93
+ inHunk = false;
94
+ }
95
+ }
96
+
97
+ return { added, removed, hunks, lines: lines.length };
98
+ }
99
+
100
+ function truncateDiffByHunk(
101
+ diffText: string,
102
+ maxHunks: number,
103
+ maxLines: number,
104
+ ): { text: string; hiddenHunks: number; hiddenLines: number } {
105
+ const lines = diffText ? diffText.split("\n") : [];
106
+ const totalStats = getDiffStats(diffText);
107
+ const kept: string[] = [];
108
+ let inHunk = false;
109
+ let currentHunks = 0;
110
+ let reachedLimit = false;
111
+
112
+ for (const line of lines) {
113
+ const isChange = line.startsWith("+") || line.startsWith("-");
114
+ if (isChange && !inHunk) {
115
+ currentHunks++;
116
+ inHunk = true;
117
+ }
118
+ if (!isChange) {
119
+ inHunk = false;
120
+ }
121
+
122
+ if (currentHunks > maxHunks) {
123
+ reachedLimit = true;
124
+ break;
125
+ }
126
+
127
+ kept.push(line);
128
+ if (kept.length >= maxLines) {
129
+ reachedLimit = true;
130
+ break;
131
+ }
132
+ }
133
+
134
+ if (!reachedLimit) {
135
+ return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
136
+ }
137
+
138
+ const keptStats = getDiffStats(kept.join("\n"));
139
+ return {
140
+ text: kept.join("\n"),
141
+ hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
142
+ hiddenLines: Math.max(0, totalStats.lines - kept.length),
143
+ };
144
+ }
145
+
146
+ interface ParsedDiagnostic {
147
+ filePath: string;
148
+ line: number;
149
+ col: number;
150
+ severity: "error" | "warning" | "info" | "hint";
151
+ source?: string;
152
+ message: string;
153
+ code?: string;
154
+ }
155
+
156
+ function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
157
+ // Format: filePath:line:col [severity] [source] message (code)
158
+ const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
159
+ if (!match) return null;
160
+ return {
161
+ filePath: match[1],
162
+ line: parseInt(match[2], 10),
163
+ col: parseInt(match[3], 10),
164
+ severity: match[4] as ParsedDiagnostic["severity"],
165
+ source: match[5],
166
+ message: match[6],
167
+ code: match[7],
168
+ };
169
+ }
170
+
171
+ function formatDiagnostics(diag: { errored: boolean; summary: string; messages: string[] }, expanded: boolean): string {
172
+ if (diag.messages.length === 0) return "";
173
+
174
+ // Parse and group diagnostics by file
175
+ const byFile = new Map<string, ParsedDiagnostic[]>();
176
+ const unparsed: string[] = [];
177
+
178
+ for (const msg of diag.messages) {
179
+ const parsed = parseDiagnosticMessage(msg);
180
+ if (parsed) {
181
+ const existing = byFile.get(parsed.filePath) ?? [];
182
+ existing.push(parsed);
183
+ byFile.set(parsed.filePath, existing);
184
+ } else {
185
+ unparsed.push(msg);
186
+ }
187
+ }
188
+
189
+ const headerIcon = diag.errored
190
+ ? theme.styledSymbol("status.error", "error")
191
+ : theme.styledSymbol("status.warning", "warning");
192
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
193
+
194
+ const maxDiags = expanded ? diag.messages.length : 5;
195
+ let shown = 0;
196
+
197
+ // Render grouped diagnostics with file icons
198
+ const files = Array.from(byFile.entries());
199
+ for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
200
+ const [filePath, diagnostics] = files[fi];
201
+ const isLastFile = fi === files.length - 1 && unparsed.length === 0;
202
+ const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
203
+
204
+ // File header with icon
205
+ output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("muted", theme.icon.file)} ${theme.fg("accent", filePath)}`;
206
+ shown++;
207
+
208
+ // Render diagnostics for this file
209
+ for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
210
+ const d = diagnostics[di];
211
+ const isLastDiag = di === diagnostics.length - 1;
212
+ const diagBranch = isLastFile
213
+ ? isLastDiag
214
+ ? ` ${theme.tree.last}`
215
+ : ` ${theme.tree.branch}`
216
+ : isLastDiag
217
+ ? ` ${theme.tree.vertical} ${theme.tree.last}`
218
+ : ` ${theme.tree.vertical} ${theme.tree.branch}`;
219
+
220
+ const sevIcon =
221
+ d.severity === "error"
222
+ ? theme.styledSymbol("status.error", "error")
223
+ : d.severity === "warning"
224
+ ? theme.styledSymbol("status.warning", "warning")
225
+ : theme.styledSymbol("status.info", "muted");
226
+ const location = theme.fg("dim", `:${d.line}:${d.col}`);
227
+ const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
228
+ const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
229
+
230
+ output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
231
+ shown++;
232
+ }
233
+ }
234
+
235
+ // Render unparsed messages (fallback)
236
+ for (const msg of unparsed) {
237
+ if (shown >= maxDiags) break;
238
+ const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
239
+ output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
240
+ shown++;
241
+ }
242
+
243
+ if (diag.messages.length > shown) {
244
+ const remaining = diag.messages.length - shown;
245
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
246
+ }
247
+
248
+ return output;
249
+ }
250
+
251
+ function formatCompactValue(value: unknown, maxLength: number): string {
252
+ let rendered = "";
253
+
254
+ if (value === null) {
255
+ rendered = "null";
256
+ } else if (value === undefined) {
257
+ rendered = "undefined";
258
+ } else if (typeof value === "string") {
259
+ rendered = value;
260
+ } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
261
+ rendered = String(value);
262
+ } else if (Array.isArray(value)) {
263
+ const previewItems = value.slice(0, 3).map((item) => formatCompactValue(item, maxLength));
264
+ rendered = `[${previewItems.join(", ")}${value.length > 3 ? ", ..." : ""}]`;
265
+ } else if (typeof value === "object") {
266
+ try {
267
+ rendered = JSON.stringify(value);
268
+ } catch {
269
+ rendered = "[object]";
270
+ }
271
+ } else if (typeof value === "function") {
272
+ rendered = "[function]";
273
+ } else {
274
+ rendered = String(value);
275
+ }
276
+
277
+ if (rendered.length > maxLength) {
278
+ rendered = `${rendered.slice(0, maxLength - 1)}${theme.format.ellipsis}`;
279
+ }
280
+
281
+ return rendered;
282
+ }
283
+
284
+ function formatArgsPreview(
285
+ args: unknown,
286
+ maxEntries: number,
287
+ maxValueLength: number,
288
+ ): { lines: string[]; remaining: number; total: number } {
289
+ if (args === undefined) {
290
+ return { lines: [theme.fg("dim", "(none)")], remaining: 0, total: 0 };
291
+ }
292
+ if (args === null || typeof args !== "object") {
293
+ const single = theme.fg("toolOutput", formatCompactValue(args, maxValueLength));
294
+ return { lines: [single], remaining: 0, total: 1 };
295
+ }
296
+
297
+ const entries = Object.entries(args as Record<string, unknown>);
298
+ const total = entries.length;
299
+ const visible = entries.slice(0, maxEntries);
300
+ const lines = visible.map(([key, value]) => {
301
+ const keyText = theme.fg("accent", key);
302
+ const valueText = theme.fg("toolOutput", formatCompactValue(value, maxValueLength));
303
+ return `${keyText}: ${valueText}`;
304
+ });
305
+
306
+ return { lines, remaining: Math.max(total - visible.length, 0), total };
307
+ }
25
308
 
26
309
  /**
27
310
  * Convert absolute path to tilde notation if it's in home directory
@@ -63,12 +346,15 @@ export class ToolExecutionComponent extends Container {
63
346
  private cwd: string;
64
347
  private result?: {
65
348
  content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
66
- isError: boolean;
349
+ isError?: boolean;
67
350
  details?: any;
68
351
  };
69
352
  // Cached edit diff preview (computed when args arrive, before tool executes)
70
353
  private editDiffPreview?: EditDiffResult | EditDiffError;
71
354
  private editDiffArgsKey?: string; // Track which args the preview is for
355
+ // Spinner animation for partial task results
356
+ private spinnerFrame = 0;
357
+ private spinnerInterval: ReturnType<typeof setInterval> | null = null;
72
358
 
73
359
  constructor(
74
360
  toolName: string,
@@ -153,15 +439,45 @@ export class ToolExecutionComponent extends Container {
153
439
  result: {
154
440
  content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
155
441
  details?: any;
156
- isError: boolean;
442
+ isError?: boolean;
157
443
  },
158
444
  isPartial = false,
159
445
  ): void {
160
446
  this.result = result;
161
447
  this.isPartial = isPartial;
448
+ this.updateSpinnerAnimation();
162
449
  this.updateDisplay();
163
450
  }
164
451
 
452
+ /**
453
+ * Start or stop spinner animation based on whether this is a partial task result.
454
+ */
455
+ private updateSpinnerAnimation(): void {
456
+ const needsSpinner = this.isPartial && this.toolName === "task";
457
+ if (needsSpinner && !this.spinnerInterval) {
458
+ this.spinnerInterval = setInterval(() => {
459
+ const frameCount = theme.spinnerFrames.length;
460
+ if (frameCount === 0) return;
461
+ this.spinnerFrame = (this.spinnerFrame + 1) % frameCount;
462
+ this.updateDisplay();
463
+ this.ui.requestRender();
464
+ }, 80);
465
+ } else if (!needsSpinner && this.spinnerInterval) {
466
+ clearInterval(this.spinnerInterval);
467
+ this.spinnerInterval = null;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Stop spinner animation and cleanup resources.
473
+ */
474
+ stopAnimation(): void {
475
+ if (this.spinnerInterval) {
476
+ clearInterval(this.spinnerInterval);
477
+ this.spinnerInterval = null;
478
+ }
479
+ }
480
+
165
481
  setExpanded(expanded: boolean): void {
166
482
  this.expanded = expanded;
167
483
  this.updateDisplay();
@@ -207,7 +523,7 @@ export class ToolExecutionComponent extends Container {
207
523
  try {
208
524
  const resultComponent = this.customTool.renderResult(
209
525
  { content: this.result.content as any, details: this.result.details },
210
- { expanded: this.expanded, isPartial: this.isPartial },
526
+ { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
211
527
  theme,
212
528
  );
213
529
  if (resultComponent) {
@@ -254,7 +570,7 @@ export class ToolExecutionComponent extends Container {
254
570
  try {
255
571
  const resultComponent = renderer.renderResult(
256
572
  { content: this.result.content as any, details: this.result.details },
257
- { expanded: this.expanded, isPartial: this.isPartial },
573
+ { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
258
574
  theme,
259
575
  );
260
576
  if (resultComponent) {
@@ -314,7 +630,11 @@ export class ToolExecutionComponent extends Container {
314
630
 
315
631
  // Header
316
632
  this.contentBox.addChild(
317
- new Text(theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)), 0, 0),
633
+ new Text(
634
+ theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", theme.format.ellipsis)}`)),
635
+ 0,
636
+ 0,
637
+ ),
318
638
  );
319
639
 
320
640
  if (this.result) {
@@ -339,9 +659,17 @@ export class ToolExecutionComponent extends Container {
339
659
  this.ui.terminal.columns - 2,
340
660
  );
341
661
 
662
+ const totalVisualLines = skippedCount + visualLines.length;
342
663
  if (skippedCount > 0) {
343
664
  this.contentBox.addChild(
344
- new Text(theme.fg("toolOutput", `\n... (${skippedCount} earlier lines)`), 0, 0),
665
+ new Text(
666
+ theme.fg(
667
+ "dim",
668
+ `\n${theme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
669
+ ),
670
+ 0,
671
+ 0,
672
+ ),
345
673
  );
346
674
  }
347
675
 
@@ -372,7 +700,7 @@ export class ToolExecutionComponent extends Container {
372
700
  );
373
701
  }
374
702
  }
375
- this.contentBox.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
703
+ this.contentBox.addChild(new Text(`\n${theme.fg("warning", wrapBrackets(warnings.join(". ")))}`, 0, 0));
376
704
  }
377
705
  }
378
706
  }
@@ -408,11 +736,13 @@ export class ToolExecutionComponent extends Container {
408
736
  let text = "";
409
737
 
410
738
  if (this.toolName === "read") {
411
- const path = shortenPath(this.args?.file_path || this.args?.path || "");
739
+ const rawPath = this.args?.file_path || this.args?.path || "";
740
+ const path = shortenPath(rawPath);
412
741
  const offset = this.args?.offset;
413
742
  const limit = this.args?.limit;
743
+ const fileType = getFileType(rawPath);
414
744
 
415
- let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
745
+ let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
416
746
  if (offset !== undefined || limit !== undefined) {
417
747
  const startLine = offset ?? 1;
418
748
  const endLine = limit !== undefined ? startLine + limit - 1 : "";
@@ -423,50 +753,46 @@ export class ToolExecutionComponent extends Container {
423
753
 
424
754
  if (this.result) {
425
755
  const output = this.getTextOutput();
426
- const rawPath = this.args?.file_path || this.args?.path || "";
427
- const lang = getLanguageFromPath(rawPath);
428
- const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
429
756
 
430
- const maxLines = this.expanded ? lines.length : 10;
431
- const displayLines = lines.slice(0, maxLines);
432
- const remaining = lines.length - maxLines;
433
-
434
- text +=
435
- "\n\n" +
436
- displayLines
437
- .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
438
- .join("\n");
439
- if (remaining > 0) {
440
- text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
441
- }
757
+ if (fileType === "image") {
758
+ // Image file - use image icon
759
+ const ext = rawPath.split(".").pop()?.toLowerCase() ?? "image";
760
+ text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
761
+ // Images are rendered by the image component, just show hint
762
+ text += `\n${theme.fg("muted", "Image rendered below")}`;
763
+ } else if (fileType === "binary") {
764
+ // Binary file - use binary/pdf/archive icon based on extension
765
+ const ext = rawPath.split(".").pop()?.toLowerCase() ?? "binary";
766
+ text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
767
+ } else {
768
+ // Text file - show line count and language on same line
769
+ const lang = getLanguageFromPath(rawPath);
770
+ const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
771
+ text += `${theme.sep.dot}${formatMetadataLine(null, lang)}`;
442
772
 
443
- const truncation = this.result.details?.truncation;
444
- if (truncation?.truncated) {
445
- if (truncation.firstLineExceedsLimit) {
773
+ // Content is hidden by default, only shown when expanded
774
+ if (this.expanded) {
446
775
  text +=
447
- "\n" +
448
- theme.fg(
449
- "warning",
450
- `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`,
451
- );
452
- } else if (truncation.truncatedBy === "lines") {
453
- text +=
454
- "\n" +
455
- theme.fg(
456
- "warning",
457
- `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${
458
- truncation.maxLines ?? DEFAULT_MAX_LINES
459
- } line limit)]`,
460
- );
776
+ "\n\n" +
777
+ lines
778
+ .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
779
+ .join("\n");
461
780
  } else {
462
- text +=
463
- "\n" +
464
- theme.fg(
465
- "warning",
466
- `[Truncated: ${truncation.outputLines} lines shown (${formatSize(
467
- truncation.maxBytes ?? DEFAULT_MAX_BYTES,
468
- )} limit)]`,
469
- );
781
+ text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to show content`)}`;
782
+ }
783
+
784
+ // Truncation warning
785
+ const truncation = this.result.details?.truncation;
786
+ if (truncation?.truncated) {
787
+ let warning: string;
788
+ if (truncation.firstLineExceedsLimit) {
789
+ warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
790
+ } else if (truncation.truncatedBy === "lines") {
791
+ warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
792
+ } else {
793
+ warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
794
+ }
795
+ text += `\n${theme.fg("warning", wrapBrackets(warning))}`;
470
796
  }
471
797
  }
472
798
  }
@@ -485,7 +811,9 @@ export class ToolExecutionComponent extends Container {
485
811
  text =
486
812
  theme.fg("toolTitle", theme.bold("write")) +
487
813
  " " +
488
- (path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
814
+ (path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis));
815
+
816
+ text += `\n${formatMetadataLine(countLines(fileContent), lang ?? "text")}`;
489
817
 
490
818
  if (fileContent) {
491
819
  const maxLines = this.expanded ? lines.length : 10;
@@ -498,33 +826,23 @@ export class ToolExecutionComponent extends Container {
498
826
  .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
499
827
  .join("\n");
500
828
  if (remaining > 0) {
501
- text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`);
829
+ text += theme.fg(
830
+ "toolOutput",
831
+ `\n${theme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${wrapBrackets("Ctrl+O to expand")}`,
832
+ );
502
833
  }
503
834
  }
504
835
 
505
836
  // Show LSP diagnostics if available
506
837
  if (this.result?.details?.diagnostics) {
507
- const diag = this.result.details.diagnostics;
508
- if (diag.messages.length > 0) {
509
- const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
510
- text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
511
- const maxDiags = this.expanded ? diag.messages.length : 5;
512
- const displayDiags = diag.messages.slice(0, maxDiags);
513
- for (const d of displayDiags) {
514
- const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
515
- text += `\n ${theme.fg(color, d)}`;
516
- }
517
- if (diag.messages.length > maxDiags) {
518
- text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
519
- }
520
- }
838
+ text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
521
839
  }
522
840
  } else if (this.toolName === "edit") {
523
841
  const rawPath = this.args?.file_path || this.args?.path || "";
524
842
  const path = shortenPath(rawPath);
525
843
 
526
844
  // Build path display, appending :line if we have diff info
527
- let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
845
+ let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
528
846
  const firstChangedLine =
529
847
  (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
530
848
  ? this.editDiffPreview.firstChangedLine
@@ -536,6 +854,10 @@ export class ToolExecutionComponent extends Container {
536
854
 
537
855
  text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
538
856
 
857
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
858
+ const editLineCount = countLines(this.args?.newText ?? this.args?.oldText ?? "");
859
+ text += `\n${formatMetadataLine(editLineCount, editLanguage)}`;
860
+
539
861
  if (this.result?.isError) {
540
862
  // Show error from result
541
863
  const errorText = this.getTextOutput();
@@ -547,162 +869,75 @@ export class ToolExecutionComponent extends Container {
547
869
  if ("error" in this.editDiffPreview) {
548
870
  text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
549
871
  } else if (this.editDiffPreview.diff) {
550
- text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath })}`;
872
+ const diffStats = getDiffStats(this.editDiffPreview.diff);
873
+ text += `\n${theme.fg("dim", theme.format.bracketLeft)}${formatDiffStats(diffStats.added, diffStats.removed, diffStats.hunks)}${theme.fg("dim", theme.format.bracketRight)}`;
874
+
875
+ const {
876
+ text: diffText,
877
+ hiddenHunks,
878
+ hiddenLines,
879
+ } = this.expanded
880
+ ? { text: this.editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
881
+ : truncateDiffByHunk(this.editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
882
+
883
+ text += `\n\n${renderDiff(diffText, { filePath: rawPath })}`;
884
+ if (!this.expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
885
+ const remainder: string[] = [];
886
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
887
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
888
+ text += theme.fg(
889
+ "toolOutput",
890
+ `\n${theme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand")}`,
891
+ );
892
+ }
551
893
  }
552
894
  }
553
895
 
554
896
  // Show LSP diagnostics if available
555
897
  if (this.result?.details?.diagnostics) {
556
- const diag = this.result.details.diagnostics;
557
- if (diag.messages.length > 0) {
558
- const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
559
- text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
560
- const maxDiags = this.expanded ? diag.messages.length : 5;
561
- const displayDiags = diag.messages.slice(0, maxDiags);
562
- for (const d of displayDiags) {
563
- const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
564
- text += `\n ${theme.fg(color, d)}`;
565
- }
566
- if (diag.messages.length > maxDiags) {
567
- text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
568
- }
569
- }
570
- }
571
- } else if (this.toolName === "ls") {
572
- const path = shortenPath(this.args?.path || ".");
573
- const limit = this.args?.limit;
574
-
575
- text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`;
576
- if (limit !== undefined) {
577
- text += theme.fg("toolOutput", ` (limit ${limit})`);
578
- }
579
-
580
- if (this.result) {
581
- const output = this.getTextOutput().trim();
582
- if (output) {
583
- const lines = output.split("\n");
584
- const maxLines = this.expanded ? lines.length : 20;
585
- const displayLines = lines.slice(0, maxLines);
586
- const remaining = lines.length - maxLines;
587
-
588
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
589
- if (remaining > 0) {
590
- text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
591
- }
592
- }
593
-
594
- const entryLimit = this.result.details?.entryLimitReached;
595
- const truncation = this.result.details?.truncation;
596
- if (entryLimit || truncation?.truncated) {
597
- const warnings: string[] = [];
598
- if (entryLimit) {
599
- warnings.push(`${entryLimit} entries limit`);
600
- }
601
- if (truncation?.truncated) {
602
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
603
- }
604
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
605
- }
606
- }
607
- } else if (this.toolName === "find") {
608
- const pattern = this.args?.pattern || "";
609
- const path = shortenPath(this.args?.path || ".");
610
- const limit = this.args?.limit;
611
-
612
- text =
613
- theme.fg("toolTitle", theme.bold("find")) +
614
- " " +
615
- theme.fg("accent", pattern) +
616
- theme.fg("toolOutput", ` in ${path}`);
617
- if (limit !== undefined) {
618
- text += theme.fg("toolOutput", ` (limit ${limit})`);
619
- }
620
-
621
- if (this.result) {
622
- const output = this.getTextOutput().trim();
623
- if (output) {
624
- const lines = output.split("\n");
625
- const maxLines = this.expanded ? lines.length : 20;
626
- const displayLines = lines.slice(0, maxLines);
627
- const remaining = lines.length - maxLines;
628
-
629
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
630
- if (remaining > 0) {
631
- text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
632
- }
633
- }
634
-
635
- const resultLimit = this.result.details?.resultLimitReached;
636
- const truncation = this.result.details?.truncation;
637
- if (resultLimit || truncation?.truncated) {
638
- const warnings: string[] = [];
639
- if (resultLimit) {
640
- warnings.push(`${resultLimit} results limit`);
641
- }
642
- if (truncation?.truncated) {
643
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
644
- }
645
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
646
- }
647
- }
648
- } else if (this.toolName === "grep") {
649
- const pattern = this.args?.pattern || "";
650
- const path = shortenPath(this.args?.path || ".");
651
- const glob = this.args?.glob;
652
- const limit = this.args?.limit;
653
-
654
- text =
655
- theme.fg("toolTitle", theme.bold("grep")) +
656
- " " +
657
- theme.fg("accent", `/${pattern}/`) +
658
- theme.fg("toolOutput", ` in ${path}`);
659
- if (glob) {
660
- text += theme.fg("toolOutput", ` (${glob})`);
661
- }
662
- if (limit !== undefined) {
663
- text += theme.fg("toolOutput", ` limit ${limit}`);
664
- }
665
-
666
- if (this.result) {
667
- const output = this.getTextOutput().trim();
668
- if (output) {
669
- const lines = output.split("\n");
670
- const maxLines = this.expanded ? lines.length : 15;
671
- const displayLines = lines.slice(0, maxLines);
672
- const remaining = lines.length - maxLines;
673
-
674
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
675
- if (remaining > 0) {
676
- text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
677
- }
678
- }
679
-
680
- const matchLimit = this.result.details?.matchLimitReached;
681
- const truncation = this.result.details?.truncation;
682
- const linesTruncated = this.result.details?.linesTruncated;
683
- if (matchLimit || truncation?.truncated || linesTruncated) {
684
- const warnings: string[] = [];
685
- if (matchLimit) {
686
- warnings.push(`${matchLimit} matches limit`);
687
- }
688
- if (truncation?.truncated) {
689
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
690
- }
691
- if (linesTruncated) {
692
- warnings.push("some lines truncated");
693
- }
694
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
695
- }
898
+ text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
696
899
  }
697
900
  } else {
698
901
  // Generic tool (shouldn't reach here for custom tools)
699
902
  text = theme.fg("toolTitle", theme.bold(this.toolName));
700
903
 
701
- const content = JSON.stringify(this.args, null, 2);
702
- text += `\n\n${content}`;
703
- const output = this.getTextOutput();
904
+ const argTotal =
905
+ this.args && typeof this.args === "object"
906
+ ? Object.keys(this.args as Record<string, unknown>).length
907
+ : this.args === undefined
908
+ ? 0
909
+ : 1;
910
+ const argPreviewLimit = this.expanded ? argTotal : GENERIC_ARG_PREVIEW;
911
+ const valueLimit = this.expanded ? 2000 : GENERIC_VALUE_MAX;
912
+ const argsPreview = formatArgsPreview(this.args, argPreviewLimit, valueLimit);
913
+
914
+ text += `\n\n${theme.fg("toolTitle", "Args")} ${theme.fg("dim", `(${argsPreview.total})`)}`;
915
+ if (argsPreview.lines.length > 0) {
916
+ text += `\n${argsPreview.lines.join("\n")}`;
917
+ } else {
918
+ text += `\n${theme.fg("dim", "(none)")}`;
919
+ }
920
+ if (argsPreview.remaining > 0) {
921
+ text += theme.fg(
922
+ "dim",
923
+ `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`,
924
+ );
925
+ }
926
+
927
+ const output = this.getTextOutput().trim();
928
+ text += `\n\n${theme.fg("toolTitle", "Output")}`;
704
929
  if (output) {
705
- text += `\n${output}`;
930
+ const lines = output.split("\n");
931
+ const maxLines = this.expanded ? lines.length : GENERIC_PREVIEW_LINES;
932
+ const displayLines = lines.slice(-maxLines);
933
+ const remaining = lines.length - displayLines.length;
934
+ text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`;
935
+ text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
936
+ if (remaining > 0) {
937
+ text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`);
938
+ }
939
+ } else {
940
+ text += ` ${theme.fg("dim", "(empty)")}`;
706
941
  }
707
942
  }
708
943