mini-coder 0.5.5 → 0.5.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -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
  );
@@ -0,0 +1,443 @@
1
+ import { afterEach, describe, test } from "bun:test";
2
+ import { cel, MockTerminal } from "@cel-tui/core";
3
+ import type { Node } from "@cel-tui/types";
4
+ import {
5
+ fauxAssistantMessage,
6
+ fauxThinking,
7
+ fauxToolCall,
8
+ } from "@mariozechner/pi-ai";
9
+ import type { AppState } from "../index.ts";
10
+ import {
11
+ computeContextTokens,
12
+ computeStats,
13
+ createUiMessage,
14
+ openDatabase,
15
+ } from "../session.ts";
16
+ import { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "../settings.ts";
17
+ import { DEFAULT_THEME } from "../theme.ts";
18
+ import {
19
+ buildConversationLogNodes,
20
+ resetConversationRenderCache,
21
+ } from "../ui/conversation.ts";
22
+ import {
23
+ createInputController,
24
+ renderActiveOverlay,
25
+ renderBaseLayout,
26
+ resetUiState,
27
+ } from "../ui.ts";
28
+
29
+ const VIEWPORT_COLS = 120;
30
+ const VIEWPORT_ROWS = 40;
31
+ const TYPING_SAMPLE_COUNT = 9;
32
+ const LARGE_MARKDOWN_SECTION_COUNT = 8;
33
+ const NODE_BUDGET = 6_000;
34
+ const TYPING_MEDIAN_BUDGET_MS = 40;
35
+
36
+ function flushCelRender(): Promise<void> {
37
+ return new Promise((resolve) => {
38
+ process.nextTick(resolve);
39
+ });
40
+ }
41
+
42
+ function startUiViewport(
43
+ state: AppState,
44
+ terminal: MockTerminal,
45
+ controller: ReturnType<typeof createInputController>,
46
+ ): void {
47
+ cel.init(terminal);
48
+ cel.viewport(() => {
49
+ const base = renderBaseLayout(state, terminal.columns, controller);
50
+ const overlay = renderActiveOverlay(state);
51
+ return overlay ? [base, overlay] : base;
52
+ });
53
+ }
54
+
55
+ function createTestState(): AppState {
56
+ const cwd = "/tmp/mini-coder-ui-render-perf-test";
57
+ const model: NonNullable<AppState["model"]> = {
58
+ id: "gpt-5.4",
59
+ name: "gpt-5.4",
60
+ provider: "openai-codex",
61
+ api: "responses",
62
+ baseUrl: "http://localhost:0",
63
+ reasoning: true,
64
+ input: ["text"],
65
+ cost: {
66
+ input: 0,
67
+ output: 0,
68
+ cacheRead: 0,
69
+ cacheWrite: 0,
70
+ },
71
+ contextWindow: 272_000,
72
+ maxTokens: 8_192,
73
+ };
74
+
75
+ return {
76
+ db: openDatabase(":memory:"),
77
+ session: null,
78
+ model,
79
+ effort: "medium",
80
+ messages: [],
81
+ stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
82
+ contextTokens: 0,
83
+ agentsMd: [],
84
+ skills: [],
85
+ plugins: [],
86
+ theme: DEFAULT_THEME,
87
+ git: null,
88
+ providers: new Map(),
89
+ oauthCredentials: {},
90
+ settings: {},
91
+ settingsPath: `${cwd}/settings.json`,
92
+ cwd,
93
+ canonicalCwd: cwd,
94
+ running: false,
95
+ abortController: null,
96
+ activeTurnPromise: null,
97
+ showReasoning: DEFAULT_SHOW_REASONING,
98
+ verbose: DEFAULT_VERBOSE,
99
+ versionLabel: "dev",
100
+ customModels: [],
101
+ startupWarnings: [],
102
+ };
103
+ }
104
+
105
+ function setMessages(state: AppState, messages: AppState["messages"]): void {
106
+ state.messages = messages;
107
+ state.stats = computeStats(messages);
108
+ state.contextTokens = computeContextTokens(messages);
109
+ }
110
+
111
+ function buildLargeMarkdown(seed: number): string {
112
+ return Array.from({ length: LARGE_MARKDOWN_SECTION_COUNT }, (_, index) => {
113
+ const section = seed * 100 + index;
114
+ return [
115
+ `# Section ${section}`,
116
+ "",
117
+ `Paragraph ${section}: ${"lorem ipsum dolor sit amet ".repeat(10)}`,
118
+ "",
119
+ `- item ${section}a with [link](https://example.com/${section})`,
120
+ `- item ${section}b with **bold** text and \`inline_code_${section}\``,
121
+ "",
122
+ `> Quote ${section}: ${"wrapped quoted text ".repeat(10)}`,
123
+ "",
124
+ "```ts",
125
+ `const value${section} = ${section};`,
126
+ `console.log(value${section});`,
127
+ "```",
128
+ ].join("\n");
129
+ }).join("\n\n");
130
+ }
131
+
132
+ function createLargeMarkdownMessages(): AppState["messages"] {
133
+ return [
134
+ {
135
+ role: "user",
136
+ content: "Investigate the rendering lag in this session.",
137
+ timestamp: 1,
138
+ },
139
+ fauxAssistantMessage(buildLargeMarkdown(1), { timestamp: 2 }),
140
+ {
141
+ role: "user",
142
+ content: "Keep going with the detailed write-up.",
143
+ timestamp: 3,
144
+ },
145
+ fauxAssistantMessage(buildLargeMarkdown(2), { timestamp: 4 }),
146
+ {
147
+ role: "user",
148
+ content: "Add one more large markdown response for history.",
149
+ timestamp: 5,
150
+ },
151
+ fauxAssistantMessage(buildLargeMarkdown(3), { timestamp: 6 }),
152
+ ];
153
+ }
154
+
155
+ function countNodes(node: Node): number {
156
+ if (node.type === "text" || node.type === "textinput") {
157
+ return 1;
158
+ }
159
+
160
+ return (
161
+ 1 + node.children.reduce((total, child) => total + countNodes(child), 0)
162
+ );
163
+ }
164
+
165
+ function getConversationNode(
166
+ messages: AppState["messages"],
167
+ index: number,
168
+ ): Node {
169
+ resetConversationRenderCache();
170
+
171
+ const nodes = buildConversationLogNodes(
172
+ {
173
+ messages,
174
+ showReasoning: true,
175
+ verbose: false,
176
+ theme: DEFAULT_THEME,
177
+ versionLabel: "dev",
178
+ },
179
+ {
180
+ isStreaming: false,
181
+ content: [],
182
+ pendingToolResults: [],
183
+ },
184
+ 0,
185
+ 80,
186
+ );
187
+ const node = nodes[index];
188
+
189
+ if (!node) {
190
+ throw new Error(`Expected a rendered node at index ${index}`);
191
+ }
192
+
193
+ return node;
194
+ }
195
+
196
+ function expectNodeCountAtMost(
197
+ label: string,
198
+ node: Node,
199
+ maxNodes: number,
200
+ ): void {
201
+ const nodeCount = countNodes(node);
202
+
203
+ if (nodeCount > maxNodes) {
204
+ throw new Error(
205
+ `Expected ${label} node count <= ${maxNodes}, got ${nodeCount}`,
206
+ );
207
+ }
208
+ }
209
+
210
+ function median(values: readonly number[]): number {
211
+ const sorted = [...values].sort((a, b) => a - b);
212
+ const middle = Math.floor(sorted.length / 2);
213
+
214
+ if (sorted.length % 2 === 0) {
215
+ return (sorted[middle - 1]! + sorted[middle]!) / 2;
216
+ }
217
+
218
+ return sorted[middle]!;
219
+ }
220
+
221
+ async function measureTypingMedianRerenderMs(state: AppState): Promise<number> {
222
+ const terminal = new MockTerminal(VIEWPORT_COLS, VIEWPORT_ROWS);
223
+ const controller = createInputController(state);
224
+ const samples: number[] = [];
225
+
226
+ startUiViewport(state, terminal, controller);
227
+ await flushCelRender();
228
+
229
+ try {
230
+ for (let index = 0; index < TYPING_SAMPLE_COUNT; index++) {
231
+ const nextValue = index % 2 === 0 ? "a" : "ab";
232
+ const start = performance.now();
233
+ controller.onChange(nextValue);
234
+ await flushCelRender();
235
+ samples.push(performance.now() - start);
236
+ }
237
+ } finally {
238
+ cel.stop();
239
+ }
240
+
241
+ return median(samples);
242
+ }
243
+
244
+ afterEach(() => {
245
+ resetUiState();
246
+ cel.stop();
247
+ });
248
+
249
+ describe("ui render performance", () => {
250
+ test("renderBaseLayout with large historical assistant markdown stays within the node budget", () => {
251
+ // Arrange
252
+ const state = createTestState();
253
+ setMessages(state, createLargeMarkdownMessages());
254
+ const controller = createInputController(state);
255
+
256
+ try {
257
+ // Act
258
+ const base = renderBaseLayout(state, VIEWPORT_COLS, controller);
259
+ const nodeCount = countNodes(base);
260
+
261
+ // Assert
262
+ if (nodeCount > NODE_BUDGET) {
263
+ throw new Error(
264
+ `Expected large-markdown layout node count <= ${NODE_BUDGET}, got ${nodeCount}`,
265
+ );
266
+ }
267
+ } finally {
268
+ state.db.close();
269
+ }
270
+ });
271
+
272
+ test("non-markdown conversation log items stay within bounded node budgets", () => {
273
+ // Arrange
274
+ const userNode = getConversationNode(
275
+ [
276
+ {
277
+ role: "user",
278
+ content: "lorem ipsum dolor sit amet ".repeat(400),
279
+ timestamp: 1,
280
+ },
281
+ ],
282
+ 0,
283
+ );
284
+ const uiNode = getConversationNode(
285
+ [createUiMessage("status update ".repeat(400))],
286
+ 0,
287
+ );
288
+ const thinkingNode = getConversationNode(
289
+ [fauxAssistantMessage([fauxThinking("plan\n".repeat(400))])],
290
+ 0,
291
+ );
292
+ const assistantToolCallsNode = getConversationNode(
293
+ [
294
+ fauxAssistantMessage([
295
+ fauxThinking("brief plan"),
296
+ fauxToolCall(
297
+ "shell",
298
+ { command: "printf 'foo\\nbar'" },
299
+ { id: "tool-1" },
300
+ ),
301
+ fauxToolCall(
302
+ "edit",
303
+ {
304
+ path: "src/app.ts",
305
+ oldText: "before",
306
+ newText: "after",
307
+ },
308
+ { id: "tool-2" },
309
+ ),
310
+ fauxToolCall("readImage", { path: "diagram.png" }, { id: "tool-3" }),
311
+ ]),
312
+ ],
313
+ 0,
314
+ );
315
+ const longShellToolCallNode = getConversationNode(
316
+ [
317
+ fauxAssistantMessage([
318
+ fauxToolCall(
319
+ "shell",
320
+ { command: `printf ${"x".repeat(300)}TAIL` },
321
+ { id: "tool-4" },
322
+ ),
323
+ ]),
324
+ ],
325
+ 0,
326
+ );
327
+ const shellResultNode = getConversationNode(
328
+ [
329
+ fauxAssistantMessage([
330
+ fauxToolCall("shell", { command: "seq 1 200" }, { id: "tool-1" }),
331
+ ]),
332
+ {
333
+ role: "toolResult",
334
+ toolCallId: "tool-1",
335
+ toolName: "shell",
336
+ content: [
337
+ {
338
+ type: "text",
339
+ text: Array.from(
340
+ { length: 200 },
341
+ (_, index) => `line ${index + 1}`,
342
+ ).join("\n"),
343
+ },
344
+ ],
345
+ isError: false,
346
+ timestamp: 2,
347
+ },
348
+ ],
349
+ 1,
350
+ );
351
+ const editErrorNode = getConversationNode(
352
+ [
353
+ fauxAssistantMessage([
354
+ fauxToolCall(
355
+ "edit",
356
+ {
357
+ path: "src/app.ts",
358
+ oldText: "before",
359
+ newText: "after",
360
+ },
361
+ { id: "tool-2" },
362
+ ),
363
+ ]),
364
+ {
365
+ role: "toolResult",
366
+ toolCallId: "tool-2",
367
+ toolName: "edit",
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: Array.from(
372
+ { length: 120 },
373
+ (_, index) => `line ${index + 1}`,
374
+ ).join("\n"),
375
+ },
376
+ ],
377
+ isError: true,
378
+ timestamp: 3,
379
+ },
380
+ ],
381
+ 1,
382
+ );
383
+ const genericResultNode = getConversationNode(
384
+ [
385
+ {
386
+ role: "toolResult",
387
+ toolCallId: "tool-3",
388
+ toolName: "pluginSearch",
389
+ content: [
390
+ {
391
+ type: "text",
392
+ text: Array.from(
393
+ { length: 200 },
394
+ (_, index) => `row ${index + 1}`,
395
+ ).join("\n"),
396
+ },
397
+ ],
398
+ isError: false,
399
+ timestamp: 4,
400
+ },
401
+ ],
402
+ 0,
403
+ );
404
+
405
+ // Assert
406
+ expectNodeCountAtMost("long user message", userNode, 4);
407
+ expectNodeCountAtMost("long UI message", uiNode, 4);
408
+ expectNodeCountAtMost("thinking-only assistant message", thinkingNode, 4);
409
+ expectNodeCountAtMost(
410
+ "assistant tool-call bundle",
411
+ assistantToolCallsNode,
412
+ 64,
413
+ );
414
+ expectNodeCountAtMost(
415
+ "long single-token shell tool call",
416
+ longShellToolCallNode,
417
+ 64,
418
+ );
419
+ expectNodeCountAtMost("shell tool-result preview", shellResultNode, 24);
420
+ expectNodeCountAtMost("edit error preview", editErrorNode, 24);
421
+ expectNodeCountAtMost("generic tool result", genericResultNode, 240);
422
+ });
423
+
424
+ test("typing into the input with large historical assistant markdown stays within the rerender budget", async () => {
425
+ // Arrange
426
+ const state = createTestState();
427
+ setMessages(state, createLargeMarkdownMessages());
428
+
429
+ try {
430
+ // Act
431
+ const medianRerenderMs = await measureTypingMedianRerenderMs(state);
432
+
433
+ // Assert
434
+ if (medianRerenderMs > TYPING_MEDIAN_BUDGET_MS) {
435
+ throw new Error(
436
+ `Expected typing median rerender <= ${TYPING_MEDIAN_BUDGET_MS}ms, got ${medianRerenderMs.toFixed(1)}ms`,
437
+ );
438
+ }
439
+ } finally {
440
+ state.db.close();
441
+ }
442
+ });
443
+ });