agent-sh 0.15.5 → 0.15.7

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/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.js +2 -5
  4. package/dist/agent/extensions/rolling-history/index.js +20 -8
  5. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  6. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  7. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  8. package/dist/agent/providers/openai-compatible.js +9 -2
  9. package/dist/agent/store.js +6 -1
  10. package/dist/agent/token-budget.d.ts +2 -1
  11. package/dist/agent/token-budget.js +6 -1
  12. package/dist/agent/types.d.ts +4 -1
  13. package/dist/cli/index.js +1 -1
  14. package/dist/core/event-bus.d.ts +16 -1
  15. package/dist/core/event-bus.js +73 -11
  16. package/dist/core/index.js +18 -0
  17. package/dist/shell/tui-renderer.js +116 -174
  18. package/dist/utils/diff-renderer.js +65 -30
  19. package/dist/utils/executor.js +19 -11
  20. package/dist/utils/floating-panel.d.ts +1 -0
  21. package/dist/utils/floating-panel.js +28 -26
  22. package/dist/utils/markdown.js +56 -44
  23. package/dist/utils/palette.d.ts +11 -0
  24. package/dist/utils/palette.js +11 -0
  25. package/docs/agent.md +13 -11
  26. package/docs/architecture.md +3 -5
  27. package/docs/extensions.md +21 -20
  28. package/docs/library.md +6 -3
  29. package/docs/troubleshooting.md +2 -2
  30. package/docs/tui-composition.md +11 -3
  31. package/docs/usage.md +70 -50
  32. package/examples/extensions/ashi/src/chat/assistant.ts +6 -4
  33. package/examples/extensions/ashi/src/compaction.ts +4 -7
  34. package/examples/extensions/ashi/src/frontend.ts +2 -0
  35. package/examples/extensions/ashi/src/schema.ts +8 -2
  36. package/examples/extensions/command-suggest.ts +90 -0
  37. package/examples/extensions/solarized-theme.ts +11 -0
  38. package/package.json +5 -5
  39. package/src/agent/agent-loop.ts +2 -5
  40. package/src/agent/extensions/rolling-history/index.ts +20 -8
  41. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  42. package/src/agent/providers/openai-compatible.ts +19 -4
  43. package/src/agent/store.ts +5 -1
  44. package/src/agent/token-budget.ts +10 -1
  45. package/src/agent/types.ts +4 -1
  46. package/src/cli/index.ts +1 -1
  47. package/src/core/event-bus.ts +67 -12
  48. package/src/core/index.ts +18 -0
  49. package/src/shell/tui-renderer.ts +131 -207
  50. package/src/utils/diff-renderer.ts +62 -29
  51. package/src/utils/executor.ts +17 -14
  52. package/src/utils/floating-panel.ts +24 -22
  53. package/src/utils/markdown.ts +49 -40
  54. 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")) {
@@ -731,6 +680,7 @@ export default function activate(ctx: ExtensionContext): void {
731
680
  filePath,
732
681
  maxLines,
733
682
  trueColor: true,
683
+ gutterLine: false,
734
684
  });
735
685
  const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
736
686
 
@@ -799,15 +749,13 @@ export default function activate(ctx: ExtensionContext): void {
799
749
  displayDetail?: string;
800
750
  batchIndex?: number;
801
751
  batchTotal?: number;
802
- groupContinuation?: boolean;
803
752
  },
804
753
  ): void {
805
754
  closeToolLine();
806
755
  stopCurrentSpinner();
807
756
  if (!s.renderer) startAgentResponse();
808
757
  showCollapsedThinking();
809
- // No gap between grouped tools — they're visually connected
810
- if (!extra?.groupContinuation) contentGap("tool");
758
+ contentGap("tool");
811
759
  s.renderer!.flush();
812
760
  drain();
813
761
  const lines = renderToolCall({
@@ -820,38 +768,19 @@ export default function activate(ctx: ExtensionContext): void {
820
768
  displayDetail: extra?.displayDetail,
821
769
  }, cappedW(), shellCwd);
822
770
 
823
- if (extra?.groupContinuation && lines.length > 0) {
824
- // Swap the colored kind icon for a muted tree connector,
825
- // and strip the tool name prefix — show detail only.
826
- const detail = extra.displayDetail || extractDetail(extra);
827
- const maxW = Math.max(1, cappedW() - 6);
828
- const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
829
- lines[0] = detail
830
- ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
831
- : lines[0]!.replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
832
- }
833
-
834
- const batchPrefix = "";
835
-
836
771
  for (let i = 0; i < lines.length - 1; i++) {
837
772
  s.renderer!.writeLine(lines[i]!);
838
773
  }
839
774
  drain();
840
775
  if (lines.length > 0) {
841
- if (extra?.groupContinuation) {
842
- // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
843
- s.renderer!.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
844
- drain();
845
- } else {
846
- out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
847
- if (extra?.toolCallId) {
848
- s.openTool = {
849
- callId: extra.toolCallId,
850
- title,
851
- kind: extra.kind,
852
- displayDetail: extra.displayDetail ?? extractDetail(extra),
853
- };
854
- }
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
+ };
855
784
  }
856
785
  }
857
786
  s.hadToolCalls = true;
@@ -884,32 +813,6 @@ export default function activate(ctx: ExtensionContext): void {
884
813
  if (resultDisplay?.body) renderResultBody(resultDisplay.body);
885
814
  }
886
815
 
887
- /** Late completion from a finalized group — re-emit the kind header
888
- * in muted "(cont.)" form so the ⎿ has a legitimate parent, then
889
- * render the completion as a normal labeled ⎿. Subsequent orphans
890
- * of the same kind reuse the existing (cont.) header. */
891
- function showOrphanedComplete(
892
- exitCode: number | null,
893
- resultDisplay: ToolResultDisplay | undefined,
894
- title: string,
895
- kind: string | undefined,
896
- displayDetail: string | undefined,
897
- ): void {
898
- if (s.orphanContHeaderKind !== kind) {
899
- stopCurrentSpinner();
900
- closeToolLine();
901
- flushCommandOutput();
902
- if (!s.renderer) startAgentResponse();
903
- showCollapsedThinking();
904
- const icon = (kind && KIND_ICONS[kind]) ?? "▶";
905
- const label = kind ?? "tool";
906
- s.renderer!.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
907
- drain();
908
- s.orphanContHeaderKind = kind;
909
- }
910
- showToolComplete(exitCode, resultDisplay, displayDetail || title);
911
- }
912
-
913
816
  function renderResultBody(body: ToolResultBody): void {
914
817
  if (!s.renderer) return;
915
818
  const lines: string[] = ctx.call("render:result-body", body, cappedW()) ?? [];
@@ -957,7 +860,7 @@ export default function activate(ctx: ExtensionContext): void {
957
860
  function closeToolLine(): void {
958
861
  if (s.openTool) {
959
862
  out().write("\n");
960
- // Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
863
+ // Stash identity so the completion renders as ⎿ labeled, not inline ✓.
961
864
  s.pendingToolCompletes.set(s.openTool.callId, {
962
865
  title: s.openTool.title,
963
866
  kind: s.openTool.kind,
@@ -967,41 +870,62 @@ export default function activate(ctx: ExtensionContext): void {
967
870
  }
968
871
  }
969
872
 
970
- /** Render the group aggregate line, or skip if no members have
971
- * completed yet (late completes will render individually as ⎿ labeled). */
972
- function finalizeToolGroup(): void {
973
- // Late completes from this group have lost their inline slot; mark
974
- // them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
975
- if (s.toolGroupKind) {
976
- for (const pending of s.pendingToolCompletes.values()) {
977
- if (pending.kind === s.toolGroupKind) pending.orphaned = true;
978
- }
979
- }
980
- const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
981
- if (s.toolGroupCount <= 1 || skipAggregate) {
982
- s.toolGroupKind = undefined;
983
- s.toolGroupCount = 0;
984
- s.toolGroupCompletedCount = 0;
985
- s.toolGroupRendered = 0;
986
- s.toolGroupAllOk = true;
987
- s.toolGroupSummaries = [];
988
- return;
989
- }
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
+
990
884
  stopCurrentSpinner();
991
885
  closeToolLine();
992
886
  if (!s.renderer) startAgentResponse();
993
- const groupLine: string = ctx.call(
994
- "tui:render-tool-group-summary",
995
- s.toolGroupCount, s.toolGroupRendered, s.toolGroupAllOk, s.toolGroupSummaries,
996
- );
997
- 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
+ }
998
911
  drain();
999
- s.toolGroupKind = undefined;
1000
- s.toolGroupCount = 0;
1001
- s.toolGroupCompletedCount = 0;
1002
- s.toolGroupAllOk = true;
1003
- s.toolGroupRendered = 0;
1004
- 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
+ }
1005
929
  }
1006
930
 
1007
931
  function renderCommandLine(line: string): string {
@@ -140,6 +140,39 @@ function tokenLcs(
140
140
  return { oldMatch, newMatch };
141
141
  }
142
142
 
143
+ /**
144
+ * Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
145
+ * the differing middle. Null when that middle exceeds maxProduct.
146
+ */
147
+ function inlineMatches(
148
+ a: Token[],
149
+ b: Token[],
150
+ maxProduct: number,
151
+ ): { oldMatch: boolean[]; newMatch: boolean[] } | null {
152
+ const m = a.length;
153
+ const n = b.length;
154
+ let pre = 0;
155
+ while (pre < m && pre < n && a[pre].text === b[pre].text) pre++;
156
+ let suf = 0;
157
+ while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text) suf++;
158
+
159
+ const midM = m - pre - suf;
160
+ const midN = n - pre - suf;
161
+ if (midM * midN > maxProduct) return null;
162
+
163
+ const oldMatch = new Array<boolean>(m).fill(false);
164
+ const newMatch = new Array<boolean>(n).fill(false);
165
+ for (let i = 0; i < pre; i++) { oldMatch[i] = true; newMatch[i] = true; }
166
+ for (let i = 0; i < suf; i++) { oldMatch[m - 1 - i] = true; newMatch[n - 1 - i] = true; }
167
+
168
+ if (midM > 0 && midN > 0) {
169
+ const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
170
+ for (let i = 0; i < midM; i++) oldMatch[pre + i] = mid.oldMatch[i];
171
+ for (let j = 0; j < midN; j++) newMatch[pre + j] = mid.newMatch[j];
172
+ }
173
+ return { oldMatch, newMatch };
174
+ }
175
+
143
176
  /**
144
177
  * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
145
178
  * preserving the given background color across the line.
@@ -193,15 +226,14 @@ function highlightInlineChanges(
193
226
  };
194
227
  }
195
228
 
196
- // Safety guard: skip if LCS matrix would be too large
197
- if (oldTokens.length * newTokens.length > 50000) {
229
+ const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
230
+ if (!matches) {
198
231
  return {
199
232
  old: language ? highlightLine(oldLine, language) : oldLine,
200
233
  new: language ? highlightLine(newLine, language) : newLine,
201
234
  };
202
235
  }
203
-
204
- const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
236
+ const { oldMatch, newMatch } = matches;
205
237
 
206
238
  const buildHighlighted = (
207
239
  tokens: Token[],
@@ -340,12 +372,19 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
340
372
  const bgWidth = Math.max(1, textWidth - noW - 3);
341
373
  const gutter = (n: string): string => `${p.dim}${n} │${p.reset} `;
342
374
 
343
- const change = (no: string, sigil: string, bg: string, fg: string, text: string): string => {
344
- if (!gutterLine) {
345
- return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
346
- }
347
- if (useTrueColor) return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
348
- return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
375
+ const continuationNo = " ".repeat(noW);
376
+
377
+ // Wrapped rows after the first blank the line number and sigil.
378
+ const change = (no: string, sigil: string, bg: string, fg: string, text: string): string[] => {
379
+ return wrapLine(text, lineTextW).map((seg, r) => {
380
+ const n = r === 0 ? no : continuationNo;
381
+ const sg = r === 0 ? sigil : " ";
382
+ if (!gutterLine) {
383
+ return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
384
+ }
385
+ if (useTrueColor) return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
386
+ return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
387
+ });
349
388
  };
350
389
 
351
390
  const hlCache = new Map<ChangePair, { old: string; new: string }>();
@@ -367,31 +406,25 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
367
406
  ).padStart(noW);
368
407
 
369
408
  if (line.type === "context") {
370
- const raw = truncateText(line.text, lineTextW);
371
- const text = lang ? highlightLine(raw, lang) : raw;
372
- out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
409
+ const text = lang ? highlightLine(line.text, lang) : line.text;
410
+ wrapLine(text, lineTextW).forEach((seg, r) => {
411
+ const n = r === 0 ? no : continuationNo;
412
+ out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
413
+ });
373
414
  continue;
374
415
  }
375
416
 
376
417
  const pair = pairs.get(i);
377
418
  if (line.type === "removed") {
378
- let removedText: string;
379
- if (pair) {
380
- removedText = truncateText(highlightedPair(pair).old, lineTextW);
381
- } else {
382
- const raw = truncateText(line.text, lineTextW);
383
- removedText = lang ? highlightLine(raw, lang) : raw;
384
- }
385
- out.push(change(no, "-", p.errorBg, p.error, removedText));
419
+ const removedText = pair
420
+ ? highlightedPair(pair).old
421
+ : (lang ? highlightLine(line.text, lang) : line.text);
422
+ out.push(...change(no, "-", p.errorBg, p.error, removedText));
386
423
  } else {
387
- let addedText: string;
388
- if (pair) {
389
- addedText = truncateText(highlightedPair(pair).new, lineTextW);
390
- } else {
391
- const raw = truncateText(line.text, lineTextW);
392
- addedText = lang ? highlightLine(raw, lang) : raw;
393
- }
394
- out.push(change(no, "+", p.successBg, p.success, addedText));
424
+ const addedText = pair
425
+ ? highlightedPair(pair).new
426
+ : (lang ? highlightLine(line.text, lang) : line.text);
427
+ out.push(...change(no, "+", p.successBg, p.success, addedText));
395
428
  }
396
429
  }
397
430
  return out;