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
|
@@ -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`;
|
package/src/ui/conversation.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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"
|
|
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) =>
|
|
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(
|
|
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
|
+
});
|