agent-sh 0.4.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 (83) hide show
  1. package/README.md +37 -115
  2. package/dist/agent/agent-loop.d.ts +86 -0
  3. package/dist/agent/agent-loop.js +704 -0
  4. package/dist/agent/conversation-state.d.ts +27 -0
  5. package/dist/agent/conversation-state.js +59 -0
  6. package/dist/agent/index.d.ts +11 -0
  7. package/dist/agent/index.js +9 -0
  8. package/dist/agent/skills.d.ts +25 -0
  9. package/dist/agent/skills.js +186 -0
  10. package/dist/agent/subagent.d.ts +37 -0
  11. package/dist/agent/subagent.js +119 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +103 -0
  14. package/dist/agent/tool-registry.d.ts +15 -0
  15. package/dist/agent/tool-registry.js +30 -0
  16. package/dist/agent/tools/bash.d.ts +7 -0
  17. package/dist/agent/tools/bash.js +71 -0
  18. package/dist/agent/tools/display.d.ts +13 -0
  19. package/dist/agent/tools/display.js +70 -0
  20. package/dist/agent/tools/edit-file.d.ts +2 -0
  21. package/dist/agent/tools/edit-file.js +148 -0
  22. package/dist/agent/tools/glob.d.ts +2 -0
  23. package/dist/agent/tools/glob.js +87 -0
  24. package/dist/agent/tools/grep.d.ts +2 -0
  25. package/dist/agent/tools/grep.js +168 -0
  26. package/dist/agent/tools/list-skills.d.ts +2 -0
  27. package/dist/agent/tools/list-skills.js +28 -0
  28. package/dist/agent/tools/ls.d.ts +2 -0
  29. package/dist/agent/tools/ls.js +72 -0
  30. package/dist/agent/tools/read-file.d.ts +10 -0
  31. package/dist/agent/tools/read-file.js +101 -0
  32. package/dist/agent/tools/user-shell.d.ts +13 -0
  33. package/dist/agent/tools/user-shell.js +84 -0
  34. package/dist/agent/tools/write-file.d.ts +2 -0
  35. package/dist/agent/tools/write-file.js +82 -0
  36. package/dist/agent/types.d.ts +78 -0
  37. package/dist/agent/types.js +1 -0
  38. package/dist/core.d.ts +22 -14
  39. package/dist/core.js +256 -36
  40. package/dist/event-bus.d.ts +98 -17
  41. package/dist/event-bus.js +10 -1
  42. package/dist/extension-loader.d.ts +1 -1
  43. package/dist/extension-loader.js +10 -1
  44. package/dist/extensions/command-suggest.d.ts +10 -0
  45. package/dist/extensions/command-suggest.js +41 -0
  46. package/dist/extensions/slash-commands.d.ts +1 -1
  47. package/dist/extensions/slash-commands.js +161 -64
  48. package/dist/extensions/tui-renderer.js +426 -126
  49. package/dist/index.js +110 -129
  50. package/dist/input-handler.js +78 -9
  51. package/dist/output-parser.d.ts +7 -0
  52. package/dist/output-parser.js +27 -0
  53. package/dist/settings.d.ts +53 -2
  54. package/dist/settings.js +46 -3
  55. package/dist/shell.js +35 -28
  56. package/dist/types.d.ts +33 -6
  57. package/dist/utils/box-frame.d.ts +3 -1
  58. package/dist/utils/box-frame.js +12 -5
  59. package/dist/utils/diff.js +10 -0
  60. package/dist/utils/llm-client.d.ts +45 -0
  61. package/dist/utils/llm-client.js +60 -0
  62. package/dist/utils/markdown.d.ts +1 -0
  63. package/dist/utils/markdown.js +25 -3
  64. package/dist/utils/stream-transform.js +20 -47
  65. package/dist/utils/tool-display.d.ts +4 -0
  66. package/dist/utils/tool-display.js +35 -8
  67. package/examples/extensions/claude-code-bridge/README.md +35 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +194 -0
  69. package/examples/extensions/claude-code-bridge/package.json +11 -0
  70. package/examples/extensions/openrouter.ts +87 -0
  71. package/examples/extensions/pi-bridge/README.md +35 -0
  72. package/examples/extensions/pi-bridge/index.ts +263 -0
  73. package/examples/extensions/pi-bridge/package.json +13 -0
  74. package/examples/extensions/secret-guard.ts +100 -0
  75. package/examples/extensions/subagents.ts +87 -0
  76. package/package.json +3 -5
  77. package/dist/acp-client.d.ts +0 -105
  78. package/dist/acp-client.js +0 -684
  79. package/dist/extensions/shell-exec.d.ts +0 -24
  80. package/dist/extensions/shell-exec.js +0 -188
  81. package/dist/mcp-server.d.ts +0 -13
  82. package/dist/mcp-server.js +0 -234
  83. package/examples/pi-agent-sh.ts +0 -166
@@ -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,17 +42,25 @@ 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: {},
48
49
  spinnerInterval: null,
49
50
  spinnerStartTime: 0,
50
- lastCommand: "",
51
51
  toolLineOpen: false,
52
52
  currentToolKind: undefined,
53
+ toolStartTime: 0,
54
+ toolExitCode: null,
53
55
  commandOutputBuffer: "",
54
56
  commandOutputLineCount: 0,
55
57
  commandOutputOverflow: 0,
58
+ commandOverflowLines: [],
59
+ toolGroupKind: undefined,
60
+ toolGroupCount: 0,
61
+ toolGroupAllOk: true,
62
+ toolGroupRendered: 0,
63
+ toolGroupSummaries: [],
56
64
  isThinking: false,
57
65
  showThinkingText: false,
58
66
  thinkingPending: false,
@@ -60,9 +68,12 @@ function createRenderState() {
60
68
  };
61
69
  }
62
70
  export default function activate(ctx) {
63
- const { bus, getAcpClient, define } = ctx;
71
+ const { bus, llmClient, define } = ctx;
64
72
  const writer = new StdoutWriter();
65
73
  const s = createRenderState();
74
+ // Track backend/model info for display on response border
75
+ let backendInfo = null;
76
+ bus.on("agent:info", (info) => { backendInfo = info; });
66
77
  // ── Register fenced block transform (code blocks → ContentBlock) ──
67
78
  // Nobody is special — tui-renderer uses the same primitive as any extension.
68
79
  const fencedTransform = createFencedBlockTransform(bus, {
@@ -75,7 +86,7 @@ export default function activate(ctx) {
75
86
  // ── Event subscriptions ─────────────────────────────────────
76
87
  bus.on("agent:query", (e) => {
77
88
  s.spinnerStartTime = 0;
78
- showUserQuery(e.query, e.modeLabel);
89
+ showUserQuery(e.query);
79
90
  startAgentResponse();
80
91
  startThinkingSpinner();
81
92
  });
@@ -104,70 +115,152 @@ export default function activate(ctx) {
104
115
  }
105
116
  });
106
117
  bus.on("agent:response-chunk", (e) => {
107
- if (e.blocks) {
108
- // Inject spacing: append \n to text blocks that precede non-text blocks
109
- const blocks = e.blocks;
110
- for (let i = 0; i < blocks.length; i++) {
111
- const block = blocks[i];
112
- const next = blocks[i + 1];
113
- if (block.type === "text" && next && next.type !== "text") {
114
- block.text += "\n";
115
- }
116
- }
117
- for (const block of blocks) {
118
- switch (block.type) {
119
- case "text":
120
- if (block.text)
121
- writeAgentText(block.text);
122
- break;
123
- case "code-block":
124
- writeCodeBlock(block.language, block.code);
125
- break;
126
- case "image":
127
- writeInlineImage(block.data);
128
- break;
129
- case "raw":
130
- flushForRaw();
131
- writer.write(block.escape);
132
- break;
133
- }
118
+ const { blocks } = e;
119
+ // Inject spacing: append \n to text blocks that precede non-text blocks
120
+ for (let i = 0; i < blocks.length; i++) {
121
+ const block = blocks[i];
122
+ const next = blocks[i + 1];
123
+ if (block.type === "text" && next && next.type !== "text") {
124
+ block.text += "\n";
134
125
  }
135
126
  }
136
- else {
137
- writeAgentText(e.text);
127
+ for (const block of blocks) {
128
+ switch (block.type) {
129
+ case "text":
130
+ if (block.text)
131
+ writeAgentText(block.text);
132
+ break;
133
+ case "code-block":
134
+ writeCodeBlock(block.language, block.code);
135
+ break;
136
+ case "image":
137
+ writeInlineImage(block.data);
138
+ break;
139
+ case "raw":
140
+ flushForRaw();
141
+ writer.write(block.escape);
142
+ break;
143
+ }
138
144
  }
139
145
  });
146
+ // Track token usage for display
147
+ let pendingUsage = null;
148
+ bus.on("agent:usage", (e) => { pendingUsage = e; });
140
149
  bus.on("agent:response-done", () => {
141
150
  s.isThinking = false;
151
+ if (pendingUsage && s.renderer) {
152
+ const { prompt_tokens, completion_tokens } = pendingUsage;
153
+ const maxTokens = backendInfo?.contextWindow ?? 128_000;
154
+ // prompt_tokens of the latest call = current context usage
155
+ // (it includes the full conversation history)
156
+ const ctxK = (prompt_tokens / 1000).toFixed(1);
157
+ const maxK = (maxTokens / 1000).toFixed(0);
158
+ const pct = Math.min(100, (prompt_tokens / maxTokens) * 100).toFixed(0);
159
+ s.renderer.writeLine("");
160
+ s.renderer.writeLine(`${p.dim}⬆ ${prompt_tokens} ⬇ ${completion_tokens} ctx: ${ctxK}k/${maxK}k (${pct}%)${p.reset}`);
161
+ drain();
162
+ pendingUsage = null;
163
+ }
142
164
  endAgentResponse();
143
165
  });
144
- bus.on("agent:tool-call", (e) => {
145
- s.lastCommand = e.tool;
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
+ }
146
183
  });
147
184
  bus.on("agent:tool-started", (e) => {
148
185
  fencedTransform.flush();
149
186
  stopCurrentSpinner();
150
187
  s.currentToolKind = e.kind;
188
+ s.toolStartTime = Date.now();
151
189
  if (e.title === "user_shell") {
190
+ finalizeToolGroup();
152
191
  closeToolLine();
153
192
  if (!s.renderer)
154
193
  startAgentResponse();
194
+ contentGap("tool");
155
195
  s.renderer.flush();
156
196
  const cmd = e.rawInput?.command || "";
157
197
  s.renderer.writeLine(`${p.dim}▶ user_shell: ${cmd}${p.reset}`);
158
198
  drain();
159
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
+ }
160
236
  }
161
237
  else {
162
- showToolCall(e.title, s.lastCommand, 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
+ });
163
245
  }
164
- s.lastCommand = "";
165
246
  });
166
247
  bus.on("agent:tool-completed", (e) => {
167
- showToolComplete(e.exitCode);
168
- s.currentToolKind = undefined;
169
- s.spinnerStartTime = 0;
170
- 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
+ }
171
264
  });
172
265
  bus.on("agent:tool-output-chunk", (e) => writeCommandOutput(e.chunk));
173
266
  bus.on("agent:tool-output", () => flushCommandOutput());
@@ -182,7 +275,16 @@ export default function activate(ctx) {
182
275
  stopCurrentSpinner();
183
276
  endAgentResponse();
184
277
  });
185
- bus.on("agent:error", (e) => showError(e.message));
278
+ bus.on("agent:error", (e) => {
279
+ stopCurrentSpinner();
280
+ showCollapsedThinking();
281
+ if (!s.renderer)
282
+ startAgentResponse();
283
+ contentGap("info");
284
+ s.renderer.writeLine(`${p.error}Error: ${e.message}${p.reset}`);
285
+ s.renderer.writeLine("");
286
+ drain();
287
+ });
186
288
  bus.on("permission:request", (e) => {
187
289
  stopCurrentSpinner();
188
290
  flushCommandOutput();
@@ -191,11 +293,12 @@ export default function activate(ctx) {
191
293
  drain();
192
294
  }
193
295
  if (e.kind === "file-write" && e.metadata?.diff) {
296
+ showCollapsedThinking();
194
297
  showFileDiff(e.title, e.metadata.diff);
195
298
  }
196
- else {
197
- endAgentResponse();
198
- }
299
+ // Don't endAgentResponse() here — permission requests that aren't
300
+ // file-write diffs are handled inline (auto-approved or by extensions).
301
+ // Closing the response prematurely causes double separator borders.
199
302
  });
200
303
  bus.on("input:keypress", (e) => {
201
304
  if (e.key === "\x0f")
@@ -203,44 +306,74 @@ export default function activate(ctx) {
203
306
  if (e.key === "\x14")
204
307
  toggleThinkingDisplay(); // Ctrl+T
205
308
  });
206
- bus.on("ui:info", (e) => showInfo(e.message));
309
+ bus.on("ui:info", (e) => {
310
+ stopCurrentSpinner();
311
+ showInfo(e.message);
312
+ // Restart spinner if agent is still processing
313
+ if (s.renderer)
314
+ startThinkingSpinner();
315
+ });
207
316
  bus.on("ui:error", (e) => showError(e.message));
317
+ bus.on("ui:suggestion", (e) => {
318
+ writer.write(`${p.dim}💡 ${e.text}${p.reset}\n`);
319
+ });
208
320
  // ── Rendering functions ─────────────────────────────────────
209
321
  function drain() {
210
322
  if (!s.renderer)
211
323
  return;
212
324
  for (const line of s.renderer.drainLines()) {
213
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() === "";
214
329
  }
215
330
  }
216
331
  function startAgentResponse() {
217
332
  s.renderer = new MarkdownRenderer(writer.columns);
218
333
  s.hadToolCalls = false;
334
+ // Preserve lastContentKind across responses so text→tool gaps work
219
335
  s.renderer.printTopBorder();
220
336
  drain();
221
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
+ }
222
354
  function showCollapsedThinking() {
223
355
  if (s.thinkingPending && !s.showThinkingText) {
224
- if (!s.renderer)
225
- startAgentResponse();
226
- s.renderer.writeLine(`${p.muted}… thinking${p.reset}`);
356
+ // Just clear the pending flag — the spinner already indicates thinking.
357
+ // No need for a separate "… thinking" label that clutters the output.
227
358
  s.thinkingPending = false;
228
359
  }
229
360
  }
230
361
  function endAgentResponse() {
362
+ finalizeToolGroup();
231
363
  closeToolLine();
232
364
  stopCurrentSpinner();
233
365
  if (s.renderer) {
234
366
  s.renderer.flush();
235
367
  s.renderer.printBottomBorder();
236
368
  drain();
369
+ writer.write("\n");
237
370
  s.renderer = null;
238
371
  }
239
372
  }
240
- function showUserQuery(query, modeLabel) {
241
- const boxW = Math.min(84, writer.columns);
373
+ function showUserQuery(query) {
374
+ const boxW = writer.columns;
242
375
  const contentW = boxW - 4;
243
- const lines = [];
376
+ let lines = [];
244
377
  for (const raw of query.split("\n")) {
245
378
  if (raw.length <= contentW) {
246
379
  lines.push(`${p.accent}${raw}${p.reset}`);
@@ -258,17 +391,37 @@ export default function activate(ctx) {
258
391
  lines.push(`${p.accent}${remaining}${p.reset}`);
259
392
  }
260
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
+ }
261
403
  // Mode-specific border color and title
262
- const isExecute = modeLabel === "Execute";
263
- const borderColor = isExecute ? p.success : p.accent;
264
- const title = modeLabel
265
- ? `${borderColor}${p.bold} ${modeLabel} ${p.reset}`
266
- : `${p.accent}${p.bold}❯${p.reset}`;
404
+ const borderColor = p.accent;
405
+ const title = `${p.accent}${p.bold}❯${p.reset}`;
406
+ // Backend/model label on the right (backend/model, highlighted)
407
+ const model = backendInfo?.model ?? llmClient?.model;
408
+ const backend = backendInfo?.name;
409
+ let modelLabel;
410
+ if (backend && model) {
411
+ modelLabel = `${p.dim}${backend}/${p.reset}${p.bold}${model}${p.reset}`;
412
+ }
413
+ else if (model) {
414
+ modelLabel = `${p.bold}${model}${p.reset}`;
415
+ }
416
+ else if (backend) {
417
+ modelLabel = `${p.bold}${backend}${p.reset}`;
418
+ }
267
419
  const framed = renderBoxFrame(lines, {
268
420
  width: boxW,
269
421
  style: "rounded",
270
422
  borderColor,
271
423
  title,
424
+ titleRight: modelLabel,
272
425
  });
273
426
  writer.write("\n");
274
427
  for (const line of framed) {
@@ -276,8 +429,8 @@ export default function activate(ctx) {
276
429
  }
277
430
  }
278
431
  function writeAgentText(text) {
432
+ finalizeToolGroup();
279
433
  closeToolLine();
280
- const needsGap = s.hadToolCalls;
281
434
  s.hadToolCalls = false;
282
435
  if (s.isThinking) {
283
436
  s.isThinking = false;
@@ -292,28 +445,24 @@ export default function activate(ctx) {
292
445
  stopCurrentSpinner();
293
446
  if (!s.renderer)
294
447
  startAgentResponse();
295
- if (needsGap)
296
- writer.write("\n");
448
+ contentGap("text");
297
449
  s.renderer.push(text);
298
450
  drain();
299
451
  }
300
452
  define("render:code-block", (language, code, width) => {
301
453
  flushForRaw();
454
+ contentGap("code");
302
455
  if (language) {
303
456
  s.renderer.writeLine(`${p.dim}${language}${p.reset}`);
304
457
  }
305
458
  let highlighted;
306
- if (!language) {
307
- // No language specified — render as plain text to avoid false syntax detection
308
- highlighted = code;
459
+ try {
460
+ highlighted = language
461
+ ? highlight(code, { language })
462
+ : highlight(code); // auto-detect
309
463
  }
310
- else {
311
- try {
312
- highlighted = highlight(code, { language });
313
- }
314
- catch {
315
- highlighted = `${p.success}${code}${p.reset}`;
316
- }
464
+ catch {
465
+ highlighted = code;
317
466
  }
318
467
  const contentWidth = Math.min(90, width - 2);
319
468
  for (const line of highlighted.split("\n")) {
@@ -346,41 +495,168 @@ export default function activate(ctx) {
346
495
  function writeInlineImage(data) {
347
496
  ctx.call("render:image", data);
348
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
+ }
349
597
  function showToolCall(title, command, extra) {
350
598
  closeToolLine();
351
599
  stopCurrentSpinner();
352
600
  if (!s.renderer)
353
601
  startAgentResponse();
354
602
  showCollapsedThinking();
603
+ // No gap between grouped tools — they're visually connected
604
+ if (!extra?.groupContinuation)
605
+ contentGap("tool");
355
606
  s.renderer.flush();
356
607
  drain();
357
608
  const lines = renderToolCall({
358
609
  title,
359
610
  command: command || undefined,
360
611
  kind: extra?.kind,
612
+ icon: extra?.icon,
361
613
  locations: extra?.locations,
362
614
  rawInput: extra?.rawInput,
615
+ displayDetail: extra?.displayDetail,
363
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 = "";
364
628
  for (let i = 0; i < lines.length - 1; i++) {
365
629
  s.renderer.writeLine(lines[i]);
366
630
  }
367
631
  drain();
368
632
  if (lines.length > 0) {
369
- writer.write(` ${lines[lines.length - 1]}`);
370
- 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
+ }
371
643
  }
372
644
  s.hadToolCalls = true;
373
645
  s.commandOutputLineCount = 0;
374
646
  s.commandOutputOverflow = 0;
375
647
  }
376
- function showToolComplete(exitCode) {
648
+ function showToolComplete(exitCode, resultDisplay) {
377
649
  if (!s.renderer)
378
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}` : "";
379
655
  const mark = exitCode === null
380
656
  ? `${p.muted}(timed out)${p.reset}`
381
657
  : exitCode === 0
382
- ? `${p.success}✓${p.reset}`
383
- : `${p.error}✗ exit ${exitCode}${p.reset}`;
658
+ ? `${p.success}✓${p.reset}${summary}${timer}`
659
+ : `${p.error}✗ exit ${exitCode}${p.reset}${summary}${timer}`;
384
660
  if (s.toolLineOpen && s.commandOutputLineCount === 0) {
385
661
  writer.write(` ${mark}\n`);
386
662
  s.toolLineOpen = false;
@@ -391,10 +667,25 @@ export default function activate(ctx) {
391
667
  s.renderer.writeLine(` ${mark}`);
392
668
  drain();
393
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();
394
684
  }
685
+ // Thinking is always assumed available — the TUI renders thinking
686
+ // tokens whenever they arrive, regardless of backend.
395
687
  function hasThinkingMode() {
396
- const mode = getAcpClient().getCurrentMode();
397
- return !mode || mode.id !== "off";
688
+ return true;
398
689
  }
399
690
  function startThinkingSpinner() {
400
691
  if (!s.spinnerStartTime)
@@ -430,6 +721,40 @@ export default function activate(ctx) {
430
721
  s.toolLineOpen = false;
431
722
  }
432
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
+ }
433
758
  function writeCommandOutput(chunk) {
434
759
  if (!s.renderer)
435
760
  return;
@@ -447,10 +772,13 @@ export default function activate(ctx) {
447
772
  }
448
773
  else {
449
774
  s.commandOutputOverflow++;
775
+ s.commandOverflowLines.push(line);
450
776
  }
451
777
  }
452
778
  drain();
453
779
  }
780
+ /** Max overflow lines to show when a command fails. */
781
+ const FAIL_OVERFLOW_MAX = 20;
454
782
  function flushCommandOutput() {
455
783
  if (!s.renderer)
456
784
  return;
@@ -464,13 +792,28 @@ export default function activate(ctx) {
464
792
  }
465
793
  else {
466
794
  s.commandOutputOverflow++;
795
+ s.commandOverflowLines.push(s.commandOutputBuffer);
467
796
  }
468
797
  s.commandOutputBuffer = "";
469
798
  }
470
- 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) {
471
812
  s.renderer.writeLine(`${p.dim} … ${s.commandOutputOverflow} more lines${p.reset}`);
472
813
  }
473
814
  s.commandOutputOverflow = 0;
815
+ s.commandOverflowLines = [];
816
+ s.toolExitCode = null;
474
817
  drain();
475
818
  }
476
819
  function diffTitle(filePath, diff) {
@@ -482,37 +825,11 @@ export default function activate(ctx) {
482
825
  function showFileDiff(filePath, diff) {
483
826
  if (diff.isIdentical)
484
827
  return;
485
- const boxW = Math.min(84, writer.columns);
486
- const contentW = boxW - 4;
487
- const diffLines = renderDiff(diff, {
488
- width: contentW,
489
- filePath,
490
- maxLines: getSettings().diffMaxLines,
491
- trueColor: true,
492
- mode: "unified",
493
- });
494
- const lastLine = diffLines[diffLines.length - 1] ?? "";
495
- const isTruncated = lastLine.includes("… ");
496
- if (isTruncated) {
497
- s.lastTruncatedDiff = { filePath, diff, expanded: false };
498
- }
499
- else {
500
- s.lastTruncatedDiff = null;
501
- }
502
- const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
503
- const footer = isTruncated
504
- ? [` ${p.dim}ctrl+o to expand${p.reset}`]
505
- : undefined;
506
- const framed = renderBoxFrame(body, {
507
- width: boxW,
508
- style: "rounded",
509
- borderColor: p.dim,
510
- title: diffTitle(filePath, diff),
511
- footer,
512
- });
828
+ contentGap("diff");
829
+ const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, writer.columns) ?? [];
513
830
  if (!s.renderer)
514
831
  startAgentResponse();
515
- for (const line of framed) {
832
+ for (const line of lines) {
516
833
  s.renderer.writeLine(line);
517
834
  }
518
835
  drain();
@@ -551,26 +868,9 @@ export default function activate(ctx) {
551
868
  }
552
869
  }
553
870
  function showFileDiffCached(entry) {
554
- const { filePath, diff } = entry;
555
- const boxW = Math.min(84, writer.columns);
556
- const contentW = boxW - 4;
557
- const diffLines = renderDiff(diff, {
558
- width: contentW,
559
- filePath,
560
- maxLines: getSettings().diffMaxLines,
561
- trueColor: true,
562
- mode: "unified",
563
- });
564
- const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
565
- const framed = renderBoxFrame(body, {
566
- width: boxW,
567
- style: "rounded",
568
- borderColor: p.dim,
569
- title: diffTitle(filePath, diff),
570
- footer: [` ${p.dim}ctrl+o to expand${p.reset}`],
571
- });
871
+ const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, writer.columns) ?? [];
572
872
  writer.write("\n");
573
- for (const line of framed) {
873
+ for (const line of lines) {
574
874
  writer.write(line + "\n");
575
875
  }
576
876
  }