mini-coder 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/input.ts CHANGED
@@ -25,39 +25,18 @@ export interface InputController {
25
25
  onKeyPress: (key: string) => boolean | undefined;
26
26
  }
27
27
 
28
- /** Find the longest common prefix across a list of strings. */
29
- function getLongestCommonPrefix(values: readonly string[]): string {
30
- if (values.length === 0) {
31
- return "";
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 null;
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 null;
62
+ return [];
84
63
  }
85
64
 
86
65
  const showHidden = partial.startsWith(".");
87
- const matches = entries
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
- if (matches.length === 0) {
93
- return null;
94
- }
95
-
96
- let completedName: string | null = null;
97
- if (matches.length === 1) {
98
- const match = matches[0]!;
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
- if (!completedName) {
108
- return null;
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
- return `${value.slice(0, tokenStart)}${dirToken}${completedName}`;
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
+ });
@@ -106,6 +106,7 @@ function createTestState(): AppState {
106
106
  running: false,
107
107
  abortController: null,
108
108
  activeTurnPromise: null,
109
+ queuedUserMessages: [],
109
110
  showReasoning: DEFAULT_SHOW_REASONING,
110
111
  verbose: DEFAULT_VERBOSE,
111
112
  versionLabel: "dev",
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 { type ActiveOverlay, renderOverlay } from "./ui/overlay.ts";
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
- if (inputValue.startsWith("/")) {
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
  },