agent-sh 0.15.6 → 0.15.8

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.d.ts +3 -0
  4. package/dist/agent/agent-loop.js +19 -6
  5. package/dist/agent/events.d.ts +3 -0
  6. package/dist/agent/extensions/rolling-history/index.js +20 -8
  7. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  8. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  9. package/dist/agent/host-types.d.ts +6 -0
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/llm-client.d.ts +2 -0
  12. package/dist/agent/llm-client.js +2 -2
  13. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  14. package/dist/agent/providers/openai-compatible.js +9 -2
  15. package/dist/agent/providers/openrouter.js +11 -1
  16. package/dist/agent/store.js +6 -1
  17. package/dist/agent/token-budget.d.ts +2 -1
  18. package/dist/agent/token-budget.js +6 -1
  19. package/dist/cli/index.js +1 -1
  20. package/dist/core/event-bus.d.ts +16 -1
  21. package/dist/core/event-bus.js +73 -11
  22. package/dist/core/index.js +18 -0
  23. package/dist/shell/strategies/bash.js +10 -2
  24. package/dist/shell/tui-renderer.js +115 -174
  25. package/dist/utils/executor.js +19 -11
  26. package/dist/utils/floating-panel.d.ts +1 -0
  27. package/dist/utils/floating-panel.js +28 -26
  28. package/dist/utils/markdown.js +19 -21
  29. package/dist/utils/palette.d.ts +11 -0
  30. package/dist/utils/palette.js +11 -0
  31. package/docs/agent.md +13 -11
  32. package/docs/architecture.md +3 -5
  33. package/docs/extensions.md +21 -20
  34. package/docs/library.md +6 -3
  35. package/docs/troubleshooting.md +2 -2
  36. package/docs/tui-composition.md +11 -3
  37. package/docs/usage.md +70 -50
  38. package/examples/extensions/ashi/package.json +1 -1
  39. package/examples/extensions/ashi/src/chat/assistant.ts +8 -4
  40. package/examples/extensions/ashi/src/cli.ts +8 -0
  41. package/examples/extensions/ashi/src/compaction.ts +4 -7
  42. package/examples/extensions/ashi/src/frontend.ts +6 -3
  43. package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
  45. package/examples/extensions/ashi/src/schema.ts +8 -2
  46. package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
  47. package/examples/extensions/command-suggest.ts +4 -0
  48. package/examples/extensions/latex-images.ts +152 -7
  49. package/examples/extensions/solarized-theme.ts +11 -0
  50. package/package.json +1 -1
  51. package/src/agent/agent-loop.ts +19 -6
  52. package/src/agent/events.ts +1 -0
  53. package/src/agent/extensions/rolling-history/index.ts +20 -8
  54. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  55. package/src/agent/host-types.ts +2 -0
  56. package/src/agent/index.ts +7 -1
  57. package/src/agent/llm-client.ts +4 -2
  58. package/src/agent/providers/openai-compatible.ts +19 -4
  59. package/src/agent/providers/openrouter.ts +10 -1
  60. package/src/agent/store.ts +5 -1
  61. package/src/agent/token-budget.ts +10 -1
  62. package/src/cli/index.ts +1 -1
  63. package/src/core/event-bus.ts +67 -12
  64. package/src/core/index.ts +18 -0
  65. package/src/shell/strategies/bash.ts +10 -2
  66. package/src/shell/tui-renderer.ts +130 -207
  67. package/src/utils/executor.ts +17 -14
  68. package/src/utils/floating-panel.ts +24 -22
  69. package/src/utils/markdown.ts +17 -20
  70. package/src/utils/palette.ts +30 -5
@@ -71,12 +71,9 @@ interface RenderState {
71
71
 
72
72
  // ── Tool output ──
73
73
  openTool: { callId: string; title: string; kind?: string; displayDetail?: string } | null;
74
- /** Tools whose start line was closed before their complete fired.
75
- * Their ✓ renders as a labeled ⎿ line instead of an orphan.
76
- * `orphaned` = the group was finalized before they returned, so the
77
- * ⎿ renders under a re-emitted "(cont.)" header to avoid looking
78
- * like a child of whatever tool rendered in between. */
79
- pendingToolCompletes: Map<string, { title: string; kind?: string; displayDetail?: string; orphaned?: boolean }>;
74
+ /** Tools whose start line was closed before their complete fired
75
+ * their ✓ renders as a labeled ⎿ line instead of inline. */
76
+ pendingToolCompletes: Map<string, { title: string; kind?: string; displayDetail?: string }>;
80
77
  currentToolKind: string | undefined;
81
78
  toolStartTime: number;
82
79
  toolExitCode: number | null;
@@ -85,21 +82,6 @@ interface RenderState {
85
82
  commandOutputOverflow: number;
86
83
  commandOverflowLines: string[];
87
84
 
88
- /** Consecutive orphans of the same kind share one "(cont.)" header;
89
- * cleared when any non-orphan render happens. */
90
- orphanContHeaderKind: string | undefined;
91
-
92
- // ── Tool grouping (collapse sequential same-type read-only tools) ──
93
- toolGroupKind: string | undefined;
94
- toolGroupCount: number;
95
- /** Completes-seen count — skip aggregate if finalize fires at 0. */
96
- toolGroupCompletedCount: number;
97
- toolGroupAllOk: boolean;
98
- /** Number of tools rendered individually in current group. */
99
- toolGroupRendered: number;
100
- /** Accumulated result summaries from grouped tools. */
101
- toolGroupSummaries: string[];
102
-
103
85
  // ── Thinking ──
104
86
  isThinking: boolean;
105
87
  showThinkingText: boolean;
@@ -118,7 +100,6 @@ function createRenderState(): RenderState {
118
100
  spinnerStartTime: 0,
119
101
  openTool: null,
120
102
  pendingToolCompletes: new Map(),
121
- orphanContHeaderKind: undefined,
122
103
  currentToolKind: undefined,
123
104
  toolStartTime: 0,
124
105
  toolExitCode: null,
@@ -126,12 +107,6 @@ function createRenderState(): RenderState {
126
107
  commandOutputLineCount: 0,
127
108
  commandOutputOverflow: 0,
128
109
  commandOverflowLines: [],
129
- toolGroupKind: undefined,
130
- toolGroupCount: 0,
131
- toolGroupCompletedCount: 0,
132
- toolGroupAllOk: true,
133
- toolGroupRendered: 0,
134
- toolGroupSummaries: [],
135
110
  isThinking: false,
136
111
  showThinkingText: false,
137
112
  thinkingPending: false,
@@ -195,16 +170,6 @@ export default function activate(ctx: ExtensionContext): void {
195
170
  return `${p.error}✗ exit ${exitCode}${p.reset}${summaryStr}${timer}`;
196
171
  });
197
172
 
198
- define("tui:render-tool-group-summary", (count: number, rendered: number, allOk: boolean, summaries: string[]): string => {
199
- const mark = allOk ? `${p.success}✓${p.reset}` : `${p.error}✗${p.reset}`;
200
- const summaryStr = summaries.length > 0 ? ` ${p.dim}${summaries.join(", ")}${p.reset}` : "";
201
- const collapsed = count - rendered;
202
- if (collapsed > 0) {
203
- return ` ${p.muted}└${p.reset} ${p.dim}+${collapsed} more${p.reset} ${mark}${summaryStr}`;
204
- }
205
- return ` ${p.muted}└${p.reset} ${mark}${summaryStr}`;
206
- });
207
-
208
173
  define("tui:render-command-output", (line: string, _kind: string | undefined): string =>
209
174
  `${p.dim} ${line}${p.reset}`);
210
175
 
@@ -333,34 +298,48 @@ export default function activate(ctx: ExtensionContext): void {
333
298
  const GROUP_MAX_VISIBLE = 5;
334
299
  const KIND_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
335
300
 
336
- // Batch groups: kind { total, rendered, headerShown }
337
- let batchGroups = new Map<string, { total: number; rendered: number; headerShown: boolean }>();
301
+ // Read-only batch tools run in parallel, so their events arrive interleaved
302
+ // and append-only output can't un-interleave them. Buffer each group instead
303
+ // of streaming; mutating/streaming tools are sequential and still render live.
304
+ interface GroupMember { detail: string; ok: boolean; summary?: string; done: boolean; }
305
+ interface GroupState {
306
+ total: number;
307
+ completed: number;
308
+ finalized: boolean;
309
+ order: string[]; // callIds, in start order
310
+ members: Map<string, GroupMember>;
311
+ }
312
+ let batchGroups = new Map<string, GroupState>();
313
+ let batchSize = 0;
314
+
315
+ /** A lone tool has nothing to interleave with, so deferral applies only to
316
+ * read-only kinds in a multi-tool batch. */
317
+ function isDeferred(kind: string): boolean {
318
+ return batchSize > 1 && GROUPABLE_KINDS.has(kind) && batchGroups.has(kind);
319
+ }
338
320
 
339
321
  bus.on("agent:tool-batch", (e) => {
340
322
  if (!shouldRender()) return;
341
323
  fencedTransform.flush();
342
- finalizeToolGroup();
343
- s.orphanContHeaderKind = undefined;
324
+ finalizeAllGroups();
325
+ closeToolLine();
344
326
  batchGroups = new Map();
327
+ batchSize = 0;
345
328
  for (const group of e.groups) {
346
- batchGroups.set(group.kind, {
347
- total: group.tools.length,
348
- rendered: 0,
349
- headerShown: false,
350
- });
329
+ batchSize += group.tools.length;
330
+ batchGroups.set(group.kind, { total: group.tools.length, completed: 0, finalized: false, order: [], members: new Map() });
351
331
  }
352
332
  });
353
333
 
354
334
  bus.on("agent:tool-started", (e) => {
355
335
  if (!shouldRender()) return;
356
336
  fencedTransform.flush();
357
- stopCurrentSpinner();
358
337
  s.currentToolKind = e.kind;
359
338
  s.toolStartTime = Date.now();
360
- s.orphanContHeaderKind = undefined;
361
339
 
362
340
  if (e.title === "user_shell") {
363
- finalizeToolGroup();
341
+ stopCurrentSpinner();
342
+ finalizeAllGroups();
364
343
  closeToolLine();
365
344
  if (!s.renderer) startAgentResponse();
366
345
  contentGap("tool");
@@ -373,88 +352,55 @@ export default function activate(ctx: ExtensionContext): void {
373
352
  }
374
353
 
375
354
  const kind = e.kind ?? "execute";
376
- const group = batchGroups.get(kind);
377
- const isGrouped = group && group.total > 1 && GROUPABLE_KINDS.has(kind);
378
-
379
- if (isGrouped) {
380
- // Render group header on first tool of this kind in the batch
381
- if (!group.headerShown) {
382
- finalizeToolGroup();
383
- closeToolLine();
384
- if (!s.renderer) startAgentResponse();
385
- showCollapsedThinking();
386
- contentGap("tool");
387
- s.renderer!.flush();
388
- drain();
389
-
390
- const icon = KIND_ICONS[kind] ?? "▶";
391
- s.renderer!.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
392
- drain();
393
-
394
- group.headerShown = true;
395
- s.toolGroupKind = kind;
396
- s.toolGroupCount = 0;
397
- s.toolGroupCompletedCount = 0;
398
- s.toolGroupRendered = 0;
399
- s.toolGroupAllOk = true;
400
- s.toolGroupSummaries = [];
401
- }
402
-
403
- s.toolGroupCount++;
404
-
405
- if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
406
- showToolCall(e.title, "", { ...e, groupContinuation: true });
407
- s.toolGroupRendered++;
408
- }
409
- if (e.toolCallId) {
410
- s.pendingToolCompletes.set(e.toolCallId, {
411
- title: e.title,
412
- kind,
413
- displayDetail: e.displayDetail ?? extractDetail(e),
414
- });
415
- }
416
- } else {
417
- // Standalone tool — single in its batch kind, or not groupable
418
- finalizeToolGroup();
419
- showToolCall(e.title, "", { ...e });
355
+ if (isDeferred(kind)) {
356
+ const group = batchGroups.get(kind)!;
357
+ const id = e.toolCallId ?? `${kind}-${group.order.length}`;
358
+ group.order.push(id);
359
+ group.members.set(id, { detail: e.displayDetail ?? extractDetail(e), ok: true, done: false });
360
+ s.hadToolCalls = true;
361
+ if (!s.spinner) startThinkingSpinner(); // nothing's drawn yet — stand in until the block renders
362
+ return;
420
363
  }
364
+
365
+ // Eager: a single-tool turn, or a sequential mutating/streaming tool.
366
+ stopCurrentSpinner();
367
+ showToolCall(e.title, "", { ...e });
421
368
  });
422
369
 
423
370
  bus.on("agent:tool-completed", (e) => {
424
371
  if (!shouldRender()) return;
425
372
  s.toolExitCode = e.exitCode;
426
- if (e.exitCode !== 0) s.toolGroupAllOk = false;
427
-
428
373
  const resultDisplay = e.resultDisplay;
374
+ const kind = e.kind ?? "execute";
375
+ const group = batchGroups.get(kind);
429
376
 
430
- if (s.toolGroupKind) {
431
- // Grouped tool track success/failure and summaries, show aggregate on ⎿ line.
432
- // Don't restart spinner between grouped tools — it's already running from group start.
433
- if (resultDisplay?.summary) s.toolGroupSummaries.push(resultDisplay.summary);
434
- if (e.toolCallId) s.pendingToolCompletes.delete(e.toolCallId);
435
- s.toolGroupCompletedCount++;
436
- s.currentToolKind = undefined;
437
- // Finalize as soon as all members return so aggregate lands right
438
- // after its children, not below out-of-band renders from the next tool.
439
- const batchGroup = batchGroups.get(s.toolGroupKind);
440
- if (batchGroup && s.toolGroupCompletedCount >= batchGroup.total) {
441
- finalizeToolGroup();
377
+ if (isDeferred(kind) && group) {
378
+ const id = e.toolCallId ?? group.order[group.completed];
379
+ const member = id ? group.members.get(id) : undefined;
380
+ if (member) {
381
+ member.done = true;
382
+ member.ok = e.exitCode === 0;
383
+ member.summary = resultDisplay?.summary;
442
384
  }
385
+ group.completed++;
386
+ s.currentToolKind = undefined;
387
+ flushReadyGroups();
388
+ if (!s.spinner) startThinkingSpinner(); // keep the spinner up for in-flight siblings
443
389
  } else {
444
- // Tools that lost the inline slot render as a labeled ⎿. Orphans
445
- // (group finalized before they returned) reroute via showOrphanedComplete.
446
390
  const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
447
391
  if (pending) s.pendingToolCompletes.delete(e.toolCallId!);
448
- if (pending?.orphaned) {
449
- showOrphanedComplete(e.exitCode, resultDisplay, pending.title, pending.kind, pending.displayDetail);
450
- } else {
451
- showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
452
- }
392
+ showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
453
393
  s.currentToolKind = undefined;
454
394
  s.spinnerStartTime = 0;
455
395
  startThinkingSpinner();
456
396
  }
457
397
  });
398
+
399
+ bus.on("agent:tool-batch-complete", () => {
400
+ if (!shouldRender()) return;
401
+ // Backstop for a group whose members didn't all complete (e.g. errored).
402
+ finalizeAllGroups();
403
+ });
458
404
  bus.on("agent:tool-output-chunk", (e) => { if (shouldRender()) writeCommandOutput(e.chunk); });
459
405
  bus.on("agent:tool-output", () => { if (shouldRender()) flushCommandOutput(); });
460
406
 
@@ -552,7 +498,7 @@ export default function activate(ctx: ExtensionContext): void {
552
498
  }
553
499
 
554
500
  function endAgentResponse(): void {
555
- finalizeToolGroup();
501
+ finalizeAllGroups();
556
502
  closeToolLine();
557
503
  stopCurrentSpinner();
558
504
  if (s.renderer) {
@@ -589,7 +535,7 @@ export default function activate(ctx: ExtensionContext): void {
589
535
  }
590
536
 
591
537
  function writeAgentText(text: string): void {
592
- finalizeToolGroup();
538
+ finalizeAllGroups();
593
539
  closeToolLine();
594
540
  s.hadToolCalls = false;
595
541
  if (s.isThinking) {
@@ -613,7 +559,7 @@ export default function activate(ctx: ExtensionContext): void {
613
559
  flushForRaw();
614
560
  contentGap("code");
615
561
  if (language) {
616
- s.renderer!.writeLine(`${p.dim}${language}${p.reset}`);
562
+ s.renderer!.writeLine(`${p.mdCodeBlockBorder}${language}${p.reset}`);
617
563
  }
618
564
  let highlighted: string;
619
565
  try {
@@ -633,7 +579,10 @@ export default function activate(ctx: ExtensionContext): void {
633
579
  console.error = origError;
634
580
  }
635
581
  } catch {
636
- highlighted = code;
582
+ highlighted = code
583
+ .split("\n")
584
+ .map((l) => `${p.mdCodeBlock}${l}${p.reset}`)
585
+ .join("\n");
637
586
  }
638
587
  const contentWidth = Math.min(90, width - 2);
639
588
  for (const line of highlighted.split("\n")) {
@@ -800,15 +749,13 @@ export default function activate(ctx: ExtensionContext): void {
800
749
  displayDetail?: string;
801
750
  batchIndex?: number;
802
751
  batchTotal?: number;
803
- groupContinuation?: boolean;
804
752
  },
805
753
  ): void {
806
754
  closeToolLine();
807
755
  stopCurrentSpinner();
808
756
  if (!s.renderer) startAgentResponse();
809
757
  showCollapsedThinking();
810
- // No gap between grouped tools — they're visually connected
811
- if (!extra?.groupContinuation) contentGap("tool");
758
+ contentGap("tool");
812
759
  s.renderer!.flush();
813
760
  drain();
814
761
  const lines = renderToolCall({
@@ -821,38 +768,19 @@ export default function activate(ctx: ExtensionContext): void {
821
768
  displayDetail: extra?.displayDetail,
822
769
  }, cappedW(), shellCwd);
823
770
 
824
- if (extra?.groupContinuation && lines.length > 0) {
825
- // Swap the colored kind icon for a muted tree connector,
826
- // and strip the tool name prefix — show detail only.
827
- const detail = extra.displayDetail || extractDetail(extra);
828
- const maxW = Math.max(1, cappedW() - 6);
829
- const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
830
- lines[0] = detail
831
- ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
832
- : lines[0]!.replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
833
- }
834
-
835
- const batchPrefix = "";
836
-
837
771
  for (let i = 0; i < lines.length - 1; i++) {
838
772
  s.renderer!.writeLine(lines[i]!);
839
773
  }
840
774
  drain();
841
775
  if (lines.length > 0) {
842
- if (extra?.groupContinuation) {
843
- // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
844
- s.renderer!.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
845
- drain();
846
- } else {
847
- out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
848
- if (extra?.toolCallId) {
849
- s.openTool = {
850
- callId: extra.toolCallId,
851
- title,
852
- kind: extra.kind,
853
- displayDetail: extra.displayDetail ?? extractDetail(extra),
854
- };
855
- }
776
+ out().write(` ${lines[lines.length - 1]}`);
777
+ if (extra?.toolCallId) {
778
+ s.openTool = {
779
+ callId: extra.toolCallId,
780
+ title,
781
+ kind: extra.kind,
782
+ displayDetail: extra.displayDetail ?? extractDetail(extra),
783
+ };
856
784
  }
857
785
  }
858
786
  s.hadToolCalls = true;
@@ -885,32 +813,6 @@ export default function activate(ctx: ExtensionContext): void {
885
813
  if (resultDisplay?.body) renderResultBody(resultDisplay.body);
886
814
  }
887
815
 
888
- /** Late completion from a finalized group — re-emit the kind header
889
- * in muted "(cont.)" form so the ⎿ has a legitimate parent, then
890
- * render the completion as a normal labeled ⎿. Subsequent orphans
891
- * of the same kind reuse the existing (cont.) header. */
892
- function showOrphanedComplete(
893
- exitCode: number | null,
894
- resultDisplay: ToolResultDisplay | undefined,
895
- title: string,
896
- kind: string | undefined,
897
- displayDetail: string | undefined,
898
- ): void {
899
- if (s.orphanContHeaderKind !== kind) {
900
- stopCurrentSpinner();
901
- closeToolLine();
902
- flushCommandOutput();
903
- if (!s.renderer) startAgentResponse();
904
- showCollapsedThinking();
905
- const icon = (kind && KIND_ICONS[kind]) ?? "▶";
906
- const label = kind ?? "tool";
907
- s.renderer!.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
908
- drain();
909
- s.orphanContHeaderKind = kind;
910
- }
911
- showToolComplete(exitCode, resultDisplay, displayDetail || title);
912
- }
913
-
914
816
  function renderResultBody(body: ToolResultBody): void {
915
817
  if (!s.renderer) return;
916
818
  const lines: string[] = ctx.call("render:result-body", body, cappedW()) ?? [];
@@ -958,7 +860,7 @@ export default function activate(ctx: ExtensionContext): void {
958
860
  function closeToolLine(): void {
959
861
  if (s.openTool) {
960
862
  out().write("\n");
961
- // Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
863
+ // Stash identity so the completion renders as ⎿ labeled, not inline ✓.
962
864
  s.pendingToolCompletes.set(s.openTool.callId, {
963
865
  title: s.openTool.title,
964
866
  kind: s.openTool.kind,
@@ -968,41 +870,62 @@ export default function activate(ctx: ExtensionContext): void {
968
870
  }
969
871
  }
970
872
 
971
- /** Render the group aggregate line, or skip if no members have
972
- * completed yet (late completes will render individually as ⎿ labeled). */
973
- function finalizeToolGroup(): void {
974
- // Late completes from this group have lost their inline slot; mark
975
- // them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
976
- if (s.toolGroupKind) {
977
- for (const pending of s.pendingToolCompletes.values()) {
978
- if (pending.kind === s.toolGroupKind) pending.orphaned = true;
979
- }
980
- }
981
- const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
982
- if (s.toolGroupCount <= 1 || skipAggregate) {
983
- s.toolGroupKind = undefined;
984
- s.toolGroupCount = 0;
985
- s.toolGroupCompletedCount = 0;
986
- s.toolGroupRendered = 0;
987
- s.toolGroupAllOk = true;
988
- s.toolGroupSummaries = [];
989
- return;
990
- }
873
+ /** Render a deferred group as one contiguous block, skipping a group with no
874
+ * completed members (e.g. all errored before their events fired). */
875
+ function finalizeGroup(kind: string): void {
876
+ const group = batchGroups.get(kind);
877
+ if (!group || group.finalized) return;
878
+ group.finalized = true;
879
+ const members = group.order
880
+ .map((id) => group.members.get(id))
881
+ .filter((m): m is GroupMember => !!m && m.done);
882
+ if (members.length === 0) return;
883
+
991
884
  stopCurrentSpinner();
992
885
  closeToolLine();
993
886
  if (!s.renderer) startAgentResponse();
994
- const groupLine: string = ctx.call(
995
- "tui:render-tool-group-summary",
996
- s.toolGroupCount, s.toolGroupRendered, s.toolGroupAllOk, s.toolGroupSummaries,
997
- );
998
- s.renderer!.writeLine(groupLine);
887
+ showCollapsedThinking();
888
+ contentGap("tool");
889
+ s.renderer!.flush();
890
+ drain();
891
+
892
+ const icon = KIND_ICONS[kind] ?? "▶";
893
+ const mark = (m: GroupMember): string => ctx.call("tui:render-tool-complete", m.ok ? 0 : 1, "", m.summary);
894
+
895
+ if (members.length === 1) {
896
+ const m = members[0]!;
897
+ s.renderer!.writeLine(`${p.warning}${icon}${p.reset} ${kind} ${p.dim}${m.detail}${p.reset} ${mark(m)}`);
898
+ } else {
899
+ s.renderer!.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
900
+ const shown = Math.min(members.length, GROUP_MAX_VISIBLE);
901
+ const allShown = members.length <= GROUP_MAX_VISIBLE;
902
+ for (let i = 0; i < shown; i++) {
903
+ const m = members[i]!;
904
+ const connector = allShown && i === shown - 1 ? "└" : "├";
905
+ s.renderer!.writeLine(` ${p.muted}${connector}${p.reset} ${p.dim}${m.detail}${p.reset} ${mark(m)}`);
906
+ }
907
+ if (!allShown) {
908
+ s.renderer!.writeLine(` ${p.muted}└${p.reset} ${p.dim}+${members.length - shown} more${p.reset}`);
909
+ }
910
+ }
999
911
  drain();
1000
- s.toolGroupKind = undefined;
1001
- s.toolGroupCount = 0;
1002
- s.toolGroupCompletedCount = 0;
1003
- s.toolGroupAllOk = true;
1004
- s.toolGroupRendered = 0;
1005
- s.toolGroupSummaries = [];
912
+ }
913
+
914
+ function finalizeAllGroups(): void {
915
+ for (const kind of batchGroups.keys()) finalizeGroup(kind);
916
+ }
917
+
918
+ /** Finalize ready groups in batch order, stopping at the first still in
919
+ * flight — so blocks render in dispatch order, not completion order. */
920
+ function flushReadyGroups(): void {
921
+ for (const [kind, group] of batchGroups) {
922
+ if (group.finalized) continue;
923
+ // Eager (non-groupable) groups never increment `completed`, so one earlier
924
+ // in batch order is a barrier here: a deferred group after it renders at
925
+ // the tool-batch-complete backstop, not incrementally. Order is preserved.
926
+ if (group.completed < group.total) break;
927
+ finalizeGroup(kind);
928
+ }
1006
929
  }
1007
930
 
1008
931
  function renderCommandLine(line: string): string {
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { StringDecoder } from "node:string_decoder";
3
4
  import { stripAnsi } from "./ansi.js";
4
5
 
5
6
  // Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
@@ -106,25 +107,23 @@ export function executeCommand(opts: {
106
107
 
107
108
  session.process = child;
108
109
 
109
- const handleData = (data: Buffer) => {
110
- const raw = data.toString("utf-8");
110
+ const handleText = (raw: string) => {
111
+ if (!raw) return;
111
112
  const clean = stripAnsi(raw);
112
-
113
- // Accumulate cleaned output for the agent
114
113
  session.output += clean;
115
-
116
- // Enforce output cap — truncate from beginning, keep tail
117
114
  if (session.output.length > maxOutput) {
118
115
  session.output = session.output.slice(-maxOutput);
119
116
  session.truncated = true;
120
117
  }
121
-
122
- // Real-time streaming callback
123
118
  opts.onOutput?.(raw);
124
119
  };
125
120
 
126
- child.stdout?.on("data", handleData);
127
- child.stderr?.on("data", handleData);
121
+ const outDecoder = new StringDecoder("utf-8");
122
+ const errDecoder = new StringDecoder("utf-8");
123
+ child.stdout?.on("data", (d: Buffer) => handleText(outDecoder.write(d)));
124
+ child.stderr?.on("data", (d: Buffer) => handleText(errDecoder.write(d)));
125
+ child.stdout?.on("end", () => handleText(outDecoder.end()));
126
+ child.stderr?.on("end", () => handleText(errDecoder.end()));
128
127
 
129
128
  let cancelKill: (() => void) | undefined;
130
129
  const timer = setTimeout(() => {
@@ -218,8 +217,8 @@ export function executeArgv(opts: {
218
217
 
219
218
  session.process = child;
220
219
 
221
- const handleData = (data: Buffer) => {
222
- const raw = data.toString("utf-8");
220
+ const handleText = (raw: string) => {
221
+ if (!raw) return;
223
222
  const clean = stripAnsi(raw);
224
223
  session.output += clean;
225
224
  if (session.output.length > maxOutput) {
@@ -229,8 +228,12 @@ export function executeArgv(opts: {
229
228
  opts.onOutput?.(raw);
230
229
  };
231
230
 
232
- child.stdout?.on("data", handleData);
233
- child.stderr?.on("data", handleData);
231
+ const outDecoder = new StringDecoder("utf-8");
232
+ const errDecoder = new StringDecoder("utf-8");
233
+ child.stdout?.on("data", (d: Buffer) => handleText(outDecoder.write(d)));
234
+ child.stderr?.on("data", (d: Buffer) => handleText(errDecoder.write(d)));
235
+ child.stdout?.on("end", () => handleText(outDecoder.end()));
236
+ child.stderr?.on("end", () => handleText(errDecoder.end()));
234
237
 
235
238
  const timer = setTimeout(() => {
236
239
  if (!session.done && session.process) {
@@ -823,6 +823,13 @@ export class FloatingPanel {
823
823
  this.autocompleteIndex = 0;
824
824
  }
825
825
 
826
+ private moveAutocomplete(delta: number): void {
827
+ const n = this.autocompleteItems.length;
828
+ if (n === 0) return;
829
+ this.autocompleteIndex = (this.autocompleteIndex + delta + n) % n;
830
+ this.render();
831
+ }
832
+
826
833
  // ── Input handling ──────────────────────────────────────────
827
834
 
828
835
  private handleIntercept(payload: { data: string; consumed: boolean }): { data: string; consumed: boolean } {
@@ -913,6 +920,16 @@ export class FloatingPanel {
913
920
 
914
921
  if (this.handleScroll(data, false)) return;
915
922
 
923
+ if (data === "\x10" || data === "\x0e") {
924
+ const forward = data === "\x0e";
925
+ if (this.autocompleteActive) {
926
+ this.moveAutocomplete(forward ? 1 : -1);
927
+ } else if (forward ? this.editor.historyForward() : this.editor.historyBack()) {
928
+ this.render();
929
+ }
930
+ return;
931
+ }
932
+
916
933
  const actions = this.editor.feed(data);
917
934
  for (const action of actions) {
918
935
  switch (action.action) {
@@ -924,6 +941,7 @@ export class FloatingPanel {
924
941
  this.editor.pushHistory(query);
925
942
  this.editor.clear();
926
943
  this.clearAutocomplete();
944
+ this.userScrolled = false;
927
945
  // Phase change is the submit handler's call — sync slash commands
928
946
  // (e.g. /model, /help) keep the user in input mode.
929
947
  this.handlers.call(`${this.prefix}:submit`, query);
@@ -945,30 +963,14 @@ export class FloatingPanel {
945
963
  case "shift+tab":
946
964
  this.render();
947
965
  break;
948
- case "arrow-up": {
949
- if (this.autocompleteActive) {
950
- this.autocompleteIndex = this.autocompleteIndex === 0
951
- ? this.autocompleteItems.length - 1
952
- : this.autocompleteIndex - 1;
953
- this.render();
954
- } else {
955
- const hist = this.editor.historyBack();
956
- if (hist) this.render();
957
- }
966
+ case "arrow-up":
967
+ if (this.autocompleteActive) this.moveAutocomplete(-1);
968
+ else this.scrollUp(1);
958
969
  break;
959
- }
960
- case "arrow-down": {
961
- if (this.autocompleteActive) {
962
- this.autocompleteIndex = this.autocompleteIndex === this.autocompleteItems.length - 1
963
- ? 0
964
- : this.autocompleteIndex + 1;
965
- this.render();
966
- } else {
967
- const hist = this.editor.historyForward();
968
- if (hist) this.render();
969
- }
970
+ case "arrow-down":
971
+ if (this.autocompleteActive) this.moveAutocomplete(1);
972
+ else this.scrollDown(1);
970
973
  break;
971
- }
972
974
  case "changed":
973
975
  case "delete-empty":
974
976
  this.updateAutocomplete();