mini-coder 0.5.2 → 0.5.4

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.
@@ -64,7 +64,15 @@ async function renderBufferRows(
64
64
  node: Node | null,
65
65
  cols = PREVIEW_WIDTH,
66
66
  rows = 24,
67
- ): Promise<Array<{ text: string; fgColors: Array<string | null> }>> {
67
+ ): Promise<
68
+ Array<{
69
+ text: string;
70
+ fgColors: Array<string | null>;
71
+ bold: boolean[];
72
+ italic: boolean[];
73
+ underline: boolean[];
74
+ }>
75
+ > {
68
76
  if (!node) {
69
77
  return [];
70
78
  }
@@ -79,16 +87,28 @@ async function renderBufferRows(
79
87
  throw new Error("Expected cel-tui to produce a render buffer");
80
88
  }
81
89
 
82
- const snapshot: Array<{ text: string; fgColors: Array<string | null> }> = [];
90
+ const snapshot: Array<{
91
+ text: string;
92
+ fgColors: Array<string | null>;
93
+ bold: boolean[];
94
+ italic: boolean[];
95
+ underline: boolean[];
96
+ }> = [];
83
97
  for (let y = 0; y < rows; y++) {
84
98
  let text = "";
85
99
  const fgColors: Array<string | null> = [];
100
+ const bold: boolean[] = [];
101
+ const italic: boolean[] = [];
102
+ const underline: boolean[] = [];
86
103
  for (let x = 0; x < cols; x++) {
87
104
  const cell = buffer.get(x, y);
88
105
  text += cell.char;
89
106
  fgColors.push(cell.fgColor);
107
+ bold.push(cell.bold);
108
+ italic.push(cell.italic);
109
+ underline.push(cell.underline);
90
110
  }
91
- snapshot.push({ text, fgColors });
111
+ snapshot.push({ text, fgColors, bold, italic, underline });
92
112
  }
93
113
 
94
114
  cel.stop();
@@ -119,18 +139,20 @@ afterEach(() => {
119
139
  });
120
140
 
121
141
  describe("ui/conversation", () => {
122
- test("renderAssistantMessage preserves visible paragraph order in streamed markdown", () => {
142
+ test("renderAssistantMessage keeps raw markdown markers visible in assistant text", () => {
123
143
  // Arrange
124
- const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
144
+ const message = fauxAssistantMessage(
145
+ "# Heading\n\nUse **bold** and `code`.\n- item",
146
+ );
125
147
 
126
148
  // Act
127
149
  const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
128
- const firstParagraphIndex = text.indexOf("First paragraph");
129
- const secondParagraphIndex = text.indexOf("Second paragraph");
130
150
 
131
151
  // Assert
132
- expect(firstParagraphIndex).toBeGreaterThanOrEqual(0);
133
- expect(secondParagraphIndex).toBeGreaterThan(firstParagraphIndex);
152
+ expect(text).toContain("# Heading");
153
+ expect(text).toContain("Use **bold** and `code`.");
154
+ expect(text).toContain("- item");
155
+ expect(text).not.toContain("Use bold and code.");
134
156
  });
135
157
 
136
158
  test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
@@ -196,18 +218,131 @@ describe("ui/conversation", () => {
196
218
  expect(height).toBe(6);
197
219
  });
198
220
 
199
- test("renderAssistantMessage keeps markdown paragraph spacing inside a single text block", () => {
221
+ test("renderAssistantMessage syntax-highlights markdown tokens with theme-derived colors", async () => {
200
222
  // Arrange
201
- const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
223
+ const theme = {
224
+ ...DEFAULT_THEME,
225
+ accentText: "color14",
226
+ secondaryAccentText: "color09",
227
+ diffAdded: "color10",
228
+ mutedText: "color13",
229
+ } satisfies typeof DEFAULT_THEME;
230
+ const message = fauxAssistantMessage("# Heading\n- item\n> quote\n`code`");
202
231
 
203
232
  // Act
204
- const height = measureRenderedHeight(
205
- renderAssistantMessage(message, RENDER_OPTS),
206
- PREVIEW_WIDTH,
233
+ const rows = await renderBufferRows(
234
+ renderAssistantMessage(message, {
235
+ ...RENDER_OPTS,
236
+ theme,
237
+ }),
238
+ 32,
239
+ 12,
240
+ );
241
+ const headingRow = rows.find((row) => row.text.includes("# Heading"));
242
+ const bulletRow = rows.find((row) => row.text.includes("- item"));
243
+ const quoteRow = rows.find((row) => row.text.includes("> quote"));
244
+ const codeRow = rows.find((row) => row.text.includes("`code`"));
245
+
246
+ // Assert
247
+ expect(headingRow).toBeDefined();
248
+ expect(bulletRow).toBeDefined();
249
+ expect(quoteRow).toBeDefined();
250
+ expect(codeRow).toBeDefined();
251
+ expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
252
+ theme.accentText ?? null,
253
+ );
254
+ expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
255
+ theme.secondaryAccentText ?? null,
256
+ );
257
+ expect(quoteRow?.fgColors[quoteRow.text.indexOf(">")]).toBe(
258
+ theme.mutedText ?? null,
259
+ );
260
+ expect(quoteRow?.italic[quoteRow.text.indexOf(">")]).toBe(true);
261
+ expect(codeRow?.fgColors[codeRow.text.indexOf("`")]).toBe(
262
+ theme.diffAdded ?? null,
263
+ );
264
+ });
265
+
266
+ test("renderAssistantMessage syntax-highlights markdown emphasis and links with style cues", async () => {
267
+ // Arrange
268
+ const theme = {
269
+ ...DEFAULT_THEME,
270
+ accentText: "color14",
271
+ diffAdded: "color10",
272
+ } satisfies typeof DEFAULT_THEME;
273
+ const message = fauxAssistantMessage(
274
+ "*italic* **bold** [label](https://example.com)",
275
+ );
276
+
277
+ // Act
278
+ const rows = await renderBufferRows(
279
+ renderAssistantMessage(message, {
280
+ ...RENDER_OPTS,
281
+ theme,
282
+ }),
283
+ 64,
284
+ 12,
285
+ );
286
+ const contentRow = rows.find((row) =>
287
+ row.text.includes("*italic* **bold** [label](https://example.com)"),
207
288
  );
208
289
 
209
290
  // Assert
210
- expect(height).toBe(3);
291
+ expect(contentRow).toBeDefined();
292
+ expect(contentRow?.italic[contentRow.text.indexOf("*italic*")]).toBe(true);
293
+ expect(contentRow?.bold[contentRow.text.indexOf("**bold**")]).toBe(true);
294
+ expect(contentRow?.fgColors[contentRow.text.indexOf("label")]).toBe(
295
+ theme.diffAdded ?? null,
296
+ );
297
+ expect(
298
+ contentRow?.fgColors[contentRow.text.indexOf("https://example.com")],
299
+ ).toBe(theme.accentText ?? null);
300
+ expect(
301
+ contentRow?.underline[contentRow.text.indexOf("https://example.com")],
302
+ ).toBe(true);
303
+ });
304
+
305
+ test("renderAssistantMessage with adjacent text blocks preserves markdown structure across the block boundary", async () => {
306
+ // Arrange
307
+ const singleBlock = {
308
+ content: [fauxText("```js\nconst x = 1;\n```")],
309
+ };
310
+ const splitBlocks = {
311
+ content: [fauxText("```js\n"), fauxText("const x = 1;\n```")],
312
+ };
313
+
314
+ // Act
315
+ const singleRows = await renderBufferRows(
316
+ renderAssistantMessage(singleBlock, RENDER_OPTS),
317
+ 40,
318
+ 12,
319
+ );
320
+ const splitRows = await renderBufferRows(
321
+ renderAssistantMessage(splitBlocks, RENDER_OPTS),
322
+ 40,
323
+ 12,
324
+ );
325
+ const singleHeight = measureRenderedHeight(
326
+ renderAssistantMessage(singleBlock, RENDER_OPTS),
327
+ 40,
328
+ );
329
+ const splitHeight = measureRenderedHeight(
330
+ renderAssistantMessage(splitBlocks, RENDER_OPTS),
331
+ 40,
332
+ );
333
+ const singleRow = singleRows.find((row) =>
334
+ row.text.includes("const x = 1;"),
335
+ );
336
+ const splitRow = splitRows.find((row) => row.text.includes("const x = 1;"));
337
+
338
+ // Assert
339
+ expect(singleRow).toBeDefined();
340
+ expect(splitRow).toBeDefined();
341
+ expect(singleHeight).toBe(splitHeight);
342
+ expect(singleRow?.fgColors[singleRow.text.indexOf("const")]).not.toBe(null);
343
+ expect(splitRow?.fgColors[splitRow.text.indexOf("const")]).toBe(
344
+ singleRow?.fgColors[singleRow.text.indexOf("const")],
345
+ );
211
346
  });
212
347
 
213
348
  test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
@@ -250,9 +385,9 @@ describe("ui/conversation", () => {
250
385
 
251
386
  // Assert
252
387
  expect(text).toContain("Working...");
253
- expect(text.filter((line) => line === "[shell ->]")).toHaveLength(1);
388
+ expect(text.filter((line) => line === "shell ->")).toHaveLength(1);
254
389
  expect(text).toContain("echo hi");
255
- expect(text).toContain("[shell <-]");
390
+ expect(text).toContain("shell <-");
256
391
  expect(text).toContain("partial output");
257
392
  expect(text).not.toContain("Exit code: 0");
258
393
  });
@@ -301,7 +436,7 @@ describe("ui/conversation", () => {
301
436
  });
302
437
 
303
438
  // Assert
304
- expect(text).toContain("[edit <-]");
439
+ expect(text).toContain("edit <-");
305
440
  expect(text).toContain("~ src/app.ts");
306
441
  expect(text).not.toContain("before");
307
442
  expect(text).not.toContain("after");
@@ -513,7 +648,7 @@ describe("ui/conversation", () => {
513
648
  expect(text).toContain("Thinking... 1 line.");
514
649
  });
515
650
 
516
- test("renderAssistantMessage for a shell tool call shows the command under the new header pill", () => {
651
+ test("renderAssistantMessage for a shell tool call renders an unbracketed header inside the pill", () => {
517
652
  // Arrange
518
653
  const assistant = {
519
654
  content: [
@@ -522,12 +657,47 @@ describe("ui/conversation", () => {
522
657
  };
523
658
 
524
659
  // Act
525
- const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
660
+ const node = renderAssistantMessage(assistant, RENDER_OPTS);
661
+ const text = collectText(node);
526
662
 
527
663
  // Assert
528
- expect(text).toContain("[shell ->]");
664
+ expect(text).toContain("shell ->");
665
+ expect(text).not.toContain("[shell ->]");
529
666
  expect(text).toContain("echo hi");
530
667
  expect(text).not.toContain('"command": "echo hi"');
668
+
669
+ expect(node?.type).toBe("vstack");
670
+ if (!node || node.type !== "vstack") {
671
+ throw new Error("Expected the assistant node to be a vstack");
672
+ }
673
+
674
+ const toolBlock = node.children[0];
675
+ expect(toolBlock?.type).toBe("hstack");
676
+ if (!toolBlock || toolBlock.type !== "hstack") {
677
+ throw new Error("Expected the tool block to be an hstack");
678
+ }
679
+
680
+ const contentColumn = toolBlock.children[1];
681
+ expect(contentColumn?.type).toBe("vstack");
682
+ if (!contentColumn || contentColumn.type !== "vstack") {
683
+ throw new Error("Expected the tool content column to be a vstack");
684
+ }
685
+
686
+ const headerRow = contentColumn.children[0];
687
+ expect(headerRow?.type).toBe("hstack");
688
+ if (!headerRow || headerRow.type !== "hstack") {
689
+ throw new Error("Expected the tool header row to be an hstack");
690
+ }
691
+
692
+ const headerPill = headerRow.children[0];
693
+ expect(headerPill?.type).toBe("hstack");
694
+ if (!headerPill || headerPill.type !== "hstack") {
695
+ throw new Error("Expected the tool header pill to be an hstack");
696
+ }
697
+
698
+ expect(headerPill.props.bgColor).toBe(DEFAULT_THEME.toolBorder);
699
+ expect(headerPill.props.padding).toEqual({ x: 1 });
700
+ expect(collectText(headerPill)).toEqual(["shell ->"]);
531
701
  });
532
702
 
533
703
  test("renderAssistantMessage for a shell tool call syntax-highlights bash tokens", async () => {
@@ -795,7 +965,7 @@ describe("ui/conversation", () => {
795
965
  const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
796
966
 
797
967
  // Assert
798
- expect(text).toContain("[read image ->]");
968
+ expect(text).toContain("read image ->");
799
969
  expect(text).toContain("assets/preview.png");
800
970
  expect(text).not.toContain("{");
801
971
  });
@@ -820,7 +990,7 @@ describe("ui/conversation", () => {
820
990
  const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
821
991
 
822
992
  // Assert
823
- expect(text).toContain("[edit ->]");
993
+ expect(text).toContain("edit ->");
824
994
  expect(text).toContain("src/file.ts");
825
995
  expect(text).toContain("old line");
826
996
  expect(text).toContain("new line");
@@ -883,7 +1053,7 @@ describe("ui/conversation", () => {
883
1053
  );
884
1054
 
885
1055
  // Assert
886
- expect(text).toContain("[shell <-]");
1056
+ expect(text).toContain("shell <-");
887
1057
  expect(text).toContain("line 18");
888
1058
  expect(text).toContain("line 25");
889
1059
  expect(text).toContain("And 17 lines more");
@@ -891,6 +1061,37 @@ describe("ui/conversation", () => {
891
1061
  expect(text).not.toContain("seq 1 25");
892
1062
  });
893
1063
 
1064
+ test("renderToolResult for a wrapped shell preview keeps the summary directly below the visible tail", async () => {
1065
+ // Arrange
1066
+ const output = [
1067
+ "line 1",
1068
+ "line 2",
1069
+ "line 3",
1070
+ "line 4",
1071
+ "this is a very long wrapped line that will take more than six rendered rows in the preview width so it gets dropped entirely",
1072
+ "tail A",
1073
+ "tail B",
1074
+ ].join("\n");
1075
+
1076
+ // Act
1077
+ const rows = await renderBufferRows(
1078
+ renderToolResult("shell", { command: "demo" }, output, false, {
1079
+ ...RENDER_OPTS,
1080
+ previewWidth: 24,
1081
+ }),
1082
+ 24,
1083
+ 20,
1084
+ );
1085
+ const tailRowIndex = rows.findIndex((row) => row.text.includes("tail B"));
1086
+ const summaryRowIndex = rows.findIndex(
1087
+ (row) => row.text.includes("And ") && row.text.includes(" lines more"),
1088
+ );
1089
+
1090
+ // Assert
1091
+ expect(tailRowIndex).toBeGreaterThan(-1);
1092
+ expect(summaryRowIndex).toBe(tailRowIndex + 1);
1093
+ });
1094
+
894
1095
  test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
895
1096
  // Arrange
896
1097
  const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
@@ -927,7 +1128,7 @@ describe("ui/conversation", () => {
927
1128
  );
928
1129
 
929
1130
  // Assert
930
- expect(text).toContain("[shell <-]");
1131
+ expect(text).toContain("shell <-");
931
1132
  expect(text).toContain("exit 42");
932
1133
  expect(text).toContain("boom");
933
1134
  expect(text).not.toContain("Exit code: 42");
@@ -944,7 +1145,7 @@ describe("ui/conversation", () => {
944
1145
  );
945
1146
 
946
1147
  // Assert
947
- expect(text).toContain("[read image <-]");
1148
+ expect(text).toContain("read image <-");
948
1149
  expect(text).toContain("diagram.png");
949
1150
  expect(text).not.toContain("Read image.");
950
1151
  });
@@ -993,7 +1194,7 @@ describe("ui/conversation", () => {
993
1194
  );
994
1195
 
995
1196
  // Assert
996
- expect(previewText).toContain("[edit <-]");
1197
+ expect(previewText).toContain("edit <-");
997
1198
  expect(previewText).toContain("~ src/file.ts");
998
1199
  expect(previewText).not.toContain("before");
999
1200
  expect(previewText).not.toContain("after");
@@ -1026,7 +1227,7 @@ describe("ui/conversation", () => {
1026
1227
  );
1027
1228
 
1028
1229
  // Assert
1029
- expect(text).toContain("[edit <-]");
1230
+ expect(text).toContain("edit <-");
1030
1231
  expect(text).toContain("error 18");
1031
1232
  expect(text).toContain("error 25");
1032
1233
  expect(text).toContain("And 17 lines more");
@@ -1049,7 +1250,7 @@ describe("ui/conversation", () => {
1049
1250
  );
1050
1251
 
1051
1252
  // Assert
1052
- expect(text).toContain("[mcp/search <-]");
1253
+ expect(text).toContain("mcp/search <-");
1053
1254
  expect(text).toContain("session persistence sqlite turn numbering");
1054
1255
  expect(text).not.toContain('"query"');
1055
1256
  });
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import {
12
- Markdown,
13
12
  SyntaxHighlight,
14
13
  type SyntaxHighlightTheme,
15
14
  } from "@cel-tui/components";
@@ -142,11 +141,15 @@ type ToolRenderLineKind =
142
141
  | "summary"
143
142
  | "error";
144
143
 
145
- interface HighlightedToolBodySpec {
144
+ type SyntaxThemeVariant = "markdown" | "shell";
145
+
146
+ interface HighlightedBodySpec {
146
147
  /** Full raw source content to syntax-highlight. */
147
148
  text: string;
148
149
  /** Registered lextide language id. */
149
150
  language: string;
151
+ /** Semantic theme variant for this highlighted content. */
152
+ themeVariant: SyntaxThemeVariant;
150
153
  }
151
154
 
152
155
  interface ToolBlockSpec {
@@ -157,7 +160,7 @@ interface ToolBlockSpec {
157
160
  /** Logical body lines for the tool block. */
158
161
  bodyLines: readonly ToolRenderLine[];
159
162
  /** Optional syntax-highlighted body rendered from the full unsplit source. */
160
- highlightedBody?: HighlightedToolBodySpec;
163
+ highlightedBody?: HighlightedBodySpec;
161
164
  /** Whether `/verbose` preview rules apply to the body. */
162
165
  previewBody: boolean;
163
166
  }
@@ -222,16 +225,25 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
222
225
  ]);
223
226
  }
224
227
 
225
- /** Render a markdown block with stable internal paragraph spacing. */
226
- function renderMarkdownBlock(content: string): Node | null {
227
- const children = Markdown(content).map((node) => {
228
- if (node.type === "text" && node.content === "") {
229
- return node;
230
- }
231
- return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [node])]);
232
- });
228
+ /** Render a syntax-highlighted raw markdown block. */
229
+ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
230
+ if (content === "") {
231
+ return null;
232
+ }
233
233
 
234
- return children.length > 0 ? VStack({ gap: 0 }, children) : null;
234
+ const children = getHighlightedBodyLines(
235
+ {
236
+ text: content,
237
+ language: "markdown",
238
+ themeVariant: "markdown",
239
+ },
240
+ theme,
241
+ );
242
+ if (children.length === 0) {
243
+ return null;
244
+ }
245
+
246
+ return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, children)]);
235
247
  }
236
248
 
237
249
  function getAssistantErrorMessage(
@@ -270,12 +282,38 @@ function renderThinkingBlock(
270
282
  ]);
271
283
  }
272
284
 
285
+ function coalesceAdjacentTextBlocks(
286
+ content: AssistantMessage["content"],
287
+ ): AssistantMessage["content"] {
288
+ const coalesced: AssistantMessage["content"] = [];
289
+
290
+ for (const block of content) {
291
+ if (block.type !== "text") {
292
+ coalesced.push(block);
293
+ continue;
294
+ }
295
+
296
+ const previous = coalesced.at(-1);
297
+ if (previous?.type === "text") {
298
+ coalesced[coalesced.length - 1] = {
299
+ ...previous,
300
+ text: previous.text + block.text,
301
+ };
302
+ continue;
303
+ }
304
+
305
+ coalesced.push({ ...block });
306
+ }
307
+
308
+ return coalesced;
309
+ }
310
+
273
311
  function renderAssistantContentBlock(
274
312
  block: AssistantMessage["content"][number],
275
313
  opts: ConversationRenderOpts,
276
314
  ): Node | null {
277
315
  if (block.type === "text" && block.text) {
278
- return renderMarkdownBlock(block.text);
316
+ return renderMarkdownTextBlock(block.text, opts.theme);
279
317
  }
280
318
  if (block.type === "thinking" && block.thinking) {
281
319
  return renderThinkingBlock(block.thinking, opts);
@@ -297,7 +335,7 @@ export function renderAssistantMessage(
297
335
  assistant: AssistantMessage | AssistantRenderState,
298
336
  opts: ConversationRenderOpts,
299
337
  ): Node | null {
300
- const children = assistant.content
338
+ const children = coalesceAdjacentTextBlocks(assistant.content)
301
339
  .map((block) => {
302
340
  return renderAssistantContentBlock(block, opts);
303
341
  })
@@ -388,7 +426,13 @@ type SyntaxThemeTokenColor = NonNullable<
388
426
  const graphemeSegmenter = new Intl.Segmenter(undefined, {
389
427
  granularity: "grapheme",
390
428
  });
391
- const shellSyntaxThemeCache = new WeakMap<Theme, SyntaxThemeRegistration>();
429
+ const syntaxThemeCache: Record<
430
+ SyntaxThemeVariant,
431
+ WeakMap<Theme, SyntaxThemeRegistration>
432
+ > = {
433
+ markdown: new WeakMap(),
434
+ shell: new WeakMap(),
435
+ };
392
436
 
393
437
  function colorToHex(color: Color | undefined): string | undefined {
394
438
  return color ? ANSI_COLOR_HEX[color] : undefined;
@@ -414,14 +458,11 @@ function pushSyntaxTokenColor(
414
458
  });
415
459
  }
416
460
 
417
- function getShellSyntaxTheme(theme: Theme): SyntaxThemeRegistration {
418
- const cached = shellSyntaxThemeCache.get(theme);
419
- if (cached) {
420
- return cached;
421
- }
422
-
423
- const tokenColors: SyntaxThemeTokenColor[] = [];
424
- pushSyntaxTokenColor(tokenColors, ["comment", "quote"], theme.mutedText);
461
+ function pushShellSyntaxTokenColors(
462
+ tokenColors: SyntaxThemeTokenColor[],
463
+ theme: Theme,
464
+ ): void {
465
+ pushSyntaxTokenColor(tokenColors, "comment", theme.mutedText);
425
466
  pushSyntaxTokenColor(
426
467
  tokenColors,
427
468
  ["keyword", "storage"],
@@ -472,12 +513,47 @@ function getShellSyntaxTheme(theme: Theme): SyntaxThemeRegistration {
472
513
  ["entity.name.tag", "name", "tag"],
473
514
  theme.accentText,
474
515
  );
516
+ }
517
+
518
+ function pushMarkdownSyntaxTokenColors(
519
+ tokenColors: SyntaxThemeTokenColor[],
520
+ theme: Theme,
521
+ ): void {
522
+ pushSyntaxTokenColor(tokenColors, "quote", theme.mutedText, "italic");
523
+ pushSyntaxTokenColor(tokenColors, "section", theme.accentText, "bold");
524
+ pushSyntaxTokenColor(
525
+ tokenColors,
526
+ "bullet",
527
+ theme.secondaryAccentText,
528
+ "bold",
529
+ );
530
+ pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
531
+ pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
532
+ pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
533
+ pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
534
+ }
535
+
536
+ function getSyntaxTheme(
537
+ theme: Theme,
538
+ variant: SyntaxThemeVariant,
539
+ ): SyntaxThemeRegistration {
540
+ const cache = syntaxThemeCache[variant];
541
+ const cached = cache.get(theme);
542
+ if (cached) {
543
+ return cached;
544
+ }
545
+
546
+ const tokenColors: SyntaxThemeTokenColor[] = [];
547
+ if (variant === "shell") {
548
+ pushShellSyntaxTokenColors(tokenColors, theme);
549
+ } else {
550
+ pushMarkdownSyntaxTokenColors(tokenColors, theme);
551
+ }
475
552
 
476
553
  const syntaxTheme: SyntaxThemeRegistration = {
477
- ...(colorToHex(theme.toolText) ? { fg: colorToHex(theme.toolText) } : {}),
478
554
  tokenColors,
479
555
  };
480
- shellSyntaxThemeCache.set(theme, syntaxTheme);
556
+ cache.set(theme, syntaxTheme);
481
557
  return syntaxTheme;
482
558
  }
483
559
 
@@ -508,7 +584,7 @@ function splitHighlightedTextNode(
508
584
  return children;
509
585
  }
510
586
 
511
- function normalizeHighlightedToolLine(line: Node): Node {
587
+ function normalizeHighlightedLine(line: Node): Node {
512
588
  if (line.type !== "hstack") {
513
589
  return line;
514
590
  }
@@ -519,8 +595,8 @@ function normalizeHighlightedToolLine(line: Node): Node {
519
595
  return HStack(line.props, children);
520
596
  }
521
597
 
522
- function getHighlightedToolBodyLines(
523
- spec: HighlightedToolBodySpec,
598
+ function getHighlightedBodyLines(
599
+ spec: HighlightedBodySpec,
524
600
  theme: Theme,
525
601
  ): Node[] {
526
602
  if (spec.text === "") {
@@ -528,9 +604,9 @@ function getHighlightedToolBodyLines(
528
604
  }
529
605
 
530
606
  const highlighted = SyntaxHighlight(spec.text, spec.language, {
531
- theme: getShellSyntaxTheme(theme),
607
+ theme: getSyntaxTheme(theme, spec.themeVariant),
532
608
  });
533
- return highlighted.children.map((line) => normalizeHighlightedToolLine(line));
609
+ return highlighted.children.map((line) => normalizeHighlightedLine(line));
534
610
  }
535
611
 
536
612
  /** Render a single styled text node for a tool line. */
@@ -606,7 +682,7 @@ function renderToolHeaderPill(
606
682
  theme: Theme,
607
683
  ): Node {
608
684
  return HStack({ bgColor: theme.toolBorder, padding: { x: 1 } }, [
609
- Text(`[${getToolHeaderName(toolName)} ${direction}]`, {
685
+ Text(`${getToolHeaderName(toolName)} ${direction}`, {
610
686
  fgColor: getToolHeaderColor(toolName, theme),
611
687
  bold: true,
612
688
  }),
@@ -644,6 +720,7 @@ function renderToolBodyFromNodes(
644
720
  body: VStack(
645
721
  {
646
722
  height: UI_TOOL_PREVIEW_ROWS,
723
+ justifyContent: "end",
647
724
  },
648
725
  [...previewLines],
649
726
  ),
@@ -663,7 +740,7 @@ function renderToolBody(
663
740
  ): { body: Node | null; summary?: ToolRenderLine } {
664
741
  if (spec.highlightedBody) {
665
742
  return renderToolBodyFromNodes(
666
- getHighlightedToolBodyLines(spec.highlightedBody, opts.theme),
743
+ getHighlightedBodyLines(spec.highlightedBody, opts.theme),
667
744
  spec.previewBody,
668
745
  opts,
669
746
  );
@@ -720,6 +797,7 @@ function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
720
797
  : {
721
798
  text: command,
722
799
  language: "bash",
800
+ themeVariant: "shell",
723
801
  },
724
802
  previewBody: true,
725
803
  };
@@ -24,7 +24,7 @@ describe("ui/help", () => {
24
24
  );
25
25
  });
26
26
 
27
- test("buildHelpText includes the current Escape input-focus note", () => {
27
+ test("buildHelpText describes the current Escape behavior", () => {
28
28
  const helpState: HelpRenderState = {
29
29
  providers: new Map(),
30
30
  model: null,
@@ -37,8 +37,13 @@ describe("ui/help", () => {
37
37
 
38
38
  const text = buildHelpText(helpState);
39
39
 
40
- expect(text).toContain("Escape blurs the input first");
41
- expect(text).toContain("Tab re-focuses the input");
42
- expect(text).toContain("Escape again interrupts the current turn");
40
+ expect(text).toContain(
41
+ "Escape closes the current overlay and returns focus to the input",
42
+ );
43
+ expect(text).toContain(
44
+ "With no overlay open, Escape interrupts the current turn",
45
+ );
46
+ expect(text).toContain("Otherwise Escape does nothing");
47
+ expect(text).not.toContain("Escape blurs the input first");
43
48
  });
44
49
  });