lsd-pi 1.3.6 → 1.3.7

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 (83) hide show
  1. package/dist/cli.js +2 -1
  2. package/dist/lsd-settings-manager.d.ts +2 -0
  3. package/dist/lsd-settings-manager.js +5 -0
  4. package/dist/resource-loader.js +33 -3
  5. package/dist/resources/extensions/cache-timer/index.js +3 -2
  6. package/dist/welcome-screen.js +2 -2
  7. package/package.json +1 -1
  8. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts +2 -0
  9. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts.map +1 -0
  10. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js +35 -0
  11. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js.map +1 -0
  12. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  13. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  14. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  15. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  16. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +5 -0
  17. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  18. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +21 -0
  19. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +16 -1
  21. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -1
  22. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +12 -4
  23. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts +7 -5
  25. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js +86 -28
  27. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts +2 -0
  29. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +16 -10
  31. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  32. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  33. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js +26 -4
  35. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js.map +1 -1
  36. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +14 -1
  37. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  38. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +111 -12
  39. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +1 -0
  41. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +47 -3
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  44. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +137 -6
  45. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +40 -14
  48. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +5 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +70 -25
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js +4 -4
  59. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js.map +1 -1
  60. package/packages/pi-coding-agent/package.json +1 -1
  61. package/packages/pi-coding-agent/src/core/settings-manager.collapse-tool-calls.test.ts +46 -0
  62. package/packages/pi-coding-agent/src/core/settings-manager.ts +18 -0
  63. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +20 -0
  64. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +26 -0
  65. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +14 -4
  66. package/packages/pi-coding-agent/src/modes/interactive/components/diff.ts +105 -28
  67. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +13 -6
  68. package/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +31 -4
  69. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +119 -13
  70. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +59 -3
  71. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +174 -6
  72. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +50 -14
  73. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -1
  74. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  75. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +73 -25
  76. package/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts +4 -4
  77. package/packages/pi-tui/dist/components/editor.js +3 -3
  78. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  79. package/packages/pi-tui/src/components/editor.ts +3 -3
  80. package/pkg/dist/modes/interactive/theme/themes.js +4 -4
  81. package/pkg/dist/modes/interactive/theme/themes.js.map +1 -1
  82. package/pkg/package.json +1 -1
  83. package/src/resources/extensions/cache-timer/index.ts +3 -2
@@ -11,7 +11,12 @@ import {
11
11
  } from "@gsd/pi-tui";
12
12
  import stripAnsi from "strip-ansi";
13
13
  import type { ToolDefinition } from "../../../core/extensions/types.js";
14
- import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
14
+ import {
15
+ computeEditDiff,
16
+ computeWriteDiff,
17
+ type EditDiffError,
18
+ type EditDiffResult,
19
+ } from "../../../core/tools/edit-diff.js";
15
20
  import { allTools } from "../../../core/tools/index.js";
16
21
  import { shouldCollapse } from "../../../core/tool-priority.js";
17
22
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
@@ -21,7 +26,7 @@ import { renderTerminalText } from "../../../utils/terminal-serializer.js";
21
26
  import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
22
27
  import { type EditorScheme, editorLink } from "../utils/editor-link.js";
23
28
  import { shortenPath } from "../utils/shorten-path.js";
24
- import { renderDiff } from "./diff.js";
29
+ import { renderDiff, renderDiffLines } from "./diff.js";
25
30
  import { keyHint } from "./keybinding-hints.js";
26
31
  import { truncateToVisualLines } from "./visual-truncate.js";
27
32
 
@@ -119,6 +124,9 @@ export class ToolExecutionComponent extends Container {
119
124
  // Cached edit diff preview (computed when args arrive, before tool executes)
120
125
  private editDiffPreview?: EditDiffResult | EditDiffError;
121
126
  private editDiffArgsKey?: string; // Track which args the preview is for
127
+ // Cached write diff preview (computed when args arrive, before tool executes)
128
+ private writeDiffPreview?: EditDiffResult | EditDiffError;
129
+ private writeDiffArgsKey?: string; // Track which args the preview is for
122
130
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
123
131
  private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
124
132
  // Incremental syntax highlighting cache for write tool call args
@@ -176,6 +184,40 @@ export class ToolExecutionComponent extends Container {
176
184
  return isBuiltInName && !hasCustomRenderers;
177
185
  }
178
186
 
187
+ private setPrimaryContent(useBox: boolean): void {
188
+ const hasBox = this.children.includes(this.contentBox);
189
+ const hasText = this.children.includes(this.contentText);
190
+
191
+ if (useBox) {
192
+ if (hasText) this.removeChild(this.contentText);
193
+ if (!hasBox) this.addChild(this.contentBox);
194
+ } else {
195
+ if (hasBox) this.removeChild(this.contentBox);
196
+ if (!hasText) this.addChild(this.contentText);
197
+ }
198
+ }
199
+
200
+ private getDiffTextToRender(): string | undefined {
201
+ if (this.toolName === "write") {
202
+ if (!this.isPartial && this.writeDiffPreview && !("error" in this.writeDiffPreview) && this.writeDiffPreview.diff) {
203
+ return this.writeDiffPreview.diff;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ if (this.toolName !== "edit" || this.result?.isError) {
209
+ return undefined;
210
+ }
211
+
212
+ if (this.result?.details?.diff) {
213
+ return this.result.details.diff;
214
+ }
215
+ if (this.editDiffPreview && !("error" in this.editDiffPreview) && this.editDiffPreview.diff) {
216
+ return this.editDiffPreview.diff;
217
+ }
218
+ return undefined;
219
+ }
220
+
179
221
  updateArgs(args: any): void {
180
222
  this.args = args;
181
223
  if (this.toolName === "write" && this.isPartial) {
@@ -287,10 +329,37 @@ export class ToolExecutionComponent extends Container {
287
329
  if (rawPath !== null && fileContent !== null) {
288
330
  this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
289
331
  }
332
+ this.maybeComputeWriteDiff();
290
333
  }
291
334
  this.maybeComputeEditDiff();
292
335
  }
293
336
 
337
+ /**
338
+ * Compute write diff preview when we have complete args.
339
+ * This runs async and updates display when done.
340
+ */
341
+ private maybeComputeWriteDiff(): void {
342
+ if (this.toolName !== "write") return;
343
+
344
+ const path = this.args?.path ?? this.args?.file_path;
345
+ const content = this.args?.content;
346
+
347
+ if (!path || content === undefined) return;
348
+
349
+ const argsKey = JSON.stringify({ path, content });
350
+ if (this.writeDiffArgsKey === argsKey) return;
351
+
352
+ this.writeDiffArgsKey = argsKey;
353
+
354
+ computeWriteDiff(path, content, this.cwd).then((result) => {
355
+ if (this.writeDiffArgsKey === argsKey) {
356
+ this.writeDiffPreview = result;
357
+ this.updateDisplay();
358
+ this.ui.requestRender();
359
+ }
360
+ });
361
+ }
362
+
294
363
  /**
295
364
  * Compute edit diff preview when we have complete args.
296
365
  * This runs async and updates display when done.
@@ -396,8 +465,8 @@ export class ToolExecutionComponent extends Container {
396
465
  return Date.now() - this.startTime;
397
466
  }
398
467
 
399
- shouldHideWhenCollapsed(): boolean {
400
- return !this.isPartial && shouldCollapse(this.toolName, this.result?.isError ?? false);
468
+ shouldHideWhenCollapsed(collapseToolCalls = true): boolean {
469
+ return collapseToolCalls && !this.isPartial && shouldCollapse(this.toolName, this.result?.isError ?? false);
401
470
  }
402
471
 
403
472
  setRenderMode(mode: "minimal" | "normal"): void {
@@ -469,15 +538,22 @@ export class ToolExecutionComponent extends Container {
469
538
  }
470
539
 
471
540
  const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
541
+ const diffTextToRender = this.getDiffTextToRender();
472
542
  let customRendererHasContent = false;
473
543
 
474
544
  // Use built-in rendering for built-in tools (or overrides without custom renderers)
475
545
  if (useBuiltInRenderer) {
546
+ const useDiffBox = !!diffTextToRender && !this.shouldHideCollapsedPreview();
547
+ this.setPrimaryContent(this.toolName === "bash" || useDiffBox);
476
548
  if (this.toolName === "bash") {
477
549
  // Bash uses Box with visual line truncation - no background
478
550
  this.contentBox.setBgFn((text: string) => text);
479
551
  this.contentBox.clear();
480
552
  this.renderBashContent(statusIndicator);
553
+ } else if (useDiffBox && diffTextToRender) {
554
+ this.contentBox.setBgFn((text: string) => text);
555
+ this.contentBox.clear();
556
+ this.renderBuiltInDiffContent(statusIndicator, diffTextToRender);
481
557
  } else {
482
558
  // Other built-in tools: use Text directly with caching - no background
483
559
  this.contentText.setCustomBgFn((text: string) => text);
@@ -619,6 +695,21 @@ export class ToolExecutionComponent extends Container {
619
695
  this.hideComponent = this.manuallyHidden || computedHidden;
620
696
  }
621
697
 
698
+ /**
699
+ * Render built-in edit/write diff blocks with width-aware full-line backgrounds.
700
+ */
701
+ private renderBuiltInDiffContent(statusIndicator: string, diffText: string): void {
702
+ const header = this.formatToolExecution(statusIndicator).split("\n\n", 1)[0] ?? "";
703
+ this.contentBox.addChild(new Text(header, 0, 0));
704
+ this.contentBox.addChild({
705
+ render: (width: number) => {
706
+ const contentWidth = Math.max(1, width - 2);
707
+ return ["", ...renderDiffLines(diffText, contentWidth).map((line) => ` ${line} `)];
708
+ },
709
+ invalidate: () => { },
710
+ });
711
+ }
712
+
622
713
  /**
623
714
  * Render bash content using visual line truncation (like bash-execution.ts)
624
715
  */
@@ -874,15 +965,38 @@ export class ToolExecutionComponent extends Container {
874
965
  const rawPath = str(this.args?.file_path ?? this.args?.path);
875
966
  const fileContent = str(this.args?.content);
876
967
  const path = rawPath !== null ? shortenPath(rawPath) : null;
968
+ const firstChangedLine = this.writeDiffPreview && "firstChangedLine" in this.writeDiffPreview
969
+ ? this.writeDiffPreview.firstChangedLine
970
+ : undefined;
877
971
 
878
972
  let writePathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
879
973
  if (rawPath && path) {
880
- writePathDisplay = editorLink(rawPath, writePathDisplay, { cwd: this.cwd, scheme: this.editorScheme });
974
+ writePathDisplay = editorLink(rawPath, writePathDisplay, {
975
+ cwd: this.cwd,
976
+ line: firstChangedLine ?? undefined,
977
+ scheme: this.editorScheme,
978
+ });
979
+ }
980
+ if (firstChangedLine) {
981
+ writePathDisplay += theme.fg("warning", `:${firstChangedLine}`);
881
982
  }
882
983
  text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("write"))} ${writePathDisplay}`;
883
984
 
884
985
  if (fileContent === null) {
885
986
  text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
987
+ } else if (this.result?.isError) {
988
+ const errorText = this.getTextOutput();
989
+ if (errorText) {
990
+ text += `\n\n${theme.fg("error", errorText)}`;
991
+ }
992
+ } else if (!this.isPartial && this.writeDiffPreview) {
993
+ if ("error" in this.writeDiffPreview) {
994
+ text += `\n\n${theme.fg("error", this.writeDiffPreview.error)}`;
995
+ } else if (this.writeDiffPreview.diff) {
996
+ text += hideCollapsedPreview
997
+ ? this.collapsedHintWithPrefix()
998
+ : `\n\n${renderDiff(this.writeDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
999
+ }
886
1000
  } else if (fileContent) {
887
1001
  const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
888
1002
 
@@ -924,14 +1038,6 @@ export class ToolExecutionComponent extends Container {
924
1038
  }
925
1039
  }
926
1040
  }
927
-
928
- // Show error if tool execution failed
929
- if (this.result?.isError) {
930
- const errorText = this.getTextOutput();
931
- if (errorText) {
932
- text += `\n\n${theme.fg("error", errorText)}`;
933
- }
934
- }
935
1041
  } else if (this.toolName === "edit") {
936
1042
  const rawPath = str(this.args?.file_path ?? this.args?.path);
937
1043
  const path = rawPath !== null ? shortenPath(rawPath) : null;
@@ -7,11 +7,68 @@ interface CollapsedTool {
7
7
  elapsed: number;
8
8
  }
9
9
 
10
+ // Tools that can be mixed together in one summary line
11
+ const MIXED_GROUPABLE_TOOLS = new Set([
12
+ "read", "find", "ls", "grep", "lsp",
13
+ ]);
14
+
15
+ type SummaryDescriptor = {
16
+ action: string;
17
+ singular: string;
18
+ plural: string;
19
+ };
20
+
21
+ const TOOL_SUMMARY_DESCRIPTORS: Record<string, SummaryDescriptor> = {
22
+ read: { action: "reading", singular: "file", plural: "files" },
23
+ write: { action: "editing", singular: "file", plural: "files" },
24
+ edit: { action: "editing", singular: "file", plural: "files" },
25
+ grep: { action: "searching for", singular: "pattern", plural: "patterns" },
26
+ find: { action: "finding", singular: "path", plural: "paths" },
27
+ ls: { action: "listing", singular: "directory", plural: "directories" },
28
+ lsp: { action: "looking up", singular: "symbol", plural: "symbols" },
29
+ bash: { action: "running", singular: "command", plural: "commands" },
30
+ bg_shell: { action: "running", singular: "background command", plural: "background commands" },
31
+ fetch_page: { action: "reading", singular: "page", plural: "pages" },
32
+ resolve_library: { action: "searching for", singular: "library", plural: "libraries" },
33
+ get_library_docs: { action: "reading", singular: "doc", plural: "docs" },
34
+ web_search: { action: "searching web for", singular: "query", plural: "queries" },
35
+ "search-the-web": { action: "searching web for", singular: "query", plural: "queries" },
36
+ search_and_read: { action: "researching", singular: "topic", plural: "topics" },
37
+ google_search: { action: "searching web for", singular: "query", plural: "queries" },
38
+ };
39
+
40
+ function formatCount(count: number, singular: string, plural: string): string {
41
+ return `${count} ${count === 1 ? singular : plural}`;
42
+ }
43
+
44
+ function summarizeToolGroup(name: string, count: number): string {
45
+ if (name.startsWith("browser_")) {
46
+ return `using browser for ${formatCount(count, "step", "steps")}`;
47
+ }
48
+
49
+ const descriptor = TOOL_SUMMARY_DESCRIPTORS[name];
50
+ if (!descriptor) {
51
+ return count > 1 ? `${name} ×${count}` : name;
52
+ }
53
+
54
+ return `${descriptor.action} ${formatCount(count, descriptor.singular, descriptor.plural)}`;
55
+ }
56
+
10
57
  export class ToolSummaryLine extends Container {
11
58
  private tools: CollapsedTool[] = [];
12
59
  private hidden = false;
13
60
  private contentText: Text;
14
61
 
62
+ canGroupWith(toolName: string): boolean {
63
+ if (this.tools.length === 0) return true;
64
+ // Mixed-groupable tools can share a summary line regardless of order
65
+ if (MIXED_GROUPABLE_TOOLS.has(toolName) && this.tools.every((t) => MIXED_GROUPABLE_TOOLS.has(t.name))) {
66
+ return true;
67
+ }
68
+ // Otherwise only same-tool grouping
69
+ return this.tools.every((tool) => tool.name === toolName);
70
+ }
71
+
15
72
  constructor() {
16
73
  super();
17
74
  this.contentText = new Text("", 1, 0);
@@ -53,12 +110,11 @@ export class ToolSummaryLine extends Container {
53
110
  }
54
111
 
55
112
  const groupedTools = [...counts.entries()]
56
- .map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
113
+ .map(([name, count]) => summarizeToolGroup(name, count))
57
114
  .join(" · ");
58
115
  const elapsed = (totalElapsed / 1000).toFixed(1);
59
116
  const indicator = theme.fg("success", "●");
60
- const title = theme.fg("toolTitle", theme.bold("collapsed tools"));
61
117
  const details = theme.fg("muted", `${groupedTools} · ${elapsed}s`);
62
- this.contentText.setText(`${indicator} ${title} ${details}`);
118
+ this.contentText.setText(`${indicator} ${details}`);
63
119
  }
64
120
  }
@@ -16,6 +16,15 @@ function assistantMessage(content: any[] = []): any {
16
16
  };
17
17
  }
18
18
 
19
+ function toolStartEvent(toolCallId: string, toolName: string, args: Record<string, unknown> = {}): any {
20
+ return {
21
+ type: "tool_execution_start",
22
+ toolCallId,
23
+ toolName,
24
+ args,
25
+ };
26
+ }
27
+
19
28
  function toolEndEvent(toolCallId: string, toolName: string): any {
20
29
  return {
21
30
  type: "tool_execution_end",
@@ -44,7 +53,7 @@ function createChatContainer() {
44
53
  };
45
54
  }
46
55
 
47
- function createHost(): any {
56
+ function createHost(options: { collapseToolCalls?: boolean; collapsedToolCallsExpanded?: boolean; toolOutputExpanded?: boolean } = {}): any {
48
57
  const chatContainer = createChatContainer();
49
58
  return {
50
59
  isInitialized: true,
@@ -61,10 +70,12 @@ function createHost(): any {
61
70
  getShowImages: () => true,
62
71
  getToolOutputMode: () => "normal",
63
72
  getEditorScheme: () => "auto",
73
+ getCollapseToolCalls: () => options.collapseToolCalls ?? true,
64
74
  },
65
75
  pendingTools: new Map(),
66
76
  collapsedToolSummaryLine: undefined,
67
- toolOutputExpanded: false,
77
+ collapsedToolCallsExpanded: options.collapsedToolCallsExpanded ?? options.toolOutputExpanded ?? false,
78
+ toolOutputExpanded: options.toolOutputExpanded ?? false,
68
79
  hideThinkingBlock: false,
69
80
  notificationSoundEnabled: false,
70
81
  defaultEditor: { onEscape: undefined, bottomHint: "" },
@@ -124,7 +135,75 @@ function summaryLines(host: any): ToolSummaryLine[] {
124
135
  }
125
136
 
126
137
  describe("chat-controller collapsed tool summary lifecycle", () => {
127
- it("keeps single grouped summary across text-only message updates", async () => {
138
+ it("hides collapsible tool calls immediately to avoid blink before grouping", async () => {
139
+ const host = createHost();
140
+
141
+ await handleAgentEvent(host, {
142
+ type: "message_start",
143
+ message: assistantMessage(),
144
+ });
145
+
146
+ await handleAgentEvent(host, toolStartEvent("tool-1", "read", { path: "README.md" }));
147
+ const pending = host.pendingTools.get("tool-1");
148
+ assert.ok(pending);
149
+ assert.equal(pending.isHidden(), true);
150
+
151
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
152
+ assert.equal(summaryLines(host).length, 1);
153
+ });
154
+
155
+ it("hides streamed tool-call blocks immediately to avoid blink before grouping", async () => {
156
+ const host = createHost();
157
+
158
+ await handleAgentEvent(host, {
159
+ type: "message_start",
160
+ message: assistantMessage(),
161
+ });
162
+
163
+ await handleAgentEvent(host, {
164
+ type: "message_update",
165
+ message: assistantMessage([
166
+ { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "README.md" } },
167
+ ]),
168
+ } as any);
169
+
170
+ const pending = host.pendingTools.get("tool-1");
171
+ assert.ok(pending);
172
+ assert.equal(pending.isHidden(), true);
173
+
174
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
175
+ assert.equal(summaryLines(host).length, 1);
176
+ });
177
+
178
+ it("reveals initially hidden on-error tools when they fail", async () => {
179
+ const host = createHost();
180
+
181
+ await handleAgentEvent(host, {
182
+ type: "message_start",
183
+ message: assistantMessage(),
184
+ });
185
+
186
+ await handleAgentEvent(host, toolStartEvent("tool-1", "bash", { command: "exit 1" }));
187
+ const pending = host.pendingTools.get("tool-1");
188
+ assert.ok(pending);
189
+ assert.equal(pending.isHidden(), true);
190
+
191
+ await handleAgentEvent(host, {
192
+ type: "tool_execution_end",
193
+ toolCallId: "tool-1",
194
+ toolName: "bash",
195
+ isError: true,
196
+ result: {
197
+ content: [{ type: "text", text: "failed" }],
198
+ details: {},
199
+ },
200
+ });
201
+
202
+ assert.equal(summaryLines(host).length, 0);
203
+ assert.equal(pending.isHidden(), false);
204
+ });
205
+
206
+ it("groups consecutive identical collapsed tools", async () => {
128
207
  const host = createHost();
129
208
 
130
209
  await handleAgentEvent(host, {
@@ -146,7 +225,47 @@ describe("chat-controller collapsed tool summary lifecycle", () => {
146
225
 
147
226
  const summaries = summaryLines(host);
148
227
  assert.equal(summaries.length, 1);
149
- assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("read ×2 · 1.0s"));
228
+ assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("reading 2 files · 1.0s"));
229
+ });
230
+
231
+ it("does not group web fetches", async () => {
232
+ const host = createHost();
233
+
234
+ await handleAgentEvent(host, {
235
+ type: "message_start",
236
+ message: assistantMessage(),
237
+ });
238
+
239
+ addPendingTool(host, "tool-1", 200);
240
+ await handleAgentEvent(host, toolEndEvent("tool-1", "fetch_page"));
241
+
242
+ addPendingTool(host, "tool-2", 300);
243
+ await handleAgentEvent(host, toolEndEvent("tool-2", "fetch_page"));
244
+
245
+ const summaries = summaryLines(host);
246
+ assert.equal(summaries.length, 2);
247
+ assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("reading 1 page · 0.2s"));
248
+ assert.ok(stripAnsi(summaries[1].render(160).join("\n")).includes("reading 1 page · 0.3s"));
249
+ });
250
+
251
+ it("keeps different collapsed tool types in separate summaries", async () => {
252
+ const host = createHost();
253
+
254
+ await handleAgentEvent(host, {
255
+ type: "message_start",
256
+ message: assistantMessage(),
257
+ });
258
+
259
+ addPendingTool(host, "tool-1", 200);
260
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
261
+
262
+ addPendingTool(host, "tool-2", 300);
263
+ await handleAgentEvent(host, toolEndEvent("tool-2", "find"));
264
+
265
+ const summaries = summaryLines(host);
266
+ assert.equal(summaries.length, 2);
267
+ assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("reading 1 file · 0.2s"));
268
+ assert.ok(stripAnsi(summaries[1].render(160).join("\n")).includes("finding 1 path · 0.3s"));
150
269
  });
151
270
 
152
271
  it("resets grouping after visible tool result", async () => {
@@ -173,7 +292,7 @@ describe("chat-controller collapsed tool summary lifecycle", () => {
173
292
  assert.ok(stripAnsi(summaries[1].render(160).join("\n")).includes("0.4s"));
174
293
  });
175
294
 
176
- it("merges adjacent collapsed groups across empty assistant message boundaries", async () => {
295
+ it("groups identical collapsed tools across empty assistant message boundaries", async () => {
177
296
  const host = createHost();
178
297
 
179
298
  await handleAgentEvent(host, {
@@ -194,7 +313,7 @@ describe("chat-controller collapsed tool summary lifecycle", () => {
194
313
 
195
314
  const summaries = summaryLines(host);
196
315
  assert.equal(summaries.length, 1);
197
- assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("read ×2 · 0.7s"));
316
+ assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("reading 2 files · 0.7s"));
198
317
  });
199
318
 
200
319
  it("starts new collapsed group after visible assistant content", async () => {
@@ -225,4 +344,53 @@ describe("chat-controller collapsed tool summary lifecycle", () => {
225
344
  assert.ok(stripAnsi(summaries[0].render(160).join("\n")).includes("0.3s"));
226
345
  assert.ok(stripAnsi(summaries[1].render(160).join("\n")).includes("0.4s"));
227
346
  });
347
+
348
+ it("keeps collapsed tool calls visible in intermediate mode", async () => {
349
+ const host = createHost({ collapseToolCalls: true, collapsedToolCallsExpanded: true });
350
+
351
+ await handleAgentEvent(host, {
352
+ type: "message_start",
353
+ message: assistantMessage(),
354
+ });
355
+
356
+ const tool = addPendingTool(host, "tool-1", 250);
357
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
358
+
359
+ const summaries = summaryLines(host);
360
+ assert.equal(tool.hidden, false);
361
+ assert.equal(summaries.length, 1);
362
+ assert.deepEqual(summaries[0].render(160), []);
363
+ });
364
+
365
+ it("keeps collapsed tools visible when verbose mode is active", async () => {
366
+ const host = createHost({ collapseToolCalls: true, toolOutputExpanded: true });
367
+
368
+ await handleAgentEvent(host, {
369
+ type: "message_start",
370
+ message: assistantMessage(),
371
+ });
372
+
373
+ const tool = addPendingTool(host, "tool-1", 250);
374
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
375
+
376
+ const summaries = summaryLines(host);
377
+ assert.equal(tool.hidden, false);
378
+ assert.equal(summaries.length, 1);
379
+ assert.deepEqual(summaries[0].render(160), []);
380
+ });
381
+
382
+ it("does not collapse tool calls when setting is disabled", async () => {
383
+ const host = createHost({ collapseToolCalls: false });
384
+
385
+ await handleAgentEvent(host, {
386
+ type: "message_start",
387
+ message: assistantMessage(),
388
+ });
389
+
390
+ const tool = addPendingTool(host, "tool-1", 250);
391
+ await handleAgentEvent(host, toolEndEvent("tool-1", "read"));
392
+
393
+ assert.equal(tool.hidden, false);
394
+ assert.equal(summaryLines(host).length, 0);
395
+ });
228
396
  });
@@ -9,6 +9,15 @@ import { ToolSummaryLine } from "../components/tool-summary-line.js";
9
9
  import { shouldCollapse } from "../../../core/tool-priority.js";
10
10
  import { appKey } from "../components/keybinding-hints.js";
11
11
 
12
+ const GROUPABLE_COLLAPSED_TOOLS = new Set([
13
+ "read",
14
+ "find",
15
+ "ls",
16
+ "grep",
17
+ "lsp",
18
+
19
+ ]);
20
+
12
21
  export async function handleAgentEvent(host: InteractiveModeStateHost & {
13
22
  init: () => Promise<void>;
14
23
  getMarkdownThemeWithSettings: () => any;
@@ -42,6 +51,24 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
42
51
  host.collapsedToolSummaryLine = undefined;
43
52
  };
44
53
 
54
+ // Tools that always render as their own visible row, never folded into a summary line
55
+ const ALWAYS_DIRECT_TOOLS = new Set([
56
+ "bash", "bg_shell",
57
+ "web_search", "search-the-web", "google_search", "search_and_read",
58
+ "fetch_page", "resolve_library", "get_library_docs",
59
+ ]);
60
+
61
+ const shouldStartToolHidden = (toolName: string): boolean => {
62
+ const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
63
+ if (!collapseToolCalls || host.collapsedToolCallsExpanded) {
64
+ return false;
65
+ }
66
+ if (ALWAYS_DIRECT_TOOLS.has(toolName)) {
67
+ return false;
68
+ }
69
+ return shouldCollapse(toolName, false);
70
+ };
71
+
45
72
  const hasVisibleRender = (child: { render?: (width: number) => string[] } | undefined): boolean => {
46
73
  if (!child?.render) return true;
47
74
  try {
@@ -51,14 +78,20 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
51
78
  }
52
79
  };
53
80
 
81
+ const canGroupCollapsedTool = (toolName: string): boolean => GROUPABLE_COLLAPSED_TOOLS.has(toolName);
82
+
54
83
  const findAdjacentCollapsedToolSummary = (
84
+ toolName: string,
55
85
  anchor?: { render: (width: number) => string[] },
56
86
  ): ToolSummaryLine | undefined => {
87
+ if (!canGroupCollapsedTool(toolName)) {
88
+ return undefined;
89
+ }
57
90
  const anchorIndex = anchor ? host.chatContainer.children.indexOf(anchor) : host.chatContainer.children.length;
58
91
  for (let i = anchorIndex - 1; i >= 0; i--) {
59
92
  const child = host.chatContainer.children[i];
60
93
  if (child instanceof ToolSummaryLine) {
61
- return child;
94
+ return child.canGroupWith(toolName) ? child : undefined;
62
95
  }
63
96
  if (child instanceof ToolExecutionComponent && child.isHidden()) {
64
97
  continue;
@@ -71,17 +104,15 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
71
104
  return undefined;
72
105
  };
73
106
 
74
- const appendCollapsedToolSummary = (toolName: string, elapsed: number, anchor?: { render: (width: number) => string[] }): void => {
75
- let summary = host.collapsedToolSummaryLine;
76
- if ((!summary || !host.chatContainer.children.includes(summary)) && anchor) {
77
- summary = findAdjacentCollapsedToolSummary(anchor);
78
- if (summary) {
79
- host.collapsedToolSummaryLine = summary;
80
- }
81
- }
82
- if (!summary || !host.chatContainer.children.includes(summary)) {
107
+ const appendCollapsedToolSummary = (
108
+ toolName: string,
109
+ elapsed: number,
110
+ anchor?: { render: (width: number) => string[] },
111
+ ): ToolSummaryLine => {
112
+ let summary = findAdjacentCollapsedToolSummary(toolName, anchor);
113
+ if (!summary) {
83
114
  summary = new ToolSummaryLine();
84
- summary.setHidden(host.toolOutputExpanded);
115
+ summary.setHidden(host.collapsedToolCallsExpanded);
85
116
  if (anchor) {
86
117
  const anchorIndex = host.chatContainer.children.indexOf(anchor);
87
118
  if (anchorIndex >= 0) {
@@ -92,9 +123,10 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
92
123
  } else {
93
124
  host.chatContainer.addChild(summary);
94
125
  }
95
- host.collapsedToolSummaryLine = summary;
96
126
  }
127
+ host.collapsedToolSummaryLine = summary;
97
128
  summary.addTool(toolName, elapsed);
129
+ return summary;
98
130
  };
99
131
 
100
132
  switch (event.type) {
@@ -221,6 +253,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
221
253
  host.ui,
222
254
  );
223
255
  component.setExpanded(host.toolOutputExpanded);
256
+ component.setHidden(shouldStartToolHidden(content.name));
224
257
  host.chatContainer.addChild(component);
225
258
  host.pendingTools.set(content.id, component);
226
259
  } else {
@@ -240,6 +273,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
240
273
  host.ui,
241
274
  );
242
275
  component.setExpanded(host.toolOutputExpanded);
276
+ component.setHidden(shouldStartToolHidden(content.name));
243
277
  host.chatContainer.addChild(component);
244
278
  host.pendingTools.set(content.id, component);
245
279
  }
@@ -322,6 +356,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
322
356
  host.ui,
323
357
  );
324
358
  component.setExpanded(host.toolOutputExpanded);
359
+ component.setHidden(shouldStartToolHidden(event.toolName));
325
360
  host.chatContainer.addChild(component);
326
361
  host.pendingTools.set(event.toolCallId, component);
327
362
  host.ui.requestRender();
@@ -371,9 +406,10 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
371
406
  const component = host.pendingTools.get(event.toolCallId);
372
407
  if (component) {
373
408
  component.updateResult({ ...event.result, isError: event.isError });
374
- if (shouldCollapse(event.toolName, event.isError)) {
409
+ const collapseToolCalls = host.settingsManager.getCollapseToolCalls?.() ?? false;
410
+ if (collapseToolCalls && shouldCollapse(event.toolName, event.isError) && !ALWAYS_DIRECT_TOOLS.has(event.toolName)) {
375
411
  appendCollapsedToolSummary(event.toolName, component.getElapsed(), component);
376
- component.setHidden(true);
412
+ component.setHidden(!host.collapsedToolCallsExpanded);
377
413
  } else {
378
414
  component.setHidden(false);
379
415
  resetCollapsedToolSummary();