mini-coder 0.5.5 → 0.5.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.
package/src/submit.ts CHANGED
@@ -204,12 +204,63 @@ export function resolveRawInput(
204
204
  }
205
205
  }
206
206
 
207
+ function recordRawPromptHistory(
208
+ rawInput: string,
209
+ state: Pick<AppState, "db" | "cwd">,
210
+ sessionId: string,
211
+ ): void {
212
+ appendPromptHistory(state.db, {
213
+ text: rawInput,
214
+ cwd: state.cwd,
215
+ sessionId,
216
+ });
217
+ truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
218
+ }
219
+
220
+ /**
221
+ * Queue resolved user content for the next model-request boundary of an active run.
222
+ *
223
+ * The raw prompt is recorded immediately in prompt history, but the model-visible
224
+ * `UserMessage` is only appended to session history when the agent loop consumes it.
225
+ *
226
+ * @param rawInput - Exact raw submitted prompt text.
227
+ * @param content - Resolved model-visible user content.
228
+ * @param state - Mutable application state.
229
+ */
230
+ export function queueResolvedInput(
231
+ rawInput: string,
232
+ content: UserMessage["content"],
233
+ state: AppState,
234
+ ): void {
235
+ if (!state.running) {
236
+ throw new Error("Cannot queue input while no turn is running.");
237
+ }
238
+ if (isEmptyUserContent(content)) {
239
+ throw new Error("Cannot queue empty input.");
240
+ }
241
+
242
+ const session = ensureSession(state);
243
+ recordRawPromptHistory(rawInput, state, session.id);
244
+ state.queuedUserMessages.push({
245
+ role: "user",
246
+ content,
247
+ timestamp: Date.now(),
248
+ });
249
+ }
250
+
207
251
  // ---------------------------------------------------------------------------
208
252
  // Turn submission
209
253
  // ---------------------------------------------------------------------------
210
254
 
211
255
  function handleAgentEvent(event: AgentEvent, state: AppState): void {
212
256
  switch (event.type) {
257
+ case "user_message":
258
+ state.messages.push(event.message);
259
+ state.contextTokens = addMessageToContextTokens(
260
+ state.contextTokens,
261
+ event.message,
262
+ );
263
+ break;
213
264
  case "assistant_message":
214
265
  state.messages.push(event.message);
215
266
  state.stats = addMessageToStats(state.stats, event.message);
@@ -270,12 +321,7 @@ export async function submitResolvedInput(
270
321
  }
271
322
 
272
323
  const session = ensureSession(state);
273
- appendPromptHistory(state.db, {
274
- text: rawInput,
275
- cwd: state.cwd,
276
- sessionId: session.id,
277
- });
278
- truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
324
+ recordRawPromptHistory(rawInput, state, session.id);
279
325
 
280
326
  const userMessage = {
281
327
  role: "user",
@@ -318,6 +364,7 @@ export async function submitResolvedInput(
318
364
  apiKey: state.providers.get(state.model.provider),
319
365
  effort: state.effort,
320
366
  signal: state.abortController.signal,
367
+ takeQueuedUserMessage: () => state.queuedUserMessages.shift() ?? null,
321
368
  onEvent: (event) => {
322
369
  handleAgentEvent(event, state);
323
370
  hooks?.onEvent?.(event, state);
package/src/tools.ts CHANGED
@@ -490,9 +490,12 @@ interface ShellOpts {
490
490
  onUpdate?: ToolUpdateCallback;
491
491
  }
492
492
 
493
+ type ShellProcess = ReturnType<typeof Bun.spawn>;
494
+
493
495
  const DEFAULT_MAX_LINES = 1000;
494
496
  const DEFAULT_MAX_BYTES = 50_000;
495
497
  const SHELL_UPDATE_INTERVAL_MS = 75;
498
+ const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
496
499
 
497
500
  /** Format combined stdout/stderr for display in tool results. */
498
501
  function formatShellOutput(stdout: string, stderr: string): string {
@@ -837,26 +840,132 @@ function normalizeShellCommand(command: string): string {
837
840
  }
838
841
  }
839
842
 
840
- /** Read a spawned shell stream into a string, reporting progressive updates. */
841
- async function consumeShellStream(
843
+ interface ShellStreamCapture {
844
+ done: Promise<void>;
845
+ getOutput: () => string;
846
+ isFinished: () => boolean;
847
+ close: () => Promise<void>;
848
+ }
849
+
850
+ function startShellStreamCapture(
842
851
  stream: ReadableStream<Uint8Array>,
843
852
  onChunk: (chunk: string) => void,
844
- ): Promise<string> {
853
+ ): ShellStreamCapture {
845
854
  const reader = stream.getReader();
846
855
  const decoder = new TextDecoder();
847
856
  let output = "";
857
+ let closed = false;
858
+ let finished = false;
859
+
860
+ const done = (async (): Promise<void> => {
861
+ try {
862
+ while (true) {
863
+ const { done, value } = await reader.read();
864
+ if (done) {
865
+ break;
866
+ }
867
+
868
+ const chunk = decoder.decode(value, { stream: true });
869
+ output += chunk;
870
+ onChunk(chunk);
871
+ }
872
+ } catch (error) {
873
+ if (!closed) {
874
+ throw error;
875
+ }
876
+ } finally {
877
+ const trailing = decoder.decode();
878
+ output += trailing;
879
+ onChunk(trailing);
880
+ finished = true;
881
+ }
882
+ })();
848
883
 
849
- while (true) {
850
- const { done, value } = await reader.read();
851
- if (done) {
852
- output += decoder.decode();
853
- return output;
884
+ return {
885
+ done,
886
+ getOutput: () => output,
887
+ isFinished: () => finished,
888
+ close: async (): Promise<void> => {
889
+ if (!finished) {
890
+ closed = true;
891
+ try {
892
+ await reader.cancel();
893
+ } catch {
894
+ // Ignore cancellation errors while closing the pipe after exit/abort.
895
+ }
896
+ }
897
+ await done;
898
+ },
899
+ };
900
+ }
901
+
902
+ async function finalizeShellStreamCaptures(
903
+ captures: readonly ShellStreamCapture[],
904
+ ): Promise<void> {
905
+ const pending = captures
906
+ .filter((capture) => !capture.isFinished())
907
+ .map((capture) => capture.done);
908
+
909
+ if (pending.length > 0) {
910
+ await new Promise<void>((resolve) => {
911
+ const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
912
+ void Promise.allSettled(pending).then(() => {
913
+ clearTimeout(timer);
914
+ resolve();
915
+ });
916
+ });
917
+ }
918
+
919
+ await Promise.all(captures.map((capture) => capture.close()));
920
+ }
921
+
922
+ function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
923
+ return {
924
+ cwd,
925
+ stdout: "pipe",
926
+ stderr: "pipe",
927
+ ...(process.platform === "win32" ? {} : { detached: true }),
928
+ };
929
+ }
930
+
931
+ function abortShellProcess(proc: ShellProcess): void {
932
+ if (proc.killed || proc.exitCode !== null) {
933
+ return;
934
+ }
935
+
936
+ if (process.platform !== "win32") {
937
+ try {
938
+ process.kill(-proc.pid, "SIGTERM");
939
+ return;
940
+ } catch {
941
+ // Fall through to a direct kill when the process group is unavailable.
854
942
  }
943
+ }
944
+
945
+ proc.kill("SIGTERM");
946
+ }
947
+
948
+ function registerShellAbort(
949
+ signal: AbortSignal | undefined,
950
+ proc: ShellProcess,
951
+ ): (() => void) | null {
952
+ if (!signal) {
953
+ return null;
954
+ }
955
+
956
+ const abortListener = (): void => {
957
+ abortShellProcess(proc);
958
+ };
855
959
 
856
- const chunk = decoder.decode(value, { stream: true });
857
- output += chunk;
858
- onChunk(chunk);
960
+ if (signal.aborted) {
961
+ abortShellProcess(proc);
962
+ return null;
859
963
  }
964
+
965
+ signal.addEventListener("abort", abortListener, { once: true });
966
+ return () => {
967
+ signal.removeEventListener("abort", abortListener);
968
+ };
860
969
  }
861
970
 
862
971
  /**
@@ -880,22 +989,13 @@ export async function executeShell(
880
989
  const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
881
990
  const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
882
991
  let updateTimer: ReturnType<typeof setTimeout> | null = null;
992
+ let cleanupAbort: (() => void) | null = null;
993
+ let lastReportedOutput = "";
994
+ let lastReportAt = 0;
995
+ let stdoutCapture: ShellStreamCapture | null = null;
996
+ let stderrCapture: ShellStreamCapture | null = null;
883
997
 
884
998
  try {
885
- const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
886
- cwd,
887
- stdout: "pipe",
888
- stderr: "pipe",
889
- };
890
- if (opts?.signal) spawnOpts.signal = opts.signal;
891
- const command = normalizeShellCommand(args.command);
892
- const proc = Bun.spawn([shell, "-c", command], spawnOpts);
893
-
894
- let stdoutBuf = "";
895
- let stderrBuf = "";
896
- let lastReportedOutput = "";
897
- let lastReportAt = 0;
898
-
899
999
  const clearPendingUpdate = (): void => {
900
1000
  if (updateTimer) {
901
1001
  clearTimeout(updateTimer);
@@ -903,9 +1003,14 @@ export async function executeShell(
903
1003
  }
904
1004
  };
905
1005
 
906
- const buildProgressOutput = (): string => {
1006
+ const buildOutput = (trimEnd: boolean): string => {
1007
+ const stdout = stdoutCapture?.getOutput() ?? "";
1008
+ const stderr = stderrCapture?.getOutput() ?? "";
907
1009
  return truncateOutput(
908
- formatShellOutput(stdoutBuf, stderrBuf),
1010
+ formatShellOutput(
1011
+ trimEnd ? stdout.trimEnd() : stdout,
1012
+ trimEnd ? stderr.trimEnd() : stderr,
1013
+ ),
909
1014
  maxLines,
910
1015
  maxBytes,
911
1016
  );
@@ -917,7 +1022,7 @@ export async function executeShell(
917
1022
  return;
918
1023
  }
919
1024
 
920
- const output = buildProgressOutput();
1025
+ const output = buildOutput(false);
921
1026
  if (!output || output === lastReportedOutput) {
922
1027
  return;
923
1028
  }
@@ -946,24 +1051,29 @@ export async function executeShell(
946
1051
  }, SHELL_UPDATE_INTERVAL_MS - elapsed);
947
1052
  };
948
1053
 
949
- const [stdout, stderr, exitCode] = await Promise.all([
950
- consumeShellStream(proc.stdout as ReadableStream<Uint8Array>, (chunk) => {
951
- stdoutBuf += chunk;
1054
+ const command = normalizeShellCommand(args.command);
1055
+ const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
1056
+ cleanupAbort = registerShellAbort(opts?.signal, proc);
1057
+ stdoutCapture = startShellStreamCapture(
1058
+ proc.stdout as ReadableStream<Uint8Array>,
1059
+ () => {
952
1060
  scheduleUpdate();
953
- }),
954
- consumeShellStream(proc.stderr as ReadableStream<Uint8Array>, (chunk) => {
955
- stderrBuf += chunk;
1061
+ },
1062
+ );
1063
+ stderrCapture = startShellStreamCapture(
1064
+ proc.stderr as ReadableStream<Uint8Array>,
1065
+ () => {
956
1066
  scheduleUpdate();
957
- }),
958
- proc.exited,
959
- ]);
1067
+ },
1068
+ );
1069
+
1070
+ const exitCode = await proc.exited;
1071
+ cleanupAbort?.();
1072
+ cleanupAbort = null;
960
1073
 
1074
+ await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
961
1075
  clearPendingUpdate();
962
- const output = truncateOutput(
963
- formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
964
- maxLines,
965
- maxBytes,
966
- );
1076
+ const output = buildOutput(true);
967
1077
  if (opts?.onUpdate && output && output !== lastReportedOutput) {
968
1078
  lastReportedOutput = output;
969
1079
  opts.onUpdate(textResult(output, false));
@@ -973,6 +1083,14 @@ export async function executeShell(
973
1083
  const body = output || "(no output)";
974
1084
  return textResult(`Exit code: ${exitCode}\n${body}`, isError);
975
1085
  } catch (err) {
1086
+ cleanupAbort?.();
1087
+ cleanupAbort = null;
1088
+ const captures = [stdoutCapture, stderrCapture].filter(
1089
+ (capture): capture is ShellStreamCapture => capture !== null,
1090
+ );
1091
+ if (captures.length > 0) {
1092
+ await Promise.allSettled(captures.map((capture) => capture.close()));
1093
+ }
976
1094
  if (updateTimer) {
977
1095
  clearTimeout(updateTimer);
978
1096
  updateTimer = null;
package/src/ui/agent.ts CHANGED
@@ -13,7 +13,11 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
13
13
  import type { AgentEvent } from "../agent.ts";
14
14
  import { getErrorMessage } from "../errors.ts";
15
15
  import type { AppState } from "../index.ts";
16
- import { resolveRawInput, submitResolvedInput } from "../submit.ts";
16
+ import {
17
+ queueResolvedInput,
18
+ resolveRawInput,
19
+ submitResolvedInput,
20
+ } from "../submit.ts";
17
21
  import type {
18
22
  PendingToolResult,
19
23
  StreamingConversationState,
@@ -103,7 +107,12 @@ export function createUiAgentController(
103
107
  break;
104
108
  }
105
109
 
106
- if (!state.model || state.running) {
110
+ if (state.running) {
111
+ queueResolvedInput(rawInput, resolved.content, state);
112
+ return;
113
+ }
114
+
115
+ if (!state.model) {
107
116
  return;
108
117
  }
109
118
 
@@ -154,6 +163,11 @@ export function createUiAgentController(
154
163
  runtime.render();
155
164
  break;
156
165
 
166
+ case "user_message":
167
+ runtime.scrollConversationToBottom();
168
+ runtime.render();
169
+ break;
170
+
157
171
  case "assistant_message":
158
172
  streamingContent = [];
159
173
  runtime.render();
@@ -91,6 +91,7 @@ function createTestState(): AppState {
91
91
  running: false,
92
92
  abortController: null,
93
93
  activeTurnPromise: null,
94
+ queuedUserMessages: [],
94
95
  showReasoning: true,
95
96
  verbose: false,
96
97
  versionLabel: "dev",
@@ -838,6 +838,93 @@ describe("ui/conversation", () => {
838
838
  expect(text.some((line) => line.includes("TAIL"))).toBe(true);
839
839
  });
840
840
 
841
+ test("renderAssistantMessage for a long single-token shell argument wraps through the tail in a narrow viewport", async () => {
842
+ // Arrange
843
+ const command = `printf ${"x".repeat(40)}TAIL`;
844
+ const assistant = {
845
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
846
+ };
847
+
848
+ // Act
849
+ const text = await renderVisibleText(
850
+ renderAssistantMessage(assistant, {
851
+ ...RENDER_OPTS,
852
+ verbose: true,
853
+ previewWidth: 12,
854
+ }),
855
+ 12,
856
+ 20,
857
+ );
858
+
859
+ // Assert
860
+ expect(text.some((line) => line.includes("TAIL"))).toBe(true);
861
+ });
862
+
863
+ test("renderAssistantMessage for a long quoted shell string preserves string color across wrapped rows", async () => {
864
+ // Arrange
865
+ const command = `printf "${"x".repeat(80)}TAIL"`;
866
+ const assistant = {
867
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
868
+ };
869
+
870
+ // Act
871
+ const rows = await renderBufferRows(
872
+ renderAssistantMessage(assistant, {
873
+ ...RENDER_OPTS,
874
+ verbose: true,
875
+ previewWidth: 24,
876
+ }),
877
+ 24,
878
+ 20,
879
+ );
880
+ const headRowIndex = rows.findIndex((row) => row.text.includes('"x'));
881
+ const headRow = headRowIndex >= 0 ? rows[headRowIndex] : undefined;
882
+ const wrappedRow =
883
+ headRowIndex >= 0
884
+ ? rows
885
+ .slice(headRowIndex + 1)
886
+ .find((row) => row.text.includes("xxxxxxxx"))
887
+ : undefined;
888
+
889
+ // Assert
890
+ expect(headRow).toBeDefined();
891
+ expect(wrappedRow).toBeDefined();
892
+ expect(headRow?.fgColors[headRow.text.indexOf('"')]).toBe(
893
+ DEFAULT_THEME.diffAdded ?? null,
894
+ );
895
+ expect(wrappedRow?.fgColors[wrappedRow.text.indexOf("x")]).toBe(
896
+ DEFAULT_THEME.diffAdded ?? null,
897
+ );
898
+ });
899
+
900
+ test("renderAssistantMessage for long inline markdown code keeps the tail visible", async () => {
901
+ // Arrange
902
+ const message = fauxAssistantMessage(`Use \`${"x".repeat(80)}TAIL\``);
903
+
904
+ // Act
905
+ const rows = await renderBufferRows(
906
+ renderAssistantMessage(message, {
907
+ ...RENDER_OPTS,
908
+ previewWidth: 24,
909
+ }),
910
+ 24,
911
+ 20,
912
+ );
913
+ const codeRows = rows.filter(
914
+ (row) => row.text.includes("x") || row.text.includes("TAIL`"),
915
+ );
916
+
917
+ // Assert
918
+ expect(codeRows.length).toBeGreaterThan(1);
919
+ expect(rows.some((row) => row.text.includes("TAIL`"))).toBe(true);
920
+ expect(codeRows[0]?.fgColors[codeRows[0].text.indexOf("x")]).toBe(
921
+ DEFAULT_THEME.diffAdded ?? null,
922
+ );
923
+ expect(codeRows.at(-1)?.fgColors[codeRows.at(-1)!.text.indexOf("T")]).toBe(
924
+ DEFAULT_THEME.diffAdded ?? null,
925
+ );
926
+ });
927
+
841
928
  test("renderAssistantMessage for a long single-token shell command uses wrapped preview height when verbose is off", () => {
842
929
  // Arrange
843
930
  const command = `printf ${"x".repeat(220)}TAIL`;
@@ -34,6 +34,9 @@ const UI_TOOL_PREVIEW_ROWS = 8;
34
34
  /** Default width used when preview measurements do not receive one explicitly. */
35
35
  const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
36
36
 
37
+ /** Horizontal columns consumed by assistant-markdown padding. */
38
+ const MARKDOWN_BLOCK_CHROME_WIDTH = 2;
39
+
37
40
  /** Horizontal columns consumed by tool-block padding and the left border. */
38
41
  const TOOL_BLOCK_CHROME_WIDTH = 4;
39
42
 
@@ -226,7 +229,11 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
226
229
  }
227
230
 
228
231
  /** Render a syntax-highlighted raw markdown block. */
229
- function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
232
+ function renderMarkdownTextBlock(
233
+ content: string,
234
+ theme: Theme,
235
+ previewWidth?: number,
236
+ ): Node | null {
230
237
  if (content === "") {
231
238
  return null;
232
239
  }
@@ -238,6 +245,7 @@ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
238
245
  themeVariant: "markdown",
239
246
  },
240
247
  theme,
248
+ getMarkdownBodyWidth(previewWidth),
241
249
  );
242
250
  if (children.length === 0) {
243
251
  return null;
@@ -313,7 +321,7 @@ function renderAssistantContentBlock(
313
321
  opts: ConversationRenderOpts,
314
322
  ): Node | null {
315
323
  if (block.type === "text" && block.text) {
316
- return renderMarkdownTextBlock(block.text, opts.theme);
324
+ return renderMarkdownTextBlock(block.text, opts.theme, opts.previewWidth);
317
325
  }
318
326
  if (block.type === "thinking" && block.thinking) {
319
327
  return renderThinkingBlock(block.thinking, opts);
@@ -366,10 +374,21 @@ function getPreviewWidth(previewWidth?: number): number {
366
374
  return Math.max(1, Math.floor(previewWidth!));
367
375
  }
368
376
 
377
+ function getMarkdownBodyWidth(previewWidth?: number): number {
378
+ return Math.max(
379
+ 1,
380
+ getPreviewWidth(previewWidth) - MARKDOWN_BLOCK_CHROME_WIDTH,
381
+ );
382
+ }
383
+
369
384
  function getToolBodyWidth(previewWidth?: number): number {
370
385
  return Math.max(1, getPreviewWidth(previewWidth) - TOOL_BLOCK_CHROME_WIDTH);
371
386
  }
372
387
 
388
+ function getHighlightWrapChunkSize(bodyWidth: number): number {
389
+ return Math.max(1, Math.min(HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES, bodyWidth));
390
+ }
391
+
373
392
  /** Split multi-line tool text into logical render lines. */
374
393
  function splitToolTextLines(
375
394
  text: string,
@@ -426,6 +445,7 @@ type SyntaxThemeTokenColor = NonNullable<
426
445
  const graphemeSegmenter = new Intl.Segmenter(undefined, {
427
446
  granularity: "grapheme",
428
447
  });
448
+ const HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES = 32;
429
449
  const syntaxThemeCache: Record<
430
450
  SyntaxThemeVariant,
431
451
  WeakMap<Theme, SyntaxThemeRegistration>
@@ -559,6 +579,7 @@ function getSyntaxTheme(
559
579
 
560
580
  function splitHighlightedTextNode(
561
581
  node: Extract<Node, { type: "text" }>,
582
+ chunkSize: number,
562
583
  ): Node[] {
563
584
  if (node.content === "") {
564
585
  return [Text("", node.props)];
@@ -576,21 +597,37 @@ function splitHighlightedTextNode(
576
597
  continue;
577
598
  }
578
599
 
600
+ let chunk = "";
601
+ let chunkGraphemes = 0;
579
602
  for (const { segment } of graphemeSegmenter.segment(part)) {
580
- children.push(Text(segment, node.props));
603
+ chunk += segment;
604
+ chunkGraphemes += 1;
605
+
606
+ if (chunkGraphemes === chunkSize) {
607
+ children.push(Text(chunk, node.props));
608
+ chunk = "";
609
+ chunkGraphemes = 0;
610
+ }
611
+ }
612
+
613
+ if (chunk !== "") {
614
+ children.push(Text(chunk, node.props));
581
615
  }
582
616
  }
583
617
 
584
618
  return children;
585
619
  }
586
620
 
587
- function normalizeHighlightedLine(line: Node): Node {
621
+ function normalizeHighlightedLine(line: Node, bodyWidth: number): Node {
588
622
  if (line.type !== "hstack") {
589
623
  return line;
590
624
  }
591
625
 
626
+ const chunkSize = getHighlightWrapChunkSize(bodyWidth);
592
627
  const children = line.children.flatMap((child) => {
593
- return child.type === "text" ? splitHighlightedTextNode(child) : [child];
628
+ return child.type === "text"
629
+ ? splitHighlightedTextNode(child, chunkSize)
630
+ : [child];
594
631
  });
595
632
  return HStack(line.props, children);
596
633
  }
@@ -598,6 +635,7 @@ function normalizeHighlightedLine(line: Node): Node {
598
635
  function getHighlightedBodyLines(
599
636
  spec: HighlightedBodySpec,
600
637
  theme: Theme,
638
+ bodyWidth: number,
601
639
  ): Node[] {
602
640
  if (spec.text === "") {
603
641
  return [];
@@ -606,7 +644,9 @@ function getHighlightedBodyLines(
606
644
  const highlighted = SyntaxHighlight(spec.text, spec.language, {
607
645
  theme: getSyntaxTheme(theme, spec.themeVariant),
608
646
  });
609
- return highlighted.children.map((line) => normalizeHighlightedLine(line));
647
+ return highlighted.children.map((line) =>
648
+ normalizeHighlightedLine(line, bodyWidth),
649
+ );
610
650
  }
611
651
 
612
652
  /** Render a single styled text node for a tool line. */
@@ -740,7 +780,11 @@ function renderToolBody(
740
780
  ): { body: Node | null; summary?: ToolRenderLine } {
741
781
  if (spec.highlightedBody) {
742
782
  return renderToolBodyFromNodes(
743
- getHighlightedBodyLines(spec.highlightedBody, opts.theme),
783
+ getHighlightedBodyLines(
784
+ spec.highlightedBody,
785
+ opts.theme,
786
+ getToolBodyWidth(opts.previewWidth),
787
+ ),
744
788
  spec.previewBody,
745
789
  opts,
746
790
  );
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { DEFAULT_THEME } from "../theme.ts";
6
6
  import {
7
7
  autocompleteInputPath,
8
+ findInputPathMatches,
8
9
  type InputController,
9
10
  renderInputArea,
10
11
  } from "./input.ts";
@@ -60,7 +61,7 @@ describe("ui/input", () => {
60
61
  expect(placeholder.props.italic).toBe(true);
61
62
  });
62
63
 
63
- test("autocompleteInputPath completes the last file path token", () => {
64
+ test("autocompleteInputPath completes the last file path token when exactly one match is available", () => {
64
65
  const cwd = createTempDir();
65
66
  mkdirSync(join(cwd, "src"), { recursive: true });
66
67
  writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
@@ -70,6 +71,33 @@ describe("ui/input", () => {
70
71
  );
71
72
  });
72
73
 
74
+ test("autocompleteInputPath returns null when multiple matches are available", () => {
75
+ const cwd = createTempDir();
76
+ mkdirSync(join(cwd, "src"), { recursive: true });
77
+ writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
78
+ writeFileSync(join(cwd, "src", "utils.ts"), "", "utf-8");
79
+
80
+ expect(autocompleteInputPath("inspect src/u", cwd)).toBeNull();
81
+ });
82
+
83
+ test("findInputPathMatches returns sorted selectable matches for the last file path token", () => {
84
+ const cwd = createTempDir();
85
+ mkdirSync(join(cwd, "src"), { recursive: true });
86
+ writeFileSync(join(cwd, "src", "alpine.ts"), "", "utf-8");
87
+ writeFileSync(join(cwd, "src", "alpha.ts"), "", "utf-8");
88
+
89
+ expect(findInputPathMatches("inspect src/al", cwd)).toEqual([
90
+ {
91
+ label: "src/alpha.ts",
92
+ value: "inspect src/alpha.ts",
93
+ },
94
+ {
95
+ label: "src/alpine.ts",
96
+ value: "inspect src/alpine.ts",
97
+ },
98
+ ]);
99
+ });
100
+
73
101
  test("autocompleteInputPath returns null when no completion is available", () => {
74
102
  const cwd = createTempDir();
75
103