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