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/bun.lock +13 -13
- package/package.json +3 -3
- package/src/agent.ts +141 -29
- package/src/index.ts +27 -2
- package/src/prompt.ts +84 -78
- package/src/submit.ts +53 -6
- package/src/tools.ts +160 -42
- package/src/ui/agent.ts +16 -2
- package/src/ui/commands.test.ts +1 -0
- package/src/ui/conversation.test.ts +87 -0
- package/src/ui/conversation.ts +51 -7
- package/src/ui/input.test.ts +29 -1
- package/src/ui/input.ts +45 -51
- package/src/ui/render-performance.test.ts +444 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +50 -10
package/src/ui/input.ts
CHANGED
|
@@ -25,39 +25,18 @@ export interface InputController {
|
|
|
25
25
|
onKeyPress: (key: string) => boolean | undefined;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
let prefix = values[0]!;
|
|
35
|
-
for (let i = 1; i < values.length && prefix.length > 0; i++) {
|
|
36
|
-
const value = values[i]!;
|
|
37
|
-
let j = 0;
|
|
38
|
-
while (j < prefix.length && j < value.length && prefix[j] === value[j]) {
|
|
39
|
-
j++;
|
|
40
|
-
}
|
|
41
|
-
prefix = prefix.slice(0, j);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return prefix;
|
|
28
|
+
/** A selectable path-autocomplete match for the current input draft. */
|
|
29
|
+
export interface InputPathMatch {
|
|
30
|
+
/** Path label shown in the overlay. */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Full draft value to apply when this match is selected. */
|
|
33
|
+
value: string;
|
|
45
34
|
}
|
|
46
35
|
|
|
47
|
-
|
|
48
|
-
* Attempt to autocomplete the final path token in the current draft.
|
|
49
|
-
*
|
|
50
|
-
* @param value - Current input draft.
|
|
51
|
-
* @param cwd - Working directory used to resolve relative paths.
|
|
52
|
-
* @returns The completed input value, or `null` when no completion is available.
|
|
53
|
-
*/
|
|
54
|
-
export function autocompleteInputPath(
|
|
55
|
-
value: string,
|
|
56
|
-
cwd: string,
|
|
57
|
-
): string | null {
|
|
36
|
+
function listInputPathMatches(value: string, cwd: string): InputPathMatch[] {
|
|
58
37
|
const tokenMatch = /(^|\s)(\S+)$/.exec(value);
|
|
59
38
|
if (!tokenMatch?.[2]) {
|
|
60
|
-
return
|
|
39
|
+
return [];
|
|
61
40
|
}
|
|
62
41
|
|
|
63
42
|
const token = tokenMatch[2];
|
|
@@ -80,35 +59,50 @@ export function autocompleteInputPath(
|
|
|
80
59
|
}
|
|
81
60
|
})();
|
|
82
61
|
if (!entries) {
|
|
83
|
-
return
|
|
62
|
+
return [];
|
|
84
63
|
}
|
|
85
64
|
|
|
86
65
|
const showHidden = partial.startsWith(".");
|
|
87
|
-
|
|
66
|
+
return entries
|
|
88
67
|
.filter((entry) => (showHidden ? true : !entry.name.startsWith(".")))
|
|
89
68
|
.filter((entry) => entry.name.startsWith(partial))
|
|
90
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
completedName = `${match.name}${match.isDirectory() ? "/" : ""}`;
|
|
100
|
-
} else {
|
|
101
|
-
const prefix = getLongestCommonPrefix(matches.map((entry) => entry.name));
|
|
102
|
-
if (prefix.length > partial.length) {
|
|
103
|
-
completedName = prefix;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
69
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
70
|
+
.map((entry) => {
|
|
71
|
+
const completedPath = `${dirToken}${entry.name}${entry.isDirectory() ? "/" : ""}`;
|
|
72
|
+
return {
|
|
73
|
+
label: completedPath,
|
|
74
|
+
value: `${value.slice(0, tokenStart)}${completedPath}`,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
106
78
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Find selectable path matches for the final path token in the current draft.
|
|
81
|
+
*
|
|
82
|
+
* @param value - Current input draft.
|
|
83
|
+
* @param cwd - Working directory used to resolve relative paths.
|
|
84
|
+
* @returns Matching path completions with their applied draft values.
|
|
85
|
+
*/
|
|
86
|
+
export function findInputPathMatches(
|
|
87
|
+
value: string,
|
|
88
|
+
cwd: string,
|
|
89
|
+
): InputPathMatch[] {
|
|
90
|
+
return listInputPathMatches(value, cwd);
|
|
91
|
+
}
|
|
110
92
|
|
|
111
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Attempt to autocomplete the final path token in the current draft.
|
|
95
|
+
*
|
|
96
|
+
* @param value - Current input draft.
|
|
97
|
+
* @param cwd - Working directory used to resolve relative paths.
|
|
98
|
+
* @returns The completed input value, or `null` when direct completion is unavailable.
|
|
99
|
+
*/
|
|
100
|
+
export function autocompleteInputPath(
|
|
101
|
+
value: string,
|
|
102
|
+
cwd: string,
|
|
103
|
+
): string | null {
|
|
104
|
+
const matches = listInputPathMatches(value, cwd);
|
|
105
|
+
return matches.length === 1 ? matches[0]!.value : null;
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
/**
|
|
@@ -0,0 +1,444 @@
|
|
|
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
|
+
queuedUserMessages: [],
|
|
98
|
+
showReasoning: DEFAULT_SHOW_REASONING,
|
|
99
|
+
verbose: DEFAULT_VERBOSE,
|
|
100
|
+
versionLabel: "dev",
|
|
101
|
+
customModels: [],
|
|
102
|
+
startupWarnings: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setMessages(state: AppState, messages: AppState["messages"]): void {
|
|
107
|
+
state.messages = messages;
|
|
108
|
+
state.stats = computeStats(messages);
|
|
109
|
+
state.contextTokens = computeContextTokens(messages);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildLargeMarkdown(seed: number): string {
|
|
113
|
+
return Array.from({ length: LARGE_MARKDOWN_SECTION_COUNT }, (_, index) => {
|
|
114
|
+
const section = seed * 100 + index;
|
|
115
|
+
return [
|
|
116
|
+
`# Section ${section}`,
|
|
117
|
+
"",
|
|
118
|
+
`Paragraph ${section}: ${"lorem ipsum dolor sit amet ".repeat(10)}`,
|
|
119
|
+
"",
|
|
120
|
+
`- item ${section}a with [link](https://example.com/${section})`,
|
|
121
|
+
`- item ${section}b with **bold** text and \`inline_code_${section}\``,
|
|
122
|
+
"",
|
|
123
|
+
`> Quote ${section}: ${"wrapped quoted text ".repeat(10)}`,
|
|
124
|
+
"",
|
|
125
|
+
"```ts",
|
|
126
|
+
`const value${section} = ${section};`,
|
|
127
|
+
`console.log(value${section});`,
|
|
128
|
+
"```",
|
|
129
|
+
].join("\n");
|
|
130
|
+
}).join("\n\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function createLargeMarkdownMessages(): AppState["messages"] {
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
role: "user",
|
|
137
|
+
content: "Investigate the rendering lag in this session.",
|
|
138
|
+
timestamp: 1,
|
|
139
|
+
},
|
|
140
|
+
fauxAssistantMessage(buildLargeMarkdown(1), { timestamp: 2 }),
|
|
141
|
+
{
|
|
142
|
+
role: "user",
|
|
143
|
+
content: "Keep going with the detailed write-up.",
|
|
144
|
+
timestamp: 3,
|
|
145
|
+
},
|
|
146
|
+
fauxAssistantMessage(buildLargeMarkdown(2), { timestamp: 4 }),
|
|
147
|
+
{
|
|
148
|
+
role: "user",
|
|
149
|
+
content: "Add one more large markdown response for history.",
|
|
150
|
+
timestamp: 5,
|
|
151
|
+
},
|
|
152
|
+
fauxAssistantMessage(buildLargeMarkdown(3), { timestamp: 6 }),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countNodes(node: Node): number {
|
|
157
|
+
if (node.type === "text" || node.type === "textinput") {
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
1 + node.children.reduce((total, child) => total + countNodes(child), 0)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getConversationNode(
|
|
167
|
+
messages: AppState["messages"],
|
|
168
|
+
index: number,
|
|
169
|
+
): Node {
|
|
170
|
+
resetConversationRenderCache();
|
|
171
|
+
|
|
172
|
+
const nodes = buildConversationLogNodes(
|
|
173
|
+
{
|
|
174
|
+
messages,
|
|
175
|
+
showReasoning: true,
|
|
176
|
+
verbose: false,
|
|
177
|
+
theme: DEFAULT_THEME,
|
|
178
|
+
versionLabel: "dev",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
isStreaming: false,
|
|
182
|
+
content: [],
|
|
183
|
+
pendingToolResults: [],
|
|
184
|
+
},
|
|
185
|
+
0,
|
|
186
|
+
80,
|
|
187
|
+
);
|
|
188
|
+
const node = nodes[index];
|
|
189
|
+
|
|
190
|
+
if (!node) {
|
|
191
|
+
throw new Error(`Expected a rendered node at index ${index}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return node;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function expectNodeCountAtMost(
|
|
198
|
+
label: string,
|
|
199
|
+
node: Node,
|
|
200
|
+
maxNodes: number,
|
|
201
|
+
): void {
|
|
202
|
+
const nodeCount = countNodes(node);
|
|
203
|
+
|
|
204
|
+
if (nodeCount > maxNodes) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Expected ${label} node count <= ${maxNodes}, got ${nodeCount}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function median(values: readonly number[]): number {
|
|
212
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
213
|
+
const middle = Math.floor(sorted.length / 2);
|
|
214
|
+
|
|
215
|
+
if (sorted.length % 2 === 0) {
|
|
216
|
+
return (sorted[middle - 1]! + sorted[middle]!) / 2;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return sorted[middle]!;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function measureTypingMedianRerenderMs(state: AppState): Promise<number> {
|
|
223
|
+
const terminal = new MockTerminal(VIEWPORT_COLS, VIEWPORT_ROWS);
|
|
224
|
+
const controller = createInputController(state);
|
|
225
|
+
const samples: number[] = [];
|
|
226
|
+
|
|
227
|
+
startUiViewport(state, terminal, controller);
|
|
228
|
+
await flushCelRender();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
for (let index = 0; index < TYPING_SAMPLE_COUNT; index++) {
|
|
232
|
+
const nextValue = index % 2 === 0 ? "a" : "ab";
|
|
233
|
+
const start = performance.now();
|
|
234
|
+
controller.onChange(nextValue);
|
|
235
|
+
await flushCelRender();
|
|
236
|
+
samples.push(performance.now() - start);
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
cel.stop();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return median(samples);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
afterEach(() => {
|
|
246
|
+
resetUiState();
|
|
247
|
+
cel.stop();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("ui render performance", () => {
|
|
251
|
+
test("renderBaseLayout with large historical assistant markdown stays within the node budget", () => {
|
|
252
|
+
// Arrange
|
|
253
|
+
const state = createTestState();
|
|
254
|
+
setMessages(state, createLargeMarkdownMessages());
|
|
255
|
+
const controller = createInputController(state);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// Act
|
|
259
|
+
const base = renderBaseLayout(state, VIEWPORT_COLS, controller);
|
|
260
|
+
const nodeCount = countNodes(base);
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
if (nodeCount > NODE_BUDGET) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Expected large-markdown layout node count <= ${NODE_BUDGET}, got ${nodeCount}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
state.db.close();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("non-markdown conversation log items stay within bounded node budgets", () => {
|
|
274
|
+
// Arrange
|
|
275
|
+
const userNode = getConversationNode(
|
|
276
|
+
[
|
|
277
|
+
{
|
|
278
|
+
role: "user",
|
|
279
|
+
content: "lorem ipsum dolor sit amet ".repeat(400),
|
|
280
|
+
timestamp: 1,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
0,
|
|
284
|
+
);
|
|
285
|
+
const uiNode = getConversationNode(
|
|
286
|
+
[createUiMessage("status update ".repeat(400))],
|
|
287
|
+
0,
|
|
288
|
+
);
|
|
289
|
+
const thinkingNode = getConversationNode(
|
|
290
|
+
[fauxAssistantMessage([fauxThinking("plan\n".repeat(400))])],
|
|
291
|
+
0,
|
|
292
|
+
);
|
|
293
|
+
const assistantToolCallsNode = getConversationNode(
|
|
294
|
+
[
|
|
295
|
+
fauxAssistantMessage([
|
|
296
|
+
fauxThinking("brief plan"),
|
|
297
|
+
fauxToolCall(
|
|
298
|
+
"shell",
|
|
299
|
+
{ command: "printf 'foo\\nbar'" },
|
|
300
|
+
{ id: "tool-1" },
|
|
301
|
+
),
|
|
302
|
+
fauxToolCall(
|
|
303
|
+
"edit",
|
|
304
|
+
{
|
|
305
|
+
path: "src/app.ts",
|
|
306
|
+
oldText: "before",
|
|
307
|
+
newText: "after",
|
|
308
|
+
},
|
|
309
|
+
{ id: "tool-2" },
|
|
310
|
+
),
|
|
311
|
+
fauxToolCall("readImage", { path: "diagram.png" }, { id: "tool-3" }),
|
|
312
|
+
]),
|
|
313
|
+
],
|
|
314
|
+
0,
|
|
315
|
+
);
|
|
316
|
+
const longShellToolCallNode = getConversationNode(
|
|
317
|
+
[
|
|
318
|
+
fauxAssistantMessage([
|
|
319
|
+
fauxToolCall(
|
|
320
|
+
"shell",
|
|
321
|
+
{ command: `printf ${"x".repeat(300)}TAIL` },
|
|
322
|
+
{ id: "tool-4" },
|
|
323
|
+
),
|
|
324
|
+
]),
|
|
325
|
+
],
|
|
326
|
+
0,
|
|
327
|
+
);
|
|
328
|
+
const shellResultNode = getConversationNode(
|
|
329
|
+
[
|
|
330
|
+
fauxAssistantMessage([
|
|
331
|
+
fauxToolCall("shell", { command: "seq 1 200" }, { id: "tool-1" }),
|
|
332
|
+
]),
|
|
333
|
+
{
|
|
334
|
+
role: "toolResult",
|
|
335
|
+
toolCallId: "tool-1",
|
|
336
|
+
toolName: "shell",
|
|
337
|
+
content: [
|
|
338
|
+
{
|
|
339
|
+
type: "text",
|
|
340
|
+
text: Array.from(
|
|
341
|
+
{ length: 200 },
|
|
342
|
+
(_, index) => `line ${index + 1}`,
|
|
343
|
+
).join("\n"),
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
isError: false,
|
|
347
|
+
timestamp: 2,
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
1,
|
|
351
|
+
);
|
|
352
|
+
const editErrorNode = getConversationNode(
|
|
353
|
+
[
|
|
354
|
+
fauxAssistantMessage([
|
|
355
|
+
fauxToolCall(
|
|
356
|
+
"edit",
|
|
357
|
+
{
|
|
358
|
+
path: "src/app.ts",
|
|
359
|
+
oldText: "before",
|
|
360
|
+
newText: "after",
|
|
361
|
+
},
|
|
362
|
+
{ id: "tool-2" },
|
|
363
|
+
),
|
|
364
|
+
]),
|
|
365
|
+
{
|
|
366
|
+
role: "toolResult",
|
|
367
|
+
toolCallId: "tool-2",
|
|
368
|
+
toolName: "edit",
|
|
369
|
+
content: [
|
|
370
|
+
{
|
|
371
|
+
type: "text",
|
|
372
|
+
text: Array.from(
|
|
373
|
+
{ length: 120 },
|
|
374
|
+
(_, index) => `line ${index + 1}`,
|
|
375
|
+
).join("\n"),
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
isError: true,
|
|
379
|
+
timestamp: 3,
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
1,
|
|
383
|
+
);
|
|
384
|
+
const genericResultNode = getConversationNode(
|
|
385
|
+
[
|
|
386
|
+
{
|
|
387
|
+
role: "toolResult",
|
|
388
|
+
toolCallId: "tool-3",
|
|
389
|
+
toolName: "pluginSearch",
|
|
390
|
+
content: [
|
|
391
|
+
{
|
|
392
|
+
type: "text",
|
|
393
|
+
text: Array.from(
|
|
394
|
+
{ length: 200 },
|
|
395
|
+
(_, index) => `row ${index + 1}`,
|
|
396
|
+
).join("\n"),
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
isError: false,
|
|
400
|
+
timestamp: 4,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
0,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// Assert
|
|
407
|
+
expectNodeCountAtMost("long user message", userNode, 4);
|
|
408
|
+
expectNodeCountAtMost("long UI message", uiNode, 4);
|
|
409
|
+
expectNodeCountAtMost("thinking-only assistant message", thinkingNode, 4);
|
|
410
|
+
expectNodeCountAtMost(
|
|
411
|
+
"assistant tool-call bundle",
|
|
412
|
+
assistantToolCallsNode,
|
|
413
|
+
64,
|
|
414
|
+
);
|
|
415
|
+
expectNodeCountAtMost(
|
|
416
|
+
"long single-token shell tool call",
|
|
417
|
+
longShellToolCallNode,
|
|
418
|
+
64,
|
|
419
|
+
);
|
|
420
|
+
expectNodeCountAtMost("shell tool-result preview", shellResultNode, 24);
|
|
421
|
+
expectNodeCountAtMost("edit error preview", editErrorNode, 24);
|
|
422
|
+
expectNodeCountAtMost("generic tool result", genericResultNode, 240);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("typing into the input with large historical assistant markdown stays within the rerender budget", async () => {
|
|
426
|
+
// Arrange
|
|
427
|
+
const state = createTestState();
|
|
428
|
+
setMessages(state, createLargeMarkdownMessages());
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
// Act
|
|
432
|
+
const medianRerenderMs = await measureTypingMedianRerenderMs(state);
|
|
433
|
+
|
|
434
|
+
// Assert
|
|
435
|
+
if (medianRerenderMs > TYPING_MEDIAN_BUDGET_MS) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`Expected typing median rerender <= ${TYPING_MEDIAN_BUDGET_MS}ms, got ${medianRerenderMs.toFixed(1)}ms`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
} finally {
|
|
441
|
+
state.db.close();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
});
|
package/src/ui/status.test.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { platform } from "node:os";
|
|
13
|
+
import { Select } from "@cel-tui/components";
|
|
13
14
|
import {
|
|
14
15
|
cel,
|
|
15
16
|
HStack,
|
|
@@ -37,9 +38,14 @@ import {
|
|
|
37
38
|
import type { InputController } from "./ui/input.ts";
|
|
38
39
|
import {
|
|
39
40
|
autocompleteInputPath,
|
|
41
|
+
findInputPathMatches,
|
|
40
42
|
renderInputArea as renderInputAreaNode,
|
|
41
43
|
} from "./ui/input.ts";
|
|
42
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type ActiveOverlay,
|
|
46
|
+
OVERLAY_MAX_VISIBLE,
|
|
47
|
+
renderOverlay,
|
|
48
|
+
} from "./ui/overlay.ts";
|
|
43
49
|
import { renderStatusBar } from "./ui/status.ts";
|
|
44
50
|
|
|
45
51
|
export type { InputController } from "./ui/input.ts";
|
|
@@ -286,6 +292,48 @@ function dismissOverlay(): void {
|
|
|
286
292
|
cel.render();
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
function openPathAutocompleteOverlay(state: AppState): void {
|
|
296
|
+
const matches = findInputPathMatches(inputValue, state.cwd);
|
|
297
|
+
if (matches.length <= 1) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const select = Select({
|
|
302
|
+
items: matches.map((match) => ({
|
|
303
|
+
label: match.label,
|
|
304
|
+
value: match.value,
|
|
305
|
+
filterText: match.label,
|
|
306
|
+
})),
|
|
307
|
+
maxVisible: OVERLAY_MAX_VISIBLE,
|
|
308
|
+
placeholder: "type to filter paths...",
|
|
309
|
+
focused: true,
|
|
310
|
+
highlightColor: state.theme.accentText,
|
|
311
|
+
onSelect: (value) => {
|
|
312
|
+
inputValue = value;
|
|
313
|
+
dismissOverlay();
|
|
314
|
+
},
|
|
315
|
+
onBlur: dismissOverlay,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
openOverlay({ select, title: "Path matches" });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function handleTabKeyPress(state: AppState): void {
|
|
322
|
+
if (inputValue.startsWith("/")) {
|
|
323
|
+
commandController.showCommandAutocomplete(state);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
328
|
+
if (completedInput) {
|
|
329
|
+
inputValue = completedInput;
|
|
330
|
+
cel.render();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
openPathAutocompleteOverlay(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
289
337
|
/**
|
|
290
338
|
* Render the active overlay when one is open.
|
|
291
339
|
*
|
|
@@ -344,15 +392,7 @@ export function createInputController(state: AppState): InputController {
|
|
|
344
392
|
return false;
|
|
345
393
|
}
|
|
346
394
|
if (key === "tab") {
|
|
347
|
-
|
|
348
|
-
commandController.showCommandAutocomplete(state);
|
|
349
|
-
} else {
|
|
350
|
-
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
351
|
-
if (completedInput) {
|
|
352
|
-
inputValue = completedInput;
|
|
353
|
-
cel.render();
|
|
354
|
-
}
|
|
355
|
-
}
|
|
395
|
+
handleTabKeyPress(state);
|
|
356
396
|
return false;
|
|
357
397
|
}
|
|
358
398
|
},
|