lsd-pi 1.3.6 → 1.3.9

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 (126) hide show
  1. package/README.md +82 -0
  2. package/dist/cli.js +2 -1
  3. package/dist/lsd-settings-manager.d.ts +2 -0
  4. package/dist/lsd-settings-manager.js +5 -0
  5. package/dist/resource-loader.js +33 -3
  6. package/dist/resources/extensions/cache-timer/index.js +3 -2
  7. package/dist/resources/extensions/mcp-client/index.js +72 -4
  8. package/dist/resources/extensions/slash-commands/plan.js +5 -5
  9. package/dist/resources/extensions/usage/index.js +34 -2
  10. package/dist/resources/extensions/voice/index.js +1 -0
  11. package/dist/resources/extensions/voice/push-to-talk.js +2 -0
  12. package/dist/welcome-screen.js +2 -2
  13. package/package.json +1 -1
  14. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
  15. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
  16. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
  17. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
  18. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
  19. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
  21. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  22. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  23. package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  26. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts +2 -0
  29. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts.map +1 -0
  30. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js +35 -0
  31. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js.map +1 -0
  32. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  33. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  35. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  36. package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
  37. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
  38. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +5 -0
  39. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  40. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +21 -0
  41. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  42. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +16 -1
  43. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -1
  44. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  45. package/packages/pi-coding-agent/dist/main.js +1 -0
  46. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +12 -4
  48. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts +7 -5
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js +86 -28
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts +2 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +16 -10
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js +26 -4
  60. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +16 -1
  62. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +128 -13
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +1 -0
  66. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +48 -4
  68. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  69. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +137 -6
  70. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  71. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  72. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +64 -15
  73. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +2 -1
  76. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  78. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  79. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +5 -1
  81. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  82. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +73 -27
  83. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js +4 -4
  85. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js.map +1 -1
  86. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
  88. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  89. package/packages/pi-coding-agent/package.json +1 -1
  90. package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
  91. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
  92. package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
  93. package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
  94. package/packages/pi-coding-agent/src/core/settings-manager.collapse-tool-calls.test.ts +46 -0
  95. package/packages/pi-coding-agent/src/core/settings-manager.ts +18 -0
  96. package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
  97. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +20 -0
  98. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +26 -0
  99. package/packages/pi-coding-agent/src/main.ts +1 -0
  100. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +14 -4
  101. package/packages/pi-coding-agent/src/modes/interactive/components/diff.ts +105 -28
  102. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +13 -6
  103. package/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +31 -4
  104. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +137 -14
  105. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +60 -4
  106. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +174 -6
  107. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +73 -15
  108. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +2 -1
  109. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  110. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +76 -27
  111. package/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts +4 -4
  112. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
  113. package/packages/pi-tui/dist/components/editor.js +3 -3
  114. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  115. package/packages/pi-tui/src/components/editor.ts +3 -3
  116. package/pkg/dist/modes/interactive/theme/themes.js +4 -4
  117. package/pkg/dist/modes/interactive/theme/themes.js.map +1 -1
  118. package/pkg/package.json +1 -1
  119. package/src/resources/extensions/cache-timer/index.ts +3 -2
  120. package/src/resources/extensions/mcp-client/index.ts +83 -4
  121. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +16 -0
  122. package/src/resources/extensions/slash-commands/plan.ts +6 -6
  123. package/src/resources/extensions/usage/index.ts +40 -2
  124. package/src/resources/extensions/voice/index.ts +1 -0
  125. package/src/resources/extensions/voice/push-to-talk.ts +3 -0
  126. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +6 -0
@@ -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
@@ -126,6 +134,7 @@ export class ToolExecutionComponent extends Container {
126
134
  // When true, this component intentionally renders no lines
127
135
  private hideComponent = false;
128
136
  private manuallyHidden = false;
137
+ private indented = false;
129
138
  private startTime = Date.now();
130
139
 
131
140
  // Tool status spinner state
@@ -176,6 +185,40 @@ export class ToolExecutionComponent extends Container {
176
185
  return isBuiltInName && !hasCustomRenderers;
177
186
  }
178
187
 
188
+ private setPrimaryContent(useBox: boolean): void {
189
+ const hasBox = this.children.includes(this.contentBox);
190
+ const hasText = this.children.includes(this.contentText);
191
+
192
+ if (useBox) {
193
+ if (hasText) this.removeChild(this.contentText);
194
+ if (!hasBox) this.addChild(this.contentBox);
195
+ } else {
196
+ if (hasBox) this.removeChild(this.contentBox);
197
+ if (!hasText) this.addChild(this.contentText);
198
+ }
199
+ }
200
+
201
+ private getDiffTextToRender(): string | undefined {
202
+ if (this.toolName === "write") {
203
+ if (!this.isPartial && this.writeDiffPreview && !("error" in this.writeDiffPreview) && this.writeDiffPreview.diff) {
204
+ return this.writeDiffPreview.diff;
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ if (this.toolName !== "edit" || this.result?.isError) {
210
+ return undefined;
211
+ }
212
+
213
+ if (this.result?.details?.diff) {
214
+ return this.result.details.diff;
215
+ }
216
+ if (this.editDiffPreview && !("error" in this.editDiffPreview) && this.editDiffPreview.diff) {
217
+ return this.editDiffPreview.diff;
218
+ }
219
+ return undefined;
220
+ }
221
+
179
222
  updateArgs(args: any): void {
180
223
  this.args = args;
181
224
  if (this.toolName === "write" && this.isPartial) {
@@ -287,10 +330,37 @@ export class ToolExecutionComponent extends Container {
287
330
  if (rawPath !== null && fileContent !== null) {
288
331
  this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
289
332
  }
333
+ this.maybeComputeWriteDiff();
290
334
  }
291
335
  this.maybeComputeEditDiff();
292
336
  }
293
337
 
338
+ /**
339
+ * Compute write diff preview when we have complete args.
340
+ * This runs async and updates display when done.
341
+ */
342
+ private maybeComputeWriteDiff(): void {
343
+ if (this.toolName !== "write") return;
344
+
345
+ const path = this.args?.path ?? this.args?.file_path;
346
+ const content = this.args?.content;
347
+
348
+ if (!path || content === undefined) return;
349
+
350
+ const argsKey = JSON.stringify({ path, content });
351
+ if (this.writeDiffArgsKey === argsKey) return;
352
+
353
+ this.writeDiffArgsKey = argsKey;
354
+
355
+ computeWriteDiff(path, content, this.cwd).then((result) => {
356
+ if (this.writeDiffArgsKey === argsKey) {
357
+ this.writeDiffPreview = result;
358
+ this.updateDisplay();
359
+ this.ui.requestRender();
360
+ }
361
+ });
362
+ }
363
+
294
364
  /**
295
365
  * Compute edit diff preview when we have complete args.
296
366
  * This runs async and updates display when done.
@@ -388,6 +458,11 @@ export class ToolExecutionComponent extends Container {
388
458
  this.updateDisplay();
389
459
  }
390
460
 
461
+ setIndented(indented: boolean): void {
462
+ this.indented = indented;
463
+ this.updateDisplay();
464
+ }
465
+
391
466
  isHidden(): boolean {
392
467
  return this.hideComponent;
393
468
  }
@@ -396,8 +471,8 @@ export class ToolExecutionComponent extends Container {
396
471
  return Date.now() - this.startTime;
397
472
  }
398
473
 
399
- shouldHideWhenCollapsed(): boolean {
400
- return !this.isPartial && shouldCollapse(this.toolName, this.result?.isError ?? false);
474
+ shouldHideWhenCollapsed(collapseToolCalls = true): boolean {
475
+ return collapseToolCalls && !this.isPartial && shouldCollapse(this.toolName, this.result?.isError ?? false);
401
476
  }
402
477
 
403
478
  setRenderMode(mode: "minimal" | "normal"): void {
@@ -433,7 +508,18 @@ export class ToolExecutionComponent extends Container {
433
508
  if (this.hideComponent) {
434
509
  return [];
435
510
  }
436
- return [...super.render(width), ""];
511
+ const lines = super.render(width);
512
+ if (this.indented) {
513
+ const gutter = theme.fg("dim", "│");
514
+ return lines.map((line, i) => {
515
+ if (i === lines.length - 1 && line === "") {
516
+ // Trailing empty spacer line → gutter only
517
+ return gutter;
518
+ }
519
+ return gutter + " " + theme.fg("dim", line);
520
+ });
521
+ }
522
+ return [...lines, ""];
437
523
  }
438
524
 
439
525
  private updateDisplay(): void {
@@ -469,15 +555,22 @@ export class ToolExecutionComponent extends Container {
469
555
  }
470
556
 
471
557
  const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
558
+ const diffTextToRender = this.getDiffTextToRender();
472
559
  let customRendererHasContent = false;
473
560
 
474
561
  // Use built-in rendering for built-in tools (or overrides without custom renderers)
475
562
  if (useBuiltInRenderer) {
563
+ const useDiffBox = !!diffTextToRender && !this.shouldHideCollapsedPreview();
564
+ this.setPrimaryContent(this.toolName === "bash" || useDiffBox);
476
565
  if (this.toolName === "bash") {
477
566
  // Bash uses Box with visual line truncation - no background
478
567
  this.contentBox.setBgFn((text: string) => text);
479
568
  this.contentBox.clear();
480
569
  this.renderBashContent(statusIndicator);
570
+ } else if (useDiffBox && diffTextToRender) {
571
+ this.contentBox.setBgFn((text: string) => text);
572
+ this.contentBox.clear();
573
+ this.renderBuiltInDiffContent(statusIndicator, diffTextToRender);
481
574
  } else {
482
575
  // Other built-in tools: use Text directly with caching - no background
483
576
  this.contentText.setCustomBgFn((text: string) => text);
@@ -619,6 +712,21 @@ export class ToolExecutionComponent extends Container {
619
712
  this.hideComponent = this.manuallyHidden || computedHidden;
620
713
  }
621
714
 
715
+ /**
716
+ * Render built-in edit/write diff blocks with width-aware full-line backgrounds.
717
+ */
718
+ private renderBuiltInDiffContent(statusIndicator: string, diffText: string): void {
719
+ const header = this.formatToolExecution(statusIndicator).split("\n\n", 1)[0] ?? "";
720
+ this.contentBox.addChild(new Text(header, 0, 0));
721
+ this.contentBox.addChild({
722
+ render: (width: number) => {
723
+ const contentWidth = Math.max(1, width - 2);
724
+ return ["", ...renderDiffLines(diffText, contentWidth).map((line) => ` ${line} `)];
725
+ },
726
+ invalidate: () => { },
727
+ });
728
+ }
729
+
622
730
  /**
623
731
  * Render bash content using visual line truncation (like bash-execution.ts)
624
732
  */
@@ -874,15 +982,38 @@ export class ToolExecutionComponent extends Container {
874
982
  const rawPath = str(this.args?.file_path ?? this.args?.path);
875
983
  const fileContent = str(this.args?.content);
876
984
  const path = rawPath !== null ? shortenPath(rawPath) : null;
985
+ const firstChangedLine = this.writeDiffPreview && "firstChangedLine" in this.writeDiffPreview
986
+ ? this.writeDiffPreview.firstChangedLine
987
+ : undefined;
877
988
 
878
989
  let writePathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
879
990
  if (rawPath && path) {
880
- writePathDisplay = editorLink(rawPath, writePathDisplay, { cwd: this.cwd, scheme: this.editorScheme });
991
+ writePathDisplay = editorLink(rawPath, writePathDisplay, {
992
+ cwd: this.cwd,
993
+ line: firstChangedLine ?? undefined,
994
+ scheme: this.editorScheme,
995
+ });
996
+ }
997
+ if (firstChangedLine) {
998
+ writePathDisplay += theme.fg("warning", `:${firstChangedLine}`);
881
999
  }
882
1000
  text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("write"))} ${writePathDisplay}`;
883
1001
 
884
1002
  if (fileContent === null) {
885
1003
  text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
1004
+ } else if (this.result?.isError) {
1005
+ const errorText = this.getTextOutput();
1006
+ if (errorText) {
1007
+ text += `\n\n${theme.fg("error", errorText)}`;
1008
+ }
1009
+ } else if (!this.isPartial && this.writeDiffPreview) {
1010
+ if ("error" in this.writeDiffPreview) {
1011
+ text += `\n\n${theme.fg("error", this.writeDiffPreview.error)}`;
1012
+ } else if (this.writeDiffPreview.diff) {
1013
+ text += hideCollapsedPreview
1014
+ ? this.collapsedHintWithPrefix()
1015
+ : `\n\n${renderDiff(this.writeDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
1016
+ }
886
1017
  } else if (fileContent) {
887
1018
  const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
888
1019
 
@@ -924,14 +1055,6 @@ export class ToolExecutionComponent extends Container {
924
1055
  }
925
1056
  }
926
1057
  }
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
1058
  } else if (this.toolName === "edit") {
936
1059
  const rawPath = str(this.args?.file_path ?? this.args?.path);
937
1060
  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
- const details = theme.fg("muted", `${groupedTools} · ${elapsed}s`);
62
- this.contentText.setText(`${indicator} ${title} ${details}`);
117
+ const details = theme.fg("text", groupedTools) + theme.fg("muted", ` · ${elapsed}s`);
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
  });