agent-sh 0.5.0 → 0.6.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 (37) hide show
  1. package/README.md +12 -43
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +119 -26
  4. package/dist/agent/subagent.js +3 -1
  5. package/dist/agent/system-prompt.d.ts +1 -1
  6. package/dist/agent/system-prompt.js +21 -16
  7. package/dist/agent/tools/bash.js +10 -1
  8. package/dist/agent/tools/display.d.ts +13 -0
  9. package/dist/agent/tools/display.js +70 -0
  10. package/dist/agent/tools/edit-file.js +60 -7
  11. package/dist/agent/tools/glob.js +39 -7
  12. package/dist/agent/tools/grep.js +111 -20
  13. package/dist/agent/tools/ls.js +31 -2
  14. package/dist/agent/tools/read-file.d.ts +9 -1
  15. package/dist/agent/tools/read-file.js +50 -4
  16. package/dist/agent/tools/user-shell.js +40 -13
  17. package/dist/agent/tools/write-file.js +9 -1
  18. package/dist/agent/types.d.ts +35 -1
  19. package/dist/core.d.ts +1 -3
  20. package/dist/core.js +7 -11
  21. package/dist/event-bus.d.ts +18 -3
  22. package/dist/extension-loader.d.ts +1 -1
  23. package/dist/extension-loader.js +1 -3
  24. package/dist/extensions/tui-renderer.js +341 -83
  25. package/dist/index.js +41 -36
  26. package/dist/input-handler.js +4 -2
  27. package/dist/settings.js +1 -1
  28. package/dist/shell.js +2 -2
  29. package/dist/utils/diff.js +10 -0
  30. package/dist/utils/markdown.d.ts +1 -0
  31. package/dist/utils/markdown.js +23 -1
  32. package/dist/utils/tool-display.d.ts +4 -0
  33. package/dist/utils/tool-display.js +22 -5
  34. package/examples/extensions/claude-code-bridge/index.ts +8 -12
  35. package/examples/extensions/pi-bridge/index.ts +10 -12
  36. package/examples/extensions/secret-guard.ts +100 -0
  37. package/package.json +1 -1
@@ -14,7 +14,7 @@ import { highlight } from "cli-highlight";
14
14
  import { MarkdownRenderer, wrapLine } from "../utils/markdown.js";
15
15
  import { createFencedBlockTransform } from "../utils/stream-transform.js";
16
16
  import { palette as p } from "../utils/palette.js";
17
- import { renderToolCall, createSpinner, renderSpinnerLine, } from "../utils/tool-display.js";
17
+ import { renderToolCall, createSpinner, renderSpinnerLine, formatElapsed, } from "../utils/tool-display.js";
18
18
  import { renderDiff } from "../utils/diff-renderer.js";
19
19
  import { renderBoxFrame } from "../utils/box-frame.js";
20
20
  import { getSettings } from "../settings.js";
@@ -42,6 +42,7 @@ function createRenderState() {
42
42
  return {
43
43
  renderer: null,
44
44
  hadToolCalls: false,
45
+ lastContentKind: null,
45
46
  spinner: null,
46
47
  spinnerLabel: "",
47
48
  spinnerOpts: {},
@@ -49,9 +50,17 @@ function createRenderState() {
49
50
  spinnerStartTime: 0,
50
51
  toolLineOpen: false,
51
52
  currentToolKind: undefined,
53
+ toolStartTime: 0,
54
+ toolExitCode: null,
52
55
  commandOutputBuffer: "",
53
56
  commandOutputLineCount: 0,
54
57
  commandOutputOverflow: 0,
58
+ commandOverflowLines: [],
59
+ toolGroupKind: undefined,
60
+ toolGroupCount: 0,
61
+ toolGroupAllOk: true,
62
+ toolGroupRendered: 0,
63
+ toolGroupSummaries: [],
55
64
  isThinking: false,
56
65
  showThinkingText: false,
57
66
  thinkingPending: false,
@@ -77,7 +86,7 @@ export default function activate(ctx) {
77
86
  // ── Event subscriptions ─────────────────────────────────────
78
87
  bus.on("agent:query", (e) => {
79
88
  s.spinnerStartTime = 0;
80
- showUserQuery(e.query, e.modeLabel);
89
+ showUserQuery(e.query);
81
90
  startAgentResponse();
82
91
  startThinkingSpinner();
83
92
  });
@@ -154,29 +163,104 @@ export default function activate(ctx) {
154
163
  }
155
164
  endAgentResponse();
156
165
  });
166
+ // ── Tool batch grouping ──────────────────────────────────────────
167
+ const GROUPABLE_KINDS = new Set(["read", "search"]);
168
+ const GROUP_MAX_VISIBLE = 5;
169
+ const KIND_ICONS = { read: "◆", search: "⌕" };
170
+ // Batch groups: kind → { total, rendered, headerShown }
171
+ let batchGroups = new Map();
172
+ bus.on("agent:tool-batch", (e) => {
173
+ fencedTransform.flush();
174
+ finalizeToolGroup();
175
+ batchGroups = new Map();
176
+ for (const group of e.groups) {
177
+ batchGroups.set(group.kind, {
178
+ total: group.tools.length,
179
+ rendered: 0,
180
+ headerShown: false,
181
+ });
182
+ }
183
+ });
157
184
  bus.on("agent:tool-started", (e) => {
158
185
  fencedTransform.flush();
159
186
  stopCurrentSpinner();
160
187
  s.currentToolKind = e.kind;
188
+ s.toolStartTime = Date.now();
161
189
  if (e.title === "user_shell") {
190
+ finalizeToolGroup();
162
191
  closeToolLine();
163
192
  if (!s.renderer)
164
193
  startAgentResponse();
194
+ contentGap("tool");
165
195
  s.renderer.flush();
166
196
  const cmd = e.rawInput?.command || "";
167
197
  s.renderer.writeLine(`${p.dim}▶ user_shell: ${cmd}${p.reset}`);
168
198
  drain();
169
199
  s.hadToolCalls = true;
200
+ return;
201
+ }
202
+ const kind = e.kind ?? "execute";
203
+ const group = batchGroups.get(kind);
204
+ const isGrouped = group && group.total > 1 && GROUPABLE_KINDS.has(kind);
205
+ if (isGrouped) {
206
+ // Render group header on first tool of this kind in the batch
207
+ if (!group.headerShown) {
208
+ finalizeToolGroup();
209
+ closeToolLine();
210
+ if (!s.renderer)
211
+ startAgentResponse();
212
+ showCollapsedThinking();
213
+ contentGap("tool");
214
+ s.renderer.flush();
215
+ drain();
216
+ const icon = KIND_ICONS[kind] ?? "▶";
217
+ s.renderer.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
218
+ drain();
219
+ group.headerShown = true;
220
+ s.toolGroupKind = kind;
221
+ s.toolGroupCount = 0;
222
+ s.toolGroupRendered = 0;
223
+ s.toolGroupAllOk = true;
224
+ s.toolGroupSummaries = [];
225
+ }
226
+ s.toolGroupCount++;
227
+ if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
228
+ showToolCall(e.title, "", {
229
+ ...e,
230
+ batchIndex: e.batchIndex,
231
+ batchTotal: e.batchTotal,
232
+ groupContinuation: true,
233
+ });
234
+ s.toolGroupRendered++;
235
+ }
170
236
  }
171
237
  else {
172
- showToolCall(e.title, "", e);
238
+ // Standalone tool — single in its batch kind, or not groupable
239
+ finalizeToolGroup();
240
+ showToolCall(e.title, "", {
241
+ ...e,
242
+ batchIndex: e.batchIndex,
243
+ batchTotal: e.batchTotal,
244
+ });
173
245
  }
174
246
  });
175
247
  bus.on("agent:tool-completed", (e) => {
176
- showToolComplete(e.exitCode);
177
- s.currentToolKind = undefined;
178
- s.spinnerStartTime = 0;
179
- startThinkingSpinner();
248
+ s.toolExitCode = e.exitCode;
249
+ if (e.exitCode !== 0)
250
+ s.toolGroupAllOk = false;
251
+ if (s.toolGroupKind) {
252
+ // Grouped tool — track success/failure and summaries, show aggregate on ⎿ line.
253
+ // Don't restart spinner between grouped tools — it's already running from group start.
254
+ if (e.resultDisplay?.summary)
255
+ s.toolGroupSummaries.push(e.resultDisplay.summary);
256
+ s.currentToolKind = undefined;
257
+ }
258
+ else {
259
+ showToolComplete(e.exitCode, e.resultDisplay);
260
+ s.currentToolKind = undefined;
261
+ s.spinnerStartTime = 0;
262
+ startThinkingSpinner();
263
+ }
180
264
  });
181
265
  bus.on("agent:tool-output-chunk", (e) => writeCommandOutput(e.chunk));
182
266
  bus.on("agent:tool-output", () => flushCommandOutput());
@@ -196,6 +280,7 @@ export default function activate(ctx) {
196
280
  showCollapsedThinking();
197
281
  if (!s.renderer)
198
282
  startAgentResponse();
283
+ contentGap("info");
199
284
  s.renderer.writeLine(`${p.error}Error: ${e.message}${p.reset}`);
200
285
  s.renderer.writeLine("");
201
286
  drain();
@@ -238,24 +323,43 @@ export default function activate(ctx) {
238
323
  return;
239
324
  for (const line of s.renderer.drainLines()) {
240
325
  writer.write(line + "\n");
326
+ // Track whether we just emitted a blank line (for contentGap dedup).
327
+ // Lines from the renderer are indented (" "), so a blank line is " " or empty.
328
+ lastEmittedLineBlank = line.trimEnd() === "" || line.trimEnd().replace(/\x1b\[[^m]*m/g, "").trim() === "";
241
329
  }
242
330
  }
243
331
  function startAgentResponse() {
244
332
  s.renderer = new MarkdownRenderer(writer.columns);
245
333
  s.hadToolCalls = false;
334
+ // Preserve lastContentKind across responses so text→tool gaps work
246
335
  s.renderer.printTopBorder();
247
336
  drain();
248
337
  }
338
+ /**
339
+ * Insert an empty line when transitioning between different content kinds
340
+ * (e.g., text → tool, tool → text, diff → tool) for visual breathing room.
341
+ * Avoids double-blanks by checking if the last emitted line was already empty.
342
+ */
343
+ let lastEmittedLineBlank = false;
344
+ function contentGap(kind) {
345
+ if (s.lastContentKind && s.lastContentKind !== kind) {
346
+ if (s.renderer) {
347
+ s.renderer.flush();
348
+ drain();
349
+ }
350
+ writer.write("\n");
351
+ }
352
+ s.lastContentKind = kind;
353
+ }
249
354
  function showCollapsedThinking() {
250
355
  if (s.thinkingPending && !s.showThinkingText) {
251
- if (!s.renderer)
252
- startAgentResponse();
253
- s.renderer.writeLine(`${p.muted}… thinking${p.reset}`);
254
- s.renderer.writeLine("");
356
+ // Just clear the pending flag — the spinner already indicates thinking.
357
+ // No need for a separate "… thinking" label that clutters the output.
255
358
  s.thinkingPending = false;
256
359
  }
257
360
  }
258
361
  function endAgentResponse() {
362
+ finalizeToolGroup();
259
363
  closeToolLine();
260
364
  stopCurrentSpinner();
261
365
  if (s.renderer) {
@@ -266,10 +370,10 @@ export default function activate(ctx) {
266
370
  s.renderer = null;
267
371
  }
268
372
  }
269
- function showUserQuery(query, modeLabel) {
373
+ function showUserQuery(query) {
270
374
  const boxW = writer.columns;
271
375
  const contentW = boxW - 4;
272
- const lines = [];
376
+ let lines = [];
273
377
  for (const raw of query.split("\n")) {
274
378
  if (raw.length <= contentW) {
275
379
  lines.push(`${p.accent}${raw}${p.reset}`);
@@ -287,12 +391,18 @@ export default function activate(ctx) {
287
391
  lines.push(`${p.accent}${remaining}${p.reset}`);
288
392
  }
289
393
  }
394
+ // Truncate very long queries to keep the response visible
395
+ const MAX_QUERY_LINES = 20;
396
+ if (lines.length > MAX_QUERY_LINES) {
397
+ const overflow = lines.length - MAX_QUERY_LINES;
398
+ lines = [
399
+ ...lines.slice(0, MAX_QUERY_LINES),
400
+ `${p.dim}… ${overflow} more lines${p.reset}`,
401
+ ];
402
+ }
290
403
  // Mode-specific border color and title
291
- const isExecute = modeLabel === "Execute";
292
- const borderColor = isExecute ? p.success : p.accent;
293
- const title = modeLabel
294
- ? `${borderColor}${p.bold} ${modeLabel} ${p.reset}`
295
- : `${p.accent}${p.bold}❯${p.reset}`;
404
+ const borderColor = p.accent;
405
+ const title = `${p.accent}${p.bold}❯${p.reset}`;
296
406
  // Backend/model label on the right (backend/model, highlighted)
297
407
  const model = backendInfo?.model ?? llmClient?.model;
298
408
  const backend = backendInfo?.name;
@@ -319,8 +429,8 @@ export default function activate(ctx) {
319
429
  }
320
430
  }
321
431
  function writeAgentText(text) {
432
+ finalizeToolGroup();
322
433
  closeToolLine();
323
- const needsGap = s.hadToolCalls;
324
434
  s.hadToolCalls = false;
325
435
  if (s.isThinking) {
326
436
  s.isThinking = false;
@@ -335,28 +445,24 @@ export default function activate(ctx) {
335
445
  stopCurrentSpinner();
336
446
  if (!s.renderer)
337
447
  startAgentResponse();
338
- if (needsGap)
339
- writer.write("\n");
448
+ contentGap("text");
340
449
  s.renderer.push(text);
341
450
  drain();
342
451
  }
343
452
  define("render:code-block", (language, code, width) => {
344
453
  flushForRaw();
454
+ contentGap("code");
345
455
  if (language) {
346
456
  s.renderer.writeLine(`${p.dim}${language}${p.reset}`);
347
457
  }
348
458
  let highlighted;
349
- if (!language) {
350
- // No language specified — render as plain text to avoid false syntax detection
351
- highlighted = code;
459
+ try {
460
+ highlighted = language
461
+ ? highlight(code, { language })
462
+ : highlight(code); // auto-detect
352
463
  }
353
- else {
354
- try {
355
- highlighted = highlight(code, { language });
356
- }
357
- catch {
358
- highlighted = `${p.success}${code}${p.reset}`;
359
- }
464
+ catch {
465
+ highlighted = code;
360
466
  }
361
467
  const contentWidth = Math.min(90, width - 2);
362
468
  for (const line of highlighted.split("\n")) {
@@ -389,41 +495,168 @@ export default function activate(ctx) {
389
495
  function writeInlineImage(data) {
390
496
  ctx.call("render:image", data);
391
497
  }
498
+ /**
499
+ * Default renderer for tool result bodies. Extensions can advise this handler
500
+ * to override rendering for specific body kinds or add new ones:
501
+ *
502
+ * ctx.advise("render:result-body", (next, body, width) => {
503
+ * if (body.kind === "diff") return myCustomDiffRenderer(body, width);
504
+ * return next(body, width);
505
+ * });
506
+ */
507
+ define("render:result-body", (body, width) => {
508
+ if (body.kind === "diff") {
509
+ return renderDiffBody(body.diff, body.filePath, width);
510
+ }
511
+ if (body.kind === "lines") {
512
+ return renderLinesBody(body.lines, width, body.maxLines);
513
+ }
514
+ return [];
515
+ });
516
+ /** Render a diff as framed box lines (pure — no TUI state side effects). */
517
+ function renderDiffBody(diff, filePath, width) {
518
+ if (diff.isIdentical)
519
+ return [];
520
+ const boxW = Math.min(120, width);
521
+ const contentW = boxW - 4;
522
+ const diffLines = renderDiff(diff, {
523
+ width: contentW,
524
+ filePath,
525
+ maxLines: getSettings().diffMaxLines,
526
+ trueColor: true,
527
+ });
528
+ const lastLine = diffLines[diffLines.length - 1] ?? "";
529
+ const isTruncated = lastLine.includes("… ");
530
+ if (isTruncated) {
531
+ s.lastTruncatedDiff = { filePath, diff, expanded: false };
532
+ }
533
+ else {
534
+ s.lastTruncatedDiff = null;
535
+ }
536
+ const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
537
+ const footer = isTruncated
538
+ ? [` ${p.dim}ctrl+o to expand${p.reset}`]
539
+ : undefined;
540
+ return renderBoxFrame(body, {
541
+ width: boxW,
542
+ style: "rounded",
543
+ borderColor: p.dim,
544
+ title: diffTitle(filePath, diff),
545
+ footer,
546
+ });
547
+ }
548
+ /** Render output lines with truncation. */
549
+ function renderLinesBody(lines, width, maxLines) {
550
+ const max = maxLines ?? 10;
551
+ const shown = lines.slice(0, max);
552
+ const contentW = Math.max(1, width - 6);
553
+ const output = [];
554
+ for (const line of shown) {
555
+ const text = line.length > contentW ? line.slice(0, contentW - 1) + "…" : line;
556
+ output.push(` ${p.dim} ${text}${p.reset}`);
557
+ }
558
+ if (lines.length > max) {
559
+ output.push(` ${p.dim} … ${lines.length - max} more lines${p.reset}`);
560
+ }
561
+ return output;
562
+ }
563
+ /** Extract a detail string from tool args for group continuation display. */
564
+ function extractDetail(extra) {
565
+ if (extra.locations && extra.locations.length > 0) {
566
+ const loc = extra.locations[0];
567
+ const cwd = process.cwd();
568
+ const home = process.env.HOME;
569
+ let fp = loc.path;
570
+ if (fp.startsWith(cwd + "/"))
571
+ fp = fp.slice(cwd.length + 1);
572
+ else if (home && fp.startsWith(home + "/"))
573
+ fp = "~/" + fp.slice(home.length + 1);
574
+ return loc.line ? `${fp}:${loc.line}` : fp;
575
+ }
576
+ const raw = extra.rawInput;
577
+ if (!raw)
578
+ return "";
579
+ if (typeof raw.command === "string")
580
+ return `$ ${raw.command}`;
581
+ if (typeof raw.pattern === "string")
582
+ return raw.pattern;
583
+ if (typeof raw.path === "string") {
584
+ const cwd = process.cwd();
585
+ const home = process.env.HOME;
586
+ let fp = raw.path;
587
+ if (fp.startsWith(cwd + "/"))
588
+ fp = fp.slice(cwd.length + 1);
589
+ else if (home && fp.startsWith(home + "/"))
590
+ fp = "~/" + fp.slice(home.length + 1);
591
+ return fp;
592
+ }
593
+ if (typeof raw.query === "string")
594
+ return `"${raw.query}"`;
595
+ return "";
596
+ }
392
597
  function showToolCall(title, command, extra) {
393
598
  closeToolLine();
394
599
  stopCurrentSpinner();
395
600
  if (!s.renderer)
396
601
  startAgentResponse();
397
602
  showCollapsedThinking();
603
+ // No gap between grouped tools — they're visually connected
604
+ if (!extra?.groupContinuation)
605
+ contentGap("tool");
398
606
  s.renderer.flush();
399
607
  drain();
400
608
  const lines = renderToolCall({
401
609
  title,
402
610
  command: command || undefined,
403
611
  kind: extra?.kind,
612
+ icon: extra?.icon,
404
613
  locations: extra?.locations,
405
614
  rawInput: extra?.rawInput,
615
+ displayDetail: extra?.displayDetail,
406
616
  }, writer.columns);
617
+ if (extra?.groupContinuation && lines.length > 0) {
618
+ // Swap the colored kind icon for a muted tree connector,
619
+ // and strip the tool name prefix — show detail only.
620
+ const detail = extra.displayDetail || extractDetail(extra);
621
+ const maxW = Math.max(1, writer.columns - 6);
622
+ const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
623
+ lines[0] = detail
624
+ ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
625
+ : lines[0].replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
626
+ }
627
+ const batchPrefix = "";
407
628
  for (let i = 0; i < lines.length - 1; i++) {
408
629
  s.renderer.writeLine(lines[i]);
409
630
  }
410
631
  drain();
411
632
  if (lines.length > 0) {
412
- writer.write(` ${lines[lines.length - 1]}`);
413
- s.toolLineOpen = true;
633
+ if (extra?.groupContinuation) {
634
+ // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
635
+ s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
636
+ drain();
637
+ s.toolLineOpen = false;
638
+ }
639
+ else {
640
+ writer.write(` ${batchPrefix}${lines[lines.length - 1]}`);
641
+ s.toolLineOpen = true;
642
+ }
414
643
  }
415
644
  s.hadToolCalls = true;
416
645
  s.commandOutputLineCount = 0;
417
646
  s.commandOutputOverflow = 0;
418
647
  }
419
- function showToolComplete(exitCode) {
648
+ function showToolComplete(exitCode, resultDisplay) {
420
649
  if (!s.renderer)
421
650
  return;
651
+ stopCurrentSpinner();
652
+ const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
653
+ const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
654
+ const summary = resultDisplay?.summary ? ` ${p.dim}${resultDisplay.summary}${p.reset}` : "";
422
655
  const mark = exitCode === null
423
656
  ? `${p.muted}(timed out)${p.reset}`
424
657
  : exitCode === 0
425
- ? `${p.success}✓${p.reset}`
426
- : `${p.error}✗ exit ${exitCode}${p.reset}`;
658
+ ? `${p.success}✓${p.reset}${summary}${timer}`
659
+ : `${p.error}✗ exit ${exitCode}${p.reset}${summary}${timer}`;
427
660
  if (s.toolLineOpen && s.commandOutputLineCount === 0) {
428
661
  writer.write(` ${mark}\n`);
429
662
  s.toolLineOpen = false;
@@ -434,6 +667,20 @@ export default function activate(ctx) {
434
667
  s.renderer.writeLine(` ${mark}`);
435
668
  drain();
436
669
  }
670
+ // Render structured body if present
671
+ if (resultDisplay?.body) {
672
+ renderResultBody(resultDisplay.body);
673
+ }
674
+ }
675
+ function renderResultBody(body) {
676
+ if (!s.renderer)
677
+ return;
678
+ const lines = ctx.call("render:result-body", body, writer.columns) ?? [];
679
+ for (const line of lines) {
680
+ s.renderer.writeLine(line);
681
+ }
682
+ if (lines.length > 0)
683
+ drain();
437
684
  }
438
685
  // Thinking is always assumed available — the TUI renders thinking
439
686
  // tokens whenever they arrive, regardless of backend.
@@ -474,6 +721,40 @@ export default function activate(ctx) {
474
721
  s.toolLineOpen = false;
475
722
  }
476
723
  }
724
+ /** Finalize a group of collapsed tool calls, rendering the summary. */
725
+ function finalizeToolGroup() {
726
+ if (s.toolGroupCount <= 1) {
727
+ // 0–1 tools: standalone, nothing to finalize
728
+ s.toolGroupKind = undefined;
729
+ s.toolGroupCount = 0;
730
+ s.toolGroupRendered = 0;
731
+ s.toolGroupSummaries = [];
732
+ return;
733
+ }
734
+ closeToolLine();
735
+ if (!s.renderer)
736
+ startAgentResponse();
737
+ const mark = s.toolGroupAllOk
738
+ ? `${p.success}✓${p.reset}`
739
+ : `${p.error}✗${p.reset}`;
740
+ const summary = s.toolGroupSummaries.length > 0
741
+ ? ` ${p.dim}${s.toolGroupSummaries.join(", ")}${p.reset}`
742
+ : "";
743
+ const collapsed = s.toolGroupCount - s.toolGroupRendered;
744
+ if (collapsed > 0) {
745
+ s.renderer.writeLine(` ${p.muted}└${p.reset} ${p.dim}+${collapsed} more${p.reset} ${mark}${summary}`);
746
+ }
747
+ else {
748
+ // All items visible — close the tree with └ mark + summary
749
+ s.renderer.writeLine(` ${p.muted}└${p.reset} ${mark}${summary}`);
750
+ }
751
+ drain();
752
+ s.toolGroupKind = undefined;
753
+ s.toolGroupCount = 0;
754
+ s.toolGroupAllOk = true;
755
+ s.toolGroupRendered = 0;
756
+ s.toolGroupSummaries = [];
757
+ }
477
758
  function writeCommandOutput(chunk) {
478
759
  if (!s.renderer)
479
760
  return;
@@ -491,10 +772,13 @@ export default function activate(ctx) {
491
772
  }
492
773
  else {
493
774
  s.commandOutputOverflow++;
775
+ s.commandOverflowLines.push(line);
494
776
  }
495
777
  }
496
778
  drain();
497
779
  }
780
+ /** Max overflow lines to show when a command fails. */
781
+ const FAIL_OVERFLOW_MAX = 20;
498
782
  function flushCommandOutput() {
499
783
  if (!s.renderer)
500
784
  return;
@@ -508,13 +792,28 @@ export default function activate(ctx) {
508
792
  }
509
793
  else {
510
794
  s.commandOutputOverflow++;
795
+ s.commandOverflowLines.push(s.commandOutputBuffer);
511
796
  }
512
797
  s.commandOutputBuffer = "";
513
798
  }
514
- if (s.commandOutputOverflow > 0 && maxLines > 0) {
799
+ // On failure, show the tail of the overflow so the user can see the error
800
+ const failed = s.toolExitCode !== null && s.toolExitCode !== 0;
801
+ if (failed && s.commandOverflowLines.length > 0) {
802
+ const tail = s.commandOverflowLines.slice(-FAIL_OVERFLOW_MAX);
803
+ const skipped = s.commandOverflowLines.length - tail.length;
804
+ if (skipped > 0) {
805
+ s.renderer.writeLine(`${p.dim} … ${skipped} lines hidden${p.reset}`);
806
+ }
807
+ for (const line of tail) {
808
+ s.renderer.writeLine(`${p.dim} ${line}${p.reset}`);
809
+ }
810
+ }
811
+ else if (s.commandOutputOverflow > 0 && maxLines > 0) {
515
812
  s.renderer.writeLine(`${p.dim} … ${s.commandOutputOverflow} more lines${p.reset}`);
516
813
  }
517
814
  s.commandOutputOverflow = 0;
815
+ s.commandOverflowLines = [];
816
+ s.toolExitCode = null;
518
817
  drain();
519
818
  }
520
819
  function diffTitle(filePath, diff) {
@@ -526,36 +825,11 @@ export default function activate(ctx) {
526
825
  function showFileDiff(filePath, diff) {
527
826
  if (diff.isIdentical)
528
827
  return;
529
- const boxW = Math.min(120, writer.columns);
530
- const contentW = boxW - 4;
531
- const diffLines = renderDiff(diff, {
532
- width: contentW,
533
- filePath,
534
- maxLines: getSettings().diffMaxLines,
535
- trueColor: true,
536
- });
537
- const lastLine = diffLines[diffLines.length - 1] ?? "";
538
- const isTruncated = lastLine.includes("… ");
539
- if (isTruncated) {
540
- s.lastTruncatedDiff = { filePath, diff, expanded: false };
541
- }
542
- else {
543
- s.lastTruncatedDiff = null;
544
- }
545
- const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
546
- const footer = isTruncated
547
- ? [` ${p.dim}ctrl+o to expand${p.reset}`]
548
- : undefined;
549
- const framed = renderBoxFrame(body, {
550
- width: boxW,
551
- style: "rounded",
552
- borderColor: p.dim,
553
- title: diffTitle(filePath, diff),
554
- footer,
555
- });
828
+ contentGap("diff");
829
+ const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, writer.columns) ?? [];
556
830
  if (!s.renderer)
557
831
  startAgentResponse();
558
- for (const line of framed) {
832
+ for (const line of lines) {
559
833
  s.renderer.writeLine(line);
560
834
  }
561
835
  drain();
@@ -594,25 +868,9 @@ export default function activate(ctx) {
594
868
  }
595
869
  }
596
870
  function showFileDiffCached(entry) {
597
- const { filePath, diff } = entry;
598
- const boxW = Math.min(120, writer.columns);
599
- const contentW = boxW - 4;
600
- const diffLines = renderDiff(diff, {
601
- width: contentW,
602
- filePath,
603
- maxLines: getSettings().diffMaxLines,
604
- trueColor: true,
605
- });
606
- const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
607
- const framed = renderBoxFrame(body, {
608
- width: boxW,
609
- style: "rounded",
610
- borderColor: p.dim,
611
- title: diffTitle(filePath, diff),
612
- footer: [` ${p.dim}ctrl+o to expand${p.reset}`],
613
- });
871
+ const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, writer.columns) ?? [];
614
872
  writer.write("\n");
615
- for (const line of framed) {
873
+ for (const line of lines) {
616
874
  writer.write(line + "\n");
617
875
  }
618
876
  }