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
@@ -50,7 +50,6 @@ function createRenderState() {
50
50
  spinnerStartTime: 0,
51
51
  openTool: null,
52
52
  pendingToolCompletes: new Map(),
53
- orphanContHeaderKind: undefined,
54
53
  currentToolKind: undefined,
55
54
  toolStartTime: 0,
56
55
  toolExitCode: null,
@@ -58,12 +57,6 @@ function createRenderState() {
58
57
  commandOutputLineCount: 0,
59
58
  commandOutputOverflow: 0,
60
59
  commandOverflowLines: [],
61
- toolGroupKind: undefined,
62
- toolGroupCount: 0,
63
- toolGroupCompletedCount: 0,
64
- toolGroupAllOk: true,
65
- toolGroupRendered: 0,
66
- toolGroupSummaries: [],
67
60
  isThinking: false,
68
61
  showThinkingText: false,
69
62
  thinkingPending: false,
@@ -113,15 +106,6 @@ export default function activate(ctx) {
113
106
  return `${p.success}✓${p.reset}${summaryStr}${timer}`;
114
107
  return `${p.error}✗ exit ${exitCode}${p.reset}${summaryStr}${timer}`;
115
108
  });
116
- define("tui:render-tool-group-summary", (count, rendered, allOk, summaries) => {
117
- const mark = allOk ? `${p.success}✓${p.reset}` : `${p.error}✗${p.reset}`;
118
- const summaryStr = summaries.length > 0 ? ` ${p.dim}${summaries.join(", ")}${p.reset}` : "";
119
- const collapsed = count - rendered;
120
- if (collapsed > 0) {
121
- return ` ${p.muted}└${p.reset} ${p.dim}+${collapsed} more${p.reset} ${mark}${summaryStr}`;
122
- }
123
- return ` ${p.muted}└${p.reset} ${mark}${summaryStr}`;
124
- });
125
109
  define("tui:render-command-output", (line, _kind) => `${p.dim} ${line}${p.reset}`);
126
110
  define("tui:render-spinner", (label, frame, elapsed, hint) => {
127
111
  const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
@@ -245,33 +229,35 @@ export default function activate(ctx) {
245
229
  const GROUPABLE_KINDS = new Set(["read", "search"]);
246
230
  const GROUP_MAX_VISIBLE = 5;
247
231
  const KIND_ICONS = { read: "◆", search: "⌕" };
248
- // Batch groups: kind → { total, rendered, headerShown }
249
232
  let batchGroups = new Map();
233
+ let batchSize = 0;
234
+ /** A lone tool has nothing to interleave with, so deferral applies only to
235
+ * read-only kinds in a multi-tool batch. */
236
+ function isDeferred(kind) {
237
+ return batchSize > 1 && GROUPABLE_KINDS.has(kind) && batchGroups.has(kind);
238
+ }
250
239
  bus.on("agent:tool-batch", (e) => {
251
240
  if (!shouldRender())
252
241
  return;
253
242
  fencedTransform.flush();
254
- finalizeToolGroup();
255
- s.orphanContHeaderKind = undefined;
243
+ finalizeAllGroups();
244
+ closeToolLine();
256
245
  batchGroups = new Map();
246
+ batchSize = 0;
257
247
  for (const group of e.groups) {
258
- batchGroups.set(group.kind, {
259
- total: group.tools.length,
260
- rendered: 0,
261
- headerShown: false,
262
- });
248
+ batchSize += group.tools.length;
249
+ batchGroups.set(group.kind, { total: group.tools.length, completed: 0, finalized: false, order: [], members: new Map() });
263
250
  }
264
251
  });
265
252
  bus.on("agent:tool-started", (e) => {
266
253
  if (!shouldRender())
267
254
  return;
268
255
  fencedTransform.flush();
269
- stopCurrentSpinner();
270
256
  s.currentToolKind = e.kind;
271
257
  s.toolStartTime = Date.now();
272
- s.orphanContHeaderKind = undefined;
273
258
  if (e.title === "user_shell") {
274
- finalizeToolGroup();
259
+ stopCurrentSpinner();
260
+ finalizeAllGroups();
275
261
  closeToolLine();
276
262
  if (!s.renderer)
277
263
  startAgentResponse();
@@ -284,89 +270,57 @@ export default function activate(ctx) {
284
270
  return;
285
271
  }
286
272
  const kind = e.kind ?? "execute";
287
- const group = batchGroups.get(kind);
288
- const isGrouped = group && group.total > 1 && GROUPABLE_KINDS.has(kind);
289
- if (isGrouped) {
290
- // Render group header on first tool of this kind in the batch
291
- if (!group.headerShown) {
292
- finalizeToolGroup();
293
- closeToolLine();
294
- if (!s.renderer)
295
- startAgentResponse();
296
- showCollapsedThinking();
297
- contentGap("tool");
298
- s.renderer.flush();
299
- drain();
300
- const icon = KIND_ICONS[kind] ?? "▶";
301
- s.renderer.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
302
- drain();
303
- group.headerShown = true;
304
- s.toolGroupKind = kind;
305
- s.toolGroupCount = 0;
306
- s.toolGroupCompletedCount = 0;
307
- s.toolGroupRendered = 0;
308
- s.toolGroupAllOk = true;
309
- s.toolGroupSummaries = [];
310
- }
311
- s.toolGroupCount++;
312
- if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
313
- showToolCall(e.title, "", { ...e, groupContinuation: true });
314
- s.toolGroupRendered++;
315
- }
316
- if (e.toolCallId) {
317
- s.pendingToolCompletes.set(e.toolCallId, {
318
- title: e.title,
319
- kind,
320
- displayDetail: e.displayDetail ?? extractDetail(e),
321
- });
322
- }
323
- }
324
- else {
325
- // Standalone tool — single in its batch kind, or not groupable
326
- finalizeToolGroup();
327
- showToolCall(e.title, "", { ...e });
273
+ if (isDeferred(kind)) {
274
+ const group = batchGroups.get(kind);
275
+ const id = e.toolCallId ?? `${kind}-${group.order.length}`;
276
+ group.order.push(id);
277
+ group.members.set(id, { detail: e.displayDetail ?? extractDetail(e), ok: true, done: false });
278
+ s.hadToolCalls = true;
279
+ if (!s.spinner)
280
+ startThinkingSpinner(); // nothing's drawn yet — stand in until the block renders
281
+ return;
328
282
  }
283
+ // Eager: a single-tool turn, or a sequential mutating/streaming tool.
284
+ stopCurrentSpinner();
285
+ showToolCall(e.title, "", { ...e });
329
286
  });
330
287
  bus.on("agent:tool-completed", (e) => {
331
288
  if (!shouldRender())
332
289
  return;
333
290
  s.toolExitCode = e.exitCode;
334
- if (e.exitCode !== 0)
335
- s.toolGroupAllOk = false;
336
291
  const resultDisplay = e.resultDisplay;
337
- if (s.toolGroupKind) {
338
- // Grouped tool — track success/failure and summaries, show aggregate on ⎿ line.
339
- // Don't restart spinner between grouped tools — it's already running from group start.
340
- if (resultDisplay?.summary)
341
- s.toolGroupSummaries.push(resultDisplay.summary);
342
- if (e.toolCallId)
343
- s.pendingToolCompletes.delete(e.toolCallId);
344
- s.toolGroupCompletedCount++;
345
- s.currentToolKind = undefined;
346
- // Finalize as soon as all members return so aggregate lands right
347
- // after its children, not below out-of-band renders from the next tool.
348
- const batchGroup = batchGroups.get(s.toolGroupKind);
349
- if (batchGroup && s.toolGroupCompletedCount >= batchGroup.total) {
350
- finalizeToolGroup();
292
+ const kind = e.kind ?? "execute";
293
+ const group = batchGroups.get(kind);
294
+ if (isDeferred(kind) && group) {
295
+ const id = e.toolCallId ?? group.order[group.completed];
296
+ const member = id ? group.members.get(id) : undefined;
297
+ if (member) {
298
+ member.done = true;
299
+ member.ok = e.exitCode === 0;
300
+ member.summary = resultDisplay?.summary;
351
301
  }
302
+ group.completed++;
303
+ s.currentToolKind = undefined;
304
+ flushReadyGroups();
305
+ if (!s.spinner)
306
+ startThinkingSpinner(); // keep the spinner up for in-flight siblings
352
307
  }
353
308
  else {
354
- // Tools that lost the inline slot render as a labeled ⎿. Orphans
355
- // (group finalized before they returned) reroute via showOrphanedComplete.
356
309
  const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
357
310
  if (pending)
358
311
  s.pendingToolCompletes.delete(e.toolCallId);
359
- if (pending?.orphaned) {
360
- showOrphanedComplete(e.exitCode, resultDisplay, pending.title, pending.kind, pending.displayDetail);
361
- }
362
- else {
363
- showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
364
- }
312
+ showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
365
313
  s.currentToolKind = undefined;
366
314
  s.spinnerStartTime = 0;
367
315
  startThinkingSpinner();
368
316
  }
369
317
  });
318
+ bus.on("agent:tool-batch-complete", () => {
319
+ if (!shouldRender())
320
+ return;
321
+ // Backstop for a group whose members didn't all complete (e.g. errored).
322
+ finalizeAllGroups();
323
+ });
370
324
  bus.on("agent:tool-output-chunk", (e) => { if (shouldRender())
371
325
  writeCommandOutput(e.chunk); });
372
326
  bus.on("agent:tool-output", () => { if (shouldRender())
@@ -465,7 +419,7 @@ export default function activate(ctx) {
465
419
  }
466
420
  }
467
421
  function endAgentResponse() {
468
- finalizeToolGroup();
422
+ finalizeAllGroups();
469
423
  closeToolLine();
470
424
  stopCurrentSpinner();
471
425
  if (s.renderer) {
@@ -502,7 +456,7 @@ export default function activate(ctx) {
502
456
  }
503
457
  }
504
458
  function writeAgentText(text) {
505
- finalizeToolGroup();
459
+ finalizeAllGroups();
506
460
  closeToolLine();
507
461
  s.hadToolCalls = false;
508
462
  if (s.isThinking) {
@@ -526,7 +480,7 @@ export default function activate(ctx) {
526
480
  flushForRaw();
527
481
  contentGap("code");
528
482
  if (language) {
529
- s.renderer.writeLine(`${p.dim}${language}${p.reset}`);
483
+ s.renderer.writeLine(`${p.mdCodeBlockBorder}${language}${p.reset}`);
530
484
  }
531
485
  let highlighted;
532
486
  try {
@@ -549,7 +503,10 @@ export default function activate(ctx) {
549
503
  }
550
504
  }
551
505
  catch {
552
- highlighted = code;
506
+ highlighted = code
507
+ .split("\n")
508
+ .map((l) => `${p.mdCodeBlock}${l}${p.reset}`)
509
+ .join("\n");
553
510
  }
554
511
  const contentWidth = Math.min(90, width - 2);
555
512
  for (const line of highlighted.split("\n")) {
@@ -644,6 +601,7 @@ export default function activate(ctx) {
644
601
  filePath,
645
602
  maxLines,
646
603
  trueColor: true,
604
+ gutterLine: false,
647
605
  });
648
606
  const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
649
607
  return renderBoxFrame(body, {
@@ -710,9 +668,7 @@ export default function activate(ctx) {
710
668
  if (!s.renderer)
711
669
  startAgentResponse();
712
670
  showCollapsedThinking();
713
- // No gap between grouped tools — they're visually connected
714
- if (!extra?.groupContinuation)
715
- contentGap("tool");
671
+ contentGap("tool");
716
672
  s.renderer.flush();
717
673
  drain();
718
674
  const lines = renderToolCall({
@@ -724,37 +680,19 @@ export default function activate(ctx) {
724
680
  rawInput: extra?.rawInput,
725
681
  displayDetail: extra?.displayDetail,
726
682
  }, cappedW(), shellCwd);
727
- if (extra?.groupContinuation && lines.length > 0) {
728
- // Swap the colored kind icon for a muted tree connector,
729
- // and strip the tool name prefix — show detail only.
730
- const detail = extra.displayDetail || extractDetail(extra);
731
- const maxW = Math.max(1, cappedW() - 6);
732
- const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
733
- lines[0] = detail
734
- ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
735
- : lines[0].replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
736
- }
737
- const batchPrefix = "";
738
683
  for (let i = 0; i < lines.length - 1; i++) {
739
684
  s.renderer.writeLine(lines[i]);
740
685
  }
741
686
  drain();
742
687
  if (lines.length > 0) {
743
- if (extra?.groupContinuation) {
744
- // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
745
- s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
746
- drain();
747
- }
748
- else {
749
- out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
750
- if (extra?.toolCallId) {
751
- s.openTool = {
752
- callId: extra.toolCallId,
753
- title,
754
- kind: extra.kind,
755
- displayDetail: extra.displayDetail ?? extractDetail(extra),
756
- };
757
- }
688
+ out().write(` ${lines[lines.length - 1]}`);
689
+ if (extra?.toolCallId) {
690
+ s.openTool = {
691
+ callId: extra.toolCallId,
692
+ title,
693
+ kind: extra.kind,
694
+ displayDetail: extra.displayDetail ?? extractDetail(extra),
695
+ };
758
696
  }
759
697
  }
760
698
  s.hadToolCalls = true;
@@ -782,26 +720,6 @@ export default function activate(ctx) {
782
720
  if (resultDisplay?.body)
783
721
  renderResultBody(resultDisplay.body);
784
722
  }
785
- /** Late completion from a finalized group — re-emit the kind header
786
- * in muted "(cont.)" form so the ⎿ has a legitimate parent, then
787
- * render the completion as a normal labeled ⎿. Subsequent orphans
788
- * of the same kind reuse the existing (cont.) header. */
789
- function showOrphanedComplete(exitCode, resultDisplay, title, kind, displayDetail) {
790
- if (s.orphanContHeaderKind !== kind) {
791
- stopCurrentSpinner();
792
- closeToolLine();
793
- flushCommandOutput();
794
- if (!s.renderer)
795
- startAgentResponse();
796
- showCollapsedThinking();
797
- const icon = (kind && KIND_ICONS[kind]) ?? "▶";
798
- const label = kind ?? "tool";
799
- s.renderer.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
800
- drain();
801
- s.orphanContHeaderKind = kind;
802
- }
803
- showToolComplete(exitCode, resultDisplay, displayDetail || title);
804
- }
805
723
  function renderResultBody(body) {
806
724
  if (!s.renderer)
807
725
  return;
@@ -848,7 +766,7 @@ export default function activate(ctx) {
848
766
  function closeToolLine() {
849
767
  if (s.openTool) {
850
768
  out().write("\n");
851
- // Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
769
+ // Stash identity so the completion renders as ⎿ labeled, not inline ✓.
852
770
  s.pendingToolCompletes.set(s.openTool.callId, {
853
771
  title: s.openTool.title,
854
772
  kind: s.openTool.kind,
@@ -857,40 +775,64 @@ export default function activate(ctx) {
857
775
  s.openTool = null;
858
776
  }
859
777
  }
860
- /** Render the group aggregate line, or skip if no members have
861
- * completed yet (late completes will render individually as ⎿ labeled). */
862
- function finalizeToolGroup() {
863
- // Late completes from this group have lost their inline slot; mark
864
- // them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
865
- if (s.toolGroupKind) {
866
- for (const pending of s.pendingToolCompletes.values()) {
867
- if (pending.kind === s.toolGroupKind)
868
- pending.orphaned = true;
869
- }
870
- }
871
- const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
872
- if (s.toolGroupCount <= 1 || skipAggregate) {
873
- s.toolGroupKind = undefined;
874
- s.toolGroupCount = 0;
875
- s.toolGroupCompletedCount = 0;
876
- s.toolGroupRendered = 0;
877
- s.toolGroupAllOk = true;
878
- s.toolGroupSummaries = [];
778
+ /** Render a deferred group as one contiguous block, skipping a group with no
779
+ * completed members (e.g. all errored before their events fired). */
780
+ function finalizeGroup(kind) {
781
+ const group = batchGroups.get(kind);
782
+ if (!group || group.finalized)
783
+ return;
784
+ group.finalized = true;
785
+ const members = group.order
786
+ .map((id) => group.members.get(id))
787
+ .filter((m) => !!m && m.done);
788
+ if (members.length === 0)
879
789
  return;
880
- }
881
790
  stopCurrentSpinner();
882
791
  closeToolLine();
883
792
  if (!s.renderer)
884
793
  startAgentResponse();
885
- const groupLine = ctx.call("tui:render-tool-group-summary", s.toolGroupCount, s.toolGroupRendered, s.toolGroupAllOk, s.toolGroupSummaries);
886
- s.renderer.writeLine(groupLine);
794
+ showCollapsedThinking();
795
+ contentGap("tool");
796
+ s.renderer.flush();
887
797
  drain();
888
- s.toolGroupKind = undefined;
889
- s.toolGroupCount = 0;
890
- s.toolGroupCompletedCount = 0;
891
- s.toolGroupAllOk = true;
892
- s.toolGroupRendered = 0;
893
- s.toolGroupSummaries = [];
798
+ const icon = KIND_ICONS[kind] ?? "▶";
799
+ const mark = (m) => ctx.call("tui:render-tool-complete", m.ok ? 0 : 1, "", m.summary);
800
+ if (members.length === 1) {
801
+ const m = members[0];
802
+ s.renderer.writeLine(`${p.warning}${icon}${p.reset} ${kind} ${p.dim}${m.detail}${p.reset} ${mark(m)}`);
803
+ }
804
+ else {
805
+ s.renderer.writeLine(`${p.warning}${icon}${p.reset} ${kind}`);
806
+ const shown = Math.min(members.length, GROUP_MAX_VISIBLE);
807
+ const allShown = members.length <= GROUP_MAX_VISIBLE;
808
+ for (let i = 0; i < shown; i++) {
809
+ const m = members[i];
810
+ const connector = allShown && i === shown - 1 ? "└" : "├";
811
+ s.renderer.writeLine(` ${p.muted}${connector}${p.reset} ${p.dim}${m.detail}${p.reset} ${mark(m)}`);
812
+ }
813
+ if (!allShown) {
814
+ s.renderer.writeLine(` ${p.muted}└${p.reset} ${p.dim}+${members.length - shown} more${p.reset}`);
815
+ }
816
+ }
817
+ drain();
818
+ }
819
+ function finalizeAllGroups() {
820
+ for (const kind of batchGroups.keys())
821
+ finalizeGroup(kind);
822
+ }
823
+ /** Finalize ready groups in batch order, stopping at the first still in
824
+ * flight — so blocks render in dispatch order, not completion order. */
825
+ function flushReadyGroups() {
826
+ for (const [kind, group] of batchGroups) {
827
+ if (group.finalized)
828
+ continue;
829
+ // Eager (non-groupable) groups never increment `completed`, so one earlier
830
+ // in batch order is a barrier here: a deferred group after it renders at
831
+ // the tool-batch-complete backstop, not incrementally. Order is preserved.
832
+ if (group.completed < group.total)
833
+ break;
834
+ finalizeGroup(kind);
835
+ }
894
836
  }
895
837
  function renderCommandLine(line) {
896
838
  return ctx.call("tui:render-command-output", line, s.currentToolKind);
@@ -8,6 +8,7 @@
8
8
  import { highlight } from "cli-highlight";
9
9
  import { visibleLen, charWidth } from "./ansi.js";
10
10
  import { palette as p } from "./palette.js";
11
+ import { wrapLine } from "./markdown.js";
11
12
  // ── Constants ────────────────────────────────────────────────────
12
13
  const SPLIT_MIN_WIDTH = 120;
13
14
  const UNIFIED_MIN_WIDTH = 40;
@@ -102,6 +103,42 @@ function tokenLcs(a, b) {
102
103
  }
103
104
  return { oldMatch, newMatch };
104
105
  }
106
+ /**
107
+ * Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
108
+ * the differing middle. Null when that middle exceeds maxProduct.
109
+ */
110
+ function inlineMatches(a, b, maxProduct) {
111
+ const m = a.length;
112
+ const n = b.length;
113
+ let pre = 0;
114
+ while (pre < m && pre < n && a[pre].text === b[pre].text)
115
+ pre++;
116
+ let suf = 0;
117
+ while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text)
118
+ suf++;
119
+ const midM = m - pre - suf;
120
+ const midN = n - pre - suf;
121
+ if (midM * midN > maxProduct)
122
+ return null;
123
+ const oldMatch = new Array(m).fill(false);
124
+ const newMatch = new Array(n).fill(false);
125
+ for (let i = 0; i < pre; i++) {
126
+ oldMatch[i] = true;
127
+ newMatch[i] = true;
128
+ }
129
+ for (let i = 0; i < suf; i++) {
130
+ oldMatch[m - 1 - i] = true;
131
+ newMatch[n - 1 - i] = true;
132
+ }
133
+ if (midM > 0 && midN > 0) {
134
+ const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
135
+ for (let i = 0; i < midM; i++)
136
+ oldMatch[pre + i] = mid.oldMatch[i];
137
+ for (let j = 0; j < midN; j++)
138
+ newMatch[pre + j] = mid.newMatch[j];
139
+ }
140
+ return { oldMatch, newMatch };
141
+ }
105
142
  /**
106
143
  * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
107
144
  * preserving the given background color across the line.
@@ -139,14 +176,14 @@ function highlightInlineChanges(oldLine, newLine, oldPalette, newPalette, useTru
139
176
  new: language ? highlightLine(newLine, language) : newLine,
140
177
  };
141
178
  }
142
- // Safety guard: skip if LCS matrix would be too large
143
- if (oldTokens.length * newTokens.length > 50000) {
179
+ const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
180
+ if (!matches) {
144
181
  return {
145
182
  old: language ? highlightLine(oldLine, language) : oldLine,
146
183
  new: language ? highlightLine(newLine, language) : newLine,
147
184
  };
148
185
  }
149
- const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
186
+ const { oldMatch, newMatch } = matches;
150
187
  const buildHighlighted = (tokens, matched, palette) => {
151
188
  let result = "";
152
189
  for (let i = 0; i < tokens.length; i++) {
@@ -248,13 +285,19 @@ function renderUnifiedHunk(hunk, layout) {
248
285
  const pairs = findChangePairs(hunk);
249
286
  const bgWidth = Math.max(1, textWidth - noW - 3);
250
287
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
288
+ const continuationNo = " ".repeat(noW);
289
+ // Wrapped rows after the first blank the line number and sigil.
251
290
  const change = (no, sigil, bg, fg, text) => {
252
- if (!gutterLine) {
253
- return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
254
- }
255
- if (useTrueColor)
256
- return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
257
- return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
291
+ return wrapLine(text, lineTextW).map((seg, r) => {
292
+ const n = r === 0 ? no : continuationNo;
293
+ const sg = r === 0 ? sigil : " ";
294
+ if (!gutterLine) {
295
+ return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
296
+ }
297
+ if (useTrueColor)
298
+ return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
299
+ return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
300
+ });
258
301
  };
259
302
  const hlCache = new Map();
260
303
  const highlightedPair = (pair) => {
@@ -269,33 +312,25 @@ function renderUnifiedHunk(hunk, layout) {
269
312
  const line = hunk.lines[i];
270
313
  const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
271
314
  if (line.type === "context") {
272
- const raw = truncateText(line.text, lineTextW);
273
- const text = lang ? highlightLine(raw, lang) : raw;
274
- out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
315
+ const text = lang ? highlightLine(line.text, lang) : line.text;
316
+ wrapLine(text, lineTextW).forEach((seg, r) => {
317
+ const n = r === 0 ? no : continuationNo;
318
+ out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
319
+ });
275
320
  continue;
276
321
  }
277
322
  const pair = pairs.get(i);
278
323
  if (line.type === "removed") {
279
- let removedText;
280
- if (pair) {
281
- removedText = truncateText(highlightedPair(pair).old, lineTextW);
282
- }
283
- else {
284
- const raw = truncateText(line.text, lineTextW);
285
- removedText = lang ? highlightLine(raw, lang) : raw;
286
- }
287
- out.push(change(no, "-", p.errorBg, p.error, removedText));
324
+ const removedText = pair
325
+ ? highlightedPair(pair).old
326
+ : (lang ? highlightLine(line.text, lang) : line.text);
327
+ out.push(...change(no, "-", p.errorBg, p.error, removedText));
288
328
  }
289
329
  else {
290
- let addedText;
291
- if (pair) {
292
- addedText = truncateText(highlightedPair(pair).new, lineTextW);
293
- }
294
- else {
295
- const raw = truncateText(line.text, lineTextW);
296
- addedText = lang ? highlightLine(raw, lang) : raw;
297
- }
298
- out.push(change(no, "+", p.successBg, p.success, addedText));
330
+ const addedText = pair
331
+ ? highlightedPair(pair).new
332
+ : (lang ? highlightLine(line.text, lang) : line.text);
333
+ out.push(...change(no, "+", p.successBg, p.success, addedText));
299
334
  }
300
335
  }
301
336
  return out;
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } 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
  // Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
5
6
  function explainSpawnError(err, cwd) {
@@ -77,21 +78,23 @@ export function executeCommand(opts) {
77
78
  return { session, done };
78
79
  }
79
80
  session.process = child;
80
- const handleData = (data) => {
81
- const raw = data.toString("utf-8");
81
+ const handleText = (raw) => {
82
+ if (!raw)
83
+ return;
82
84
  const clean = stripAnsi(raw);
83
- // Accumulate cleaned output for the agent
84
85
  session.output += clean;
85
- // Enforce output cap — truncate from beginning, keep tail
86
86
  if (session.output.length > maxOutput) {
87
87
  session.output = session.output.slice(-maxOutput);
88
88
  session.truncated = true;
89
89
  }
90
- // Real-time streaming callback
91
90
  opts.onOutput?.(raw);
92
91
  };
93
- child.stdout?.on("data", handleData);
94
- child.stderr?.on("data", handleData);
92
+ const outDecoder = new StringDecoder("utf-8");
93
+ const errDecoder = new StringDecoder("utf-8");
94
+ child.stdout?.on("data", (d) => handleText(outDecoder.write(d)));
95
+ child.stderr?.on("data", (d) => handleText(errDecoder.write(d)));
96
+ child.stdout?.on("end", () => handleText(outDecoder.end()));
97
+ child.stderr?.on("end", () => handleText(errDecoder.end()));
95
98
  let cancelKill;
96
99
  const timer = setTimeout(() => {
97
100
  if (!session.done) {
@@ -169,8 +172,9 @@ export function executeArgv(opts) {
169
172
  return { session, done };
170
173
  }
171
174
  session.process = child;
172
- const handleData = (data) => {
173
- const raw = data.toString("utf-8");
175
+ const handleText = (raw) => {
176
+ if (!raw)
177
+ return;
174
178
  const clean = stripAnsi(raw);
175
179
  session.output += clean;
176
180
  if (session.output.length > maxOutput) {
@@ -179,8 +183,12 @@ export function executeArgv(opts) {
179
183
  }
180
184
  opts.onOutput?.(raw);
181
185
  };
182
- child.stdout?.on("data", handleData);
183
- child.stderr?.on("data", handleData);
186
+ const outDecoder = new StringDecoder("utf-8");
187
+ const errDecoder = new StringDecoder("utf-8");
188
+ child.stdout?.on("data", (d) => handleText(outDecoder.write(d)));
189
+ child.stderr?.on("data", (d) => handleText(errDecoder.write(d)));
190
+ child.stdout?.on("end", () => handleText(outDecoder.end()));
191
+ child.stderr?.on("end", () => handleText(errDecoder.end()));
184
192
  const timer = setTimeout(() => {
185
193
  if (!session.done && session.process) {
186
194
  try {
@@ -222,6 +222,7 @@ export declare class FloatingPanel {
222
222
  private updateAutocomplete;
223
223
  private applyAutocomplete;
224
224
  private clearAutocomplete;
225
+ private moveAutocomplete;
225
226
  private handleIntercept;
226
227
  /**
227
228
  * Handle scroll input. Returns true if consumed.