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
@@ -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")) {
@@ -711,9 +668,7 @@ export default function activate(ctx) {
711
668
  if (!s.renderer)
712
669
  startAgentResponse();
713
670
  showCollapsedThinking();
714
- // No gap between grouped tools — they're visually connected
715
- if (!extra?.groupContinuation)
716
- contentGap("tool");
671
+ contentGap("tool");
717
672
  s.renderer.flush();
718
673
  drain();
719
674
  const lines = renderToolCall({
@@ -725,37 +680,19 @@ export default function activate(ctx) {
725
680
  rawInput: extra?.rawInput,
726
681
  displayDetail: extra?.displayDetail,
727
682
  }, cappedW(), shellCwd);
728
- if (extra?.groupContinuation && lines.length > 0) {
729
- // Swap the colored kind icon for a muted tree connector,
730
- // and strip the tool name prefix — show detail only.
731
- const detail = extra.displayDetail || extractDetail(extra);
732
- const maxW = Math.max(1, cappedW() - 6);
733
- const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
734
- lines[0] = detail
735
- ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
736
- : lines[0].replace(/^\x1b\[[^m]*m.\x1b\[0m/, `${p.muted}├${p.reset}`);
737
- }
738
- const batchPrefix = "";
739
683
  for (let i = 0; i < lines.length - 1; i++) {
740
684
  s.renderer.writeLine(lines[i]);
741
685
  }
742
686
  drain();
743
687
  if (lines.length > 0) {
744
- if (extra?.groupContinuation) {
745
- // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
746
- s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
747
- drain();
748
- }
749
- else {
750
- out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
751
- if (extra?.toolCallId) {
752
- s.openTool = {
753
- callId: extra.toolCallId,
754
- title,
755
- kind: extra.kind,
756
- displayDetail: extra.displayDetail ?? extractDetail(extra),
757
- };
758
- }
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
+ };
759
696
  }
760
697
  }
761
698
  s.hadToolCalls = true;
@@ -783,26 +720,6 @@ export default function activate(ctx) {
783
720
  if (resultDisplay?.body)
784
721
  renderResultBody(resultDisplay.body);
785
722
  }
786
- /** Late completion from a finalized group — re-emit the kind header
787
- * in muted "(cont.)" form so the ⎿ has a legitimate parent, then
788
- * render the completion as a normal labeled ⎿. Subsequent orphans
789
- * of the same kind reuse the existing (cont.) header. */
790
- function showOrphanedComplete(exitCode, resultDisplay, title, kind, displayDetail) {
791
- if (s.orphanContHeaderKind !== kind) {
792
- stopCurrentSpinner();
793
- closeToolLine();
794
- flushCommandOutput();
795
- if (!s.renderer)
796
- startAgentResponse();
797
- showCollapsedThinking();
798
- const icon = (kind && KIND_ICONS[kind]) ?? "▶";
799
- const label = kind ?? "tool";
800
- s.renderer.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
801
- drain();
802
- s.orphanContHeaderKind = kind;
803
- }
804
- showToolComplete(exitCode, resultDisplay, displayDetail || title);
805
- }
806
723
  function renderResultBody(body) {
807
724
  if (!s.renderer)
808
725
  return;
@@ -849,7 +766,7 @@ export default function activate(ctx) {
849
766
  function closeToolLine() {
850
767
  if (s.openTool) {
851
768
  out().write("\n");
852
- // Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
769
+ // Stash identity so the completion renders as ⎿ labeled, not inline ✓.
853
770
  s.pendingToolCompletes.set(s.openTool.callId, {
854
771
  title: s.openTool.title,
855
772
  kind: s.openTool.kind,
@@ -858,40 +775,64 @@ export default function activate(ctx) {
858
775
  s.openTool = null;
859
776
  }
860
777
  }
861
- /** Render the group aggregate line, or skip if no members have
862
- * completed yet (late completes will render individually as ⎿ labeled). */
863
- function finalizeToolGroup() {
864
- // Late completes from this group have lost their inline slot; mark
865
- // them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
866
- if (s.toolGroupKind) {
867
- for (const pending of s.pendingToolCompletes.values()) {
868
- if (pending.kind === s.toolGroupKind)
869
- pending.orphaned = true;
870
- }
871
- }
872
- const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
873
- if (s.toolGroupCount <= 1 || skipAggregate) {
874
- s.toolGroupKind = undefined;
875
- s.toolGroupCount = 0;
876
- s.toolGroupCompletedCount = 0;
877
- s.toolGroupRendered = 0;
878
- s.toolGroupAllOk = true;
879
- 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)
880
789
  return;
881
- }
882
790
  stopCurrentSpinner();
883
791
  closeToolLine();
884
792
  if (!s.renderer)
885
793
  startAgentResponse();
886
- const groupLine = ctx.call("tui:render-tool-group-summary", s.toolGroupCount, s.toolGroupRendered, s.toolGroupAllOk, s.toolGroupSummaries);
887
- s.renderer.writeLine(groupLine);
794
+ showCollapsedThinking();
795
+ contentGap("tool");
796
+ s.renderer.flush();
888
797
  drain();
889
- s.toolGroupKind = undefined;
890
- s.toolGroupCount = 0;
891
- s.toolGroupCompletedCount = 0;
892
- s.toolGroupAllOk = true;
893
- s.toolGroupRendered = 0;
894
- 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
+ }
895
836
  }
896
837
  function renderCommandLine(line) {
897
838
  return ctx.call("tui:render-command-output", line, s.currentToolKind);
@@ -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.
@@ -637,6 +637,13 @@ export class FloatingPanel {
637
637
  this.autocompleteItems = [];
638
638
  this.autocompleteIndex = 0;
639
639
  }
640
+ moveAutocomplete(delta) {
641
+ const n = this.autocompleteItems.length;
642
+ if (n === 0)
643
+ return;
644
+ this.autocompleteIndex = (this.autocompleteIndex + delta + n) % n;
645
+ this.render();
646
+ }
640
647
  // ── Input handling ──────────────────────────────────────────
641
648
  handleIntercept(payload) {
642
649
  const consumed = { ...payload, consumed: true };
@@ -746,6 +753,16 @@ export class FloatingPanel {
746
753
  }
747
754
  if (this.handleScroll(data, false))
748
755
  return;
756
+ if (data === "\x10" || data === "\x0e") {
757
+ const forward = data === "\x0e";
758
+ if (this.autocompleteActive) {
759
+ this.moveAutocomplete(forward ? 1 : -1);
760
+ }
761
+ else if (forward ? this.editor.historyForward() : this.editor.historyBack()) {
762
+ this.render();
763
+ }
764
+ return;
765
+ }
749
766
  const actions = this.editor.feed(data);
750
767
  for (const action of actions) {
751
768
  switch (action.action) {
@@ -760,6 +777,7 @@ export class FloatingPanel {
760
777
  this.editor.pushHistory(query);
761
778
  this.editor.clear();
762
779
  this.clearAutocomplete();
780
+ this.userScrolled = false;
763
781
  // Phase change is the submit handler's call — sync slash commands
764
782
  // (e.g. /model, /help) keep the user in input mode.
765
783
  this.handlers.call(`${this.prefix}:submit`, query);
@@ -782,34 +800,18 @@ export class FloatingPanel {
782
800
  case "shift+tab":
783
801
  this.render();
784
802
  break;
785
- case "arrow-up": {
786
- if (this.autocompleteActive) {
787
- this.autocompleteIndex = this.autocompleteIndex === 0
788
- ? this.autocompleteItems.length - 1
789
- : this.autocompleteIndex - 1;
790
- this.render();
791
- }
792
- else {
793
- const hist = this.editor.historyBack();
794
- if (hist)
795
- this.render();
796
- }
803
+ case "arrow-up":
804
+ if (this.autocompleteActive)
805
+ this.moveAutocomplete(-1);
806
+ else
807
+ this.scrollUp(1);
797
808
  break;
798
- }
799
- case "arrow-down": {
800
- if (this.autocompleteActive) {
801
- this.autocompleteIndex = this.autocompleteIndex === this.autocompleteItems.length - 1
802
- ? 0
803
- : this.autocompleteIndex + 1;
804
- this.render();
805
- }
806
- else {
807
- const hist = this.editor.historyForward();
808
- if (hist)
809
- this.render();
810
- }
809
+ case "arrow-down":
810
+ if (this.autocompleteActive)
811
+ this.moveAutocomplete(1);
812
+ else
813
+ this.scrollDown(1);
811
814
  break;
812
- }
813
815
  case "changed":
814
816
  case "delete-empty":
815
817
  this.updateAutocomplete();
@@ -332,27 +332,25 @@ export class MarkdownRenderer {
332
332
  renderLine(line) {
333
333
  if (line.trim() === "")
334
334
  return "";
335
- // Headings
336
- const h1 = line.match(/^# (.+)/);
337
- if (h1)
338
- return `${p.bold}${p.warning}${h1[1]}${p.reset}`;
339
- const h2 = line.match(/^## (.+)/);
340
- if (h2)
341
- return `${p.bold}${p.accent}${h2[1]}${p.reset}`;
342
- const h3 = line.match(/^### (.+)/);
343
- if (h3)
344
- return `${p.bold}${h3[1]}${p.reset}`;
345
- const h4 = line.match(/^#{4,} (.+)/);
346
- if (h4)
347
- return `${p.bold}${h4[1]}${p.reset}`;
348
- // Horizontal rule — subtle short separator, not full-width
335
+ // Headings — H3+ keep the `###` marker; H1/H2 don't
336
+ const heading = line.match(/^(#{1,6}) (.+)/);
337
+ if (heading) {
338
+ const level = heading[1].length;
339
+ const text = heading[2];
340
+ if (level === 1)
341
+ return `${p.bold}${p.underline}${p.mdHeading}${text}${p.reset}`;
342
+ if (level === 2)
343
+ return `${p.bold}${p.mdHeading}${text}${p.reset}`;
344
+ return `${p.bold}${p.mdHeading}${"#".repeat(level)} ${text}${p.reset}`;
345
+ }
346
+ // Horizontal rule
349
347
  if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
350
- return "";
348
+ return `${p.mdHr}${"".repeat(Math.min(this.contentWidth, 80))}${p.reset}`;
351
349
  }
352
350
  // Blockquote
353
351
  const bq = line.match(/^>\s?(.*)/);
354
352
  if (bq)
355
- return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
353
+ return `${p.mdQuoteBorder}│${p.reset} ${p.mdQuote}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
356
354
  // Task list (checkbox items) — must come before generic unordered list
357
355
  const task = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.*)/);
358
356
  if (task) {
@@ -367,21 +365,21 @@ export class MarkdownRenderer {
367
365
  const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
368
366
  if (ul) {
369
367
  const indent = ul[1] || "";
370
- return `${indent} ${p.accent}*${p.reset} ${this.renderInline(ul[2] || "")}`;
368
+ return `${indent} ${p.mdListBullet}-${p.reset} ${this.renderInline(ul[2] || "")}`;
371
369
  }
372
370
  // Ordered list
373
371
  const ol = line.match(/^(\s*)(\d+)[.)]\s+(.*)/);
374
372
  if (ol) {
375
373
  const indent = ol[1] || "";
376
- return `${indent} ${p.accent}${ol[2]}.${p.reset} ${this.renderInline(ol[3] || "")}`;
374
+ return `${indent} ${p.mdListBullet}${ol[2]}.${p.reset} ${this.renderInline(ol[3] || "")}`;
377
375
  }
378
376
  return this.renderInline(line);
379
377
  }
380
378
  renderInline(text) {
381
379
  // Links first — later subs inject `\x1b[…m` whose `[` would be eaten here.
382
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
380
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `${p.mdLink}${p.underline}$1${p.reset} ${p.mdLinkUrl}($2)${p.reset}`);
383
381
  // Inline code
384
- text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
382
+ text = text.replace(/`([^`]+)`/g, `${p.mdCode}$1${p.reset}`);
385
383
  // Bold + italic
386
384
  text = text.replace(/\*\*\*(.+?)\*\*\*/g, `${p.bold}${p.italic}$1${p.reset}`);
387
385
  // Bold
@@ -391,7 +389,7 @@ export class MarkdownRenderer {
391
389
  text = text.replace(/\*(.+?)\*/g, `${p.italic}$1${p.reset}`);
392
390
  text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
393
391
  // Strikethrough
394
- text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
392
+ text = text.replace(/~~(.+?)~~/g, `${p.strikethrough}$1${p.reset}`);
395
393
  return text;
396
394
  }
397
395
  /**