mini-coder 0.5.10 → 0.5.12
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/BENCHMARK.md +408 -0
- package/PROGRESS.md +5 -0
- package/README.md +14 -1
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-loop.sh +19 -0
- package/package.json +1 -1
- package/src/agent.ts +5 -1
- package/src/headless.ts +62 -21
- package/src/index.ts +35 -56
- package/src/prompt.ts +8 -0
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +19 -15
- package/src/shared.ts +39 -0
- package/src/submit.ts +3 -25
- package/src/text.ts +71 -0
- package/src/tool-common.ts +91 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +869 -0
- package/src/tools.ts +186 -995
- package/src/ui/agent.ts +199 -110
- package/src/ui/commands.test.ts +16 -302
- package/src/ui/commands.ts +21 -47
- package/src/ui/conversation.test.ts +263 -1389
- package/src/ui/conversation.ts +496 -151
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +196 -114
- package/src/ui/agent.test.ts +0 -49
- package/src/ui/help.test.ts +0 -65
- package/src/ui/overlay.test.ts +0 -42
- package/src/ui/render-performance.test.ts +0 -444
- package/src/ui/status.test.ts +0 -489
package/src/ui/input.test.ts
CHANGED
|
@@ -2,13 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
autocompleteInputPath,
|
|
8
|
-
findInputPathMatches,
|
|
9
|
-
type InputController,
|
|
10
|
-
renderInputArea,
|
|
11
|
-
} from "./input.ts";
|
|
5
|
+
import { autocompleteInputPath, findInputPathMatches } from "./input.ts";
|
|
12
6
|
|
|
13
7
|
const tempDirs: string[] = [];
|
|
14
8
|
|
|
@@ -24,43 +18,7 @@ afterEach(() => {
|
|
|
24
18
|
}
|
|
25
19
|
});
|
|
26
20
|
|
|
27
|
-
function expectTextInput(node: ReturnType<typeof renderInputArea>) {
|
|
28
|
-
if (node.type !== "textinput") {
|
|
29
|
-
throw new Error("Expected a TextInput node");
|
|
30
|
-
}
|
|
31
|
-
return node;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
21
|
describe("ui/input", () => {
|
|
35
|
-
test("renderInputArea shows the current draft with the configured placeholder and size limits", () => {
|
|
36
|
-
const controller: InputController = {
|
|
37
|
-
onChange: () => {},
|
|
38
|
-
onFocus: () => {},
|
|
39
|
-
onBlur: () => {},
|
|
40
|
-
onKeyPress: () => undefined,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const input = expectTextInput(
|
|
44
|
-
renderInputArea(DEFAULT_THEME, controller, "draft", true),
|
|
45
|
-
);
|
|
46
|
-
const placeholder = input.props.placeholder;
|
|
47
|
-
|
|
48
|
-
expect(input.props.value).toBe("draft");
|
|
49
|
-
expect(input.props.focused).toBe(true);
|
|
50
|
-
expect(input.props.minHeight).toBe(2);
|
|
51
|
-
expect(input.props.maxHeight).toBe(10);
|
|
52
|
-
expect(input.props.padding).toEqual({ x: 1 });
|
|
53
|
-
expect(placeholder?.type).toBe("text");
|
|
54
|
-
if (!placeholder || placeholder.type !== "text") {
|
|
55
|
-
throw new Error("Expected a text placeholder");
|
|
56
|
-
}
|
|
57
|
-
expect(placeholder.content).toBe(
|
|
58
|
-
"`Ctrl+R` for input history, `/` + `Tab` for interactive menu, or type a message…",
|
|
59
|
-
);
|
|
60
|
-
expect(placeholder.props.fgColor).toBe(DEFAULT_THEME.mutedText);
|
|
61
|
-
expect(placeholder.props.italic).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
22
|
test("autocompleteInputPath completes the last file path token when exactly one match is available", () => {
|
|
65
23
|
const cwd = createTempDir();
|
|
66
24
|
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-only helper bindings for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AppState } from "../index.ts";
|
|
8
|
+
import {
|
|
9
|
+
appendConversationMessage,
|
|
10
|
+
appendMessage,
|
|
11
|
+
createUiMessage,
|
|
12
|
+
createUiTodoMessage,
|
|
13
|
+
type UiInfoFormat,
|
|
14
|
+
type UiMessage,
|
|
15
|
+
} from "../session.ts";
|
|
16
|
+
import type { TodoItem } from "../tools.ts";
|
|
17
|
+
|
|
18
|
+
/** Render scheduling priorities used by the terminal UI. */
|
|
19
|
+
export type UiRenderPriority = "immediate" | "normal" | "stream" | "animation";
|
|
20
|
+
|
|
21
|
+
interface UiRuntimeHooks {
|
|
22
|
+
/** Schedule a UI render. */
|
|
23
|
+
requestRender: (priority?: UiRenderPriority) => void;
|
|
24
|
+
/** Re-enable stick-to-bottom behavior for the conversation log. */
|
|
25
|
+
scrollConversationToBottom: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface UiRuntimeHelpers {
|
|
29
|
+
/** Append a UI-only info message to the conversation log. */
|
|
30
|
+
appendInfoMessage: (
|
|
31
|
+
text: string,
|
|
32
|
+
state: AppState,
|
|
33
|
+
format?: UiInfoFormat,
|
|
34
|
+
) => void;
|
|
35
|
+
/** Append a UI-only todo snapshot to the conversation log. */
|
|
36
|
+
appendTodoMessage: (todos: readonly TodoItem[], state: AppState) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendUiMessage(
|
|
40
|
+
message: UiMessage,
|
|
41
|
+
state: AppState,
|
|
42
|
+
runtime: UiRuntimeHooks,
|
|
43
|
+
): void {
|
|
44
|
+
if (state.session) {
|
|
45
|
+
appendMessage(state.db, state.session.id, message);
|
|
46
|
+
}
|
|
47
|
+
appendConversationMessage(state, message);
|
|
48
|
+
runtime.scrollConversationToBottom();
|
|
49
|
+
runtime.requestRender("normal");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create runtime-bound helpers that append UI-only conversation messages.
|
|
54
|
+
*
|
|
55
|
+
* @param runtime - Render and scroll hooks owned by `ui.ts`.
|
|
56
|
+
* @returns Helpers shared by the command and agent controllers.
|
|
57
|
+
*/
|
|
58
|
+
export function createUiRuntimeHelpers(
|
|
59
|
+
runtime: UiRuntimeHooks,
|
|
60
|
+
): UiRuntimeHelpers {
|
|
61
|
+
return {
|
|
62
|
+
appendInfoMessage: (text, state, format) => {
|
|
63
|
+
appendUiMessage(createUiMessage(text, format), state, runtime);
|
|
64
|
+
},
|
|
65
|
+
appendTodoMessage: (todos, state) => {
|
|
66
|
+
appendUiMessage(createUiTodoMessage(todos), state, runtime);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -22,18 +22,9 @@ import {
|
|
|
22
22
|
import type { Node } from "@cel-tui/types";
|
|
23
23
|
import type { AppState } from "./index.ts";
|
|
24
24
|
import { reloadPromptContext, shutdown } from "./index.ts";
|
|
25
|
-
import {
|
|
26
|
-
appendMessage,
|
|
27
|
-
createUiMessage,
|
|
28
|
-
createUiTodoMessage,
|
|
29
|
-
type UiInfoFormat,
|
|
30
|
-
} from "./session.ts";
|
|
25
|
+
import { collapseWhitespaceToNull, joinTextBlocks } from "./text.ts";
|
|
31
26
|
import type { Theme } from "./theme.ts";
|
|
32
|
-
import {
|
|
33
|
-
createUiAgentController,
|
|
34
|
-
getStreamingConversationState,
|
|
35
|
-
resetUiAgentState,
|
|
36
|
-
} from "./ui/agent.ts";
|
|
27
|
+
import { createUiAgentController } from "./ui/agent.ts";
|
|
37
28
|
import { createCommandController } from "./ui/commands.ts";
|
|
38
29
|
import {
|
|
39
30
|
buildConversationLogNodes,
|
|
@@ -51,6 +42,7 @@ import {
|
|
|
51
42
|
OVERLAY_MAX_VISIBLE,
|
|
52
43
|
renderOverlay,
|
|
53
44
|
} from "./ui/overlay.ts";
|
|
45
|
+
import { createUiRuntimeHelpers, type UiRenderPriority } from "./ui/runtime.ts";
|
|
54
46
|
import { renderStatusBar } from "./ui/status.ts";
|
|
55
47
|
|
|
56
48
|
export type { InputController } from "./ui/input.ts";
|
|
@@ -85,6 +77,12 @@ const TERMINAL_TITLE_FRAMES = [
|
|
|
85
77
|
/** Maximum number of committed messages rendered before older history is chunked. */
|
|
86
78
|
const CONVERSATION_CHUNK_MESSAGES = 50;
|
|
87
79
|
|
|
80
|
+
/** Minimum delay between coalesced streaming renders. */
|
|
81
|
+
const STREAM_RENDER_MIN_INTERVAL_MS = 33;
|
|
82
|
+
|
|
83
|
+
/** Minimum delay between low-priority divider animation renders. */
|
|
84
|
+
const ANIMATION_RENDER_MIN_INTERVAL_MS = DIVIDER_FRAME_MS;
|
|
85
|
+
|
|
88
86
|
/** Centralized interactive quit rules for keypresses and submitted input. */
|
|
89
87
|
const QUIT_RULES: Readonly<{
|
|
90
88
|
/** Submitted raw inputs that trigger graceful quit. */
|
|
@@ -173,7 +171,8 @@ export function resetUiState(): void {
|
|
|
173
171
|
inputFocused = true;
|
|
174
172
|
dividerTick = 0;
|
|
175
173
|
stopDividerAnimation();
|
|
176
|
-
|
|
174
|
+
renderScheduler.reset();
|
|
175
|
+
agentController.reset();
|
|
177
176
|
resetConversationRenderCache();
|
|
178
177
|
activeOverlay = null;
|
|
179
178
|
stdinWasRaw = false;
|
|
@@ -186,28 +185,14 @@ export function resetUiState(): void {
|
|
|
186
185
|
// Terminal title
|
|
187
186
|
// ---------------------------------------------------------------------------
|
|
188
187
|
|
|
189
|
-
function collapseTerminalTitleText(text: string): string | null {
|
|
190
|
-
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
191
|
-
return collapsed.length > 0 ? collapsed : null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
188
|
function getUserTerminalTitleText(
|
|
195
189
|
content: Extract<AppState["messages"][number], { role: "user" }>["content"],
|
|
196
190
|
): string | null {
|
|
197
191
|
if (typeof content === "string") {
|
|
198
|
-
return
|
|
192
|
+
return collapseWhitespaceToNull(content);
|
|
199
193
|
}
|
|
200
194
|
|
|
201
|
-
|
|
202
|
-
.filter(
|
|
203
|
-
(block): block is Extract<(typeof content)[number], { type: "text" }> => {
|
|
204
|
-
return block.type === "text";
|
|
205
|
-
},
|
|
206
|
-
)
|
|
207
|
-
.map((block) => block.text)
|
|
208
|
-
.join(" ");
|
|
209
|
-
|
|
210
|
-
return collapseTerminalTitleText(text);
|
|
195
|
+
return collapseWhitespaceToNull(joinTextBlocks(content));
|
|
211
196
|
}
|
|
212
197
|
|
|
213
198
|
function getAssistantTerminalTitleText(
|
|
@@ -216,24 +201,7 @@ function getAssistantTerminalTitleText(
|
|
|
216
201
|
{ role: "assistant" }
|
|
217
202
|
>["content"],
|
|
218
203
|
): string | null {
|
|
219
|
-
|
|
220
|
-
.filter(
|
|
221
|
-
(
|
|
222
|
-
block,
|
|
223
|
-
): block is Extract<
|
|
224
|
-
Extract<
|
|
225
|
-
AppState["messages"][number],
|
|
226
|
-
{ role: "assistant" }
|
|
227
|
-
>["content"][number],
|
|
228
|
-
{ type: "text" }
|
|
229
|
-
> => {
|
|
230
|
-
return block.type === "text";
|
|
231
|
-
},
|
|
232
|
-
)
|
|
233
|
-
.map((block) => block.text)
|
|
234
|
-
.join(" ");
|
|
235
|
-
|
|
236
|
-
return collapseTerminalTitleText(text);
|
|
204
|
+
return collapseWhitespaceToNull(joinTextBlocks(content));
|
|
237
205
|
}
|
|
238
206
|
|
|
239
207
|
function truncateTerminalTitleTail(text: string): string {
|
|
@@ -316,9 +284,168 @@ function invalidateTerminalTitleCache(): void {
|
|
|
316
284
|
lastTerminalTitle = null;
|
|
317
285
|
}
|
|
318
286
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
287
|
+
interface RenderSchedulerRuntime {
|
|
288
|
+
/** Render-time clock used for throttling in tests and production. */
|
|
289
|
+
now?: () => number;
|
|
290
|
+
/** Microtask queue used for coalescing normal-priority flushes. */
|
|
291
|
+
queueMicrotask?: (callback: () => void) => void;
|
|
292
|
+
/** Timer primitive used for deferred stream/animation flushes. */
|
|
293
|
+
setTimeout?: typeof setTimeout;
|
|
294
|
+
/** Timer cancellation primitive paired with `setTimeout`. */
|
|
295
|
+
clearTimeout?: typeof clearTimeout;
|
|
296
|
+
/** Optional hook to sync the terminal title before rendering. */
|
|
297
|
+
syncTitle?: () => void;
|
|
298
|
+
/** cel render primitive invoked for each scheduled flush. */
|
|
299
|
+
render: () => void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface RenderScheduler {
|
|
303
|
+
/** Schedule a render with the given priority. */
|
|
304
|
+
requestRender: (priority?: UiRenderPriority) => void;
|
|
305
|
+
/** Cancel any pending render work and reset throttling state. */
|
|
306
|
+
reset: () => void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getRenderPriorityRank(priority: UiRenderPriority): number {
|
|
310
|
+
switch (priority) {
|
|
311
|
+
case "immediate":
|
|
312
|
+
return 3;
|
|
313
|
+
case "normal":
|
|
314
|
+
return 2;
|
|
315
|
+
case "stream":
|
|
316
|
+
return 1;
|
|
317
|
+
case "animation":
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getRenderMinInterval(priority: UiRenderPriority): number {
|
|
323
|
+
switch (priority) {
|
|
324
|
+
case "stream":
|
|
325
|
+
return STREAM_RENDER_MIN_INTERVAL_MS;
|
|
326
|
+
case "animation":
|
|
327
|
+
return ANIMATION_RENDER_MIN_INTERVAL_MS;
|
|
328
|
+
default:
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function chooseHigherRenderPriority(
|
|
334
|
+
left: UiRenderPriority | null,
|
|
335
|
+
right: UiRenderPriority,
|
|
336
|
+
): UiRenderPriority {
|
|
337
|
+
if (!left) {
|
|
338
|
+
return right;
|
|
339
|
+
}
|
|
340
|
+
return getRenderPriorityRank(left) >= getRenderPriorityRank(right)
|
|
341
|
+
? left
|
|
342
|
+
: right;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Create the coalescing render scheduler used by the terminal UI.
|
|
347
|
+
*
|
|
348
|
+
* @param runtime - Render/timer hooks for production code and focused tests.
|
|
349
|
+
* @returns A scheduler that coalesces repeated render requests by priority.
|
|
350
|
+
*/
|
|
351
|
+
export function createRenderScheduler(
|
|
352
|
+
runtime: RenderSchedulerRuntime,
|
|
353
|
+
): RenderScheduler {
|
|
354
|
+
let pendingPriority: UiRenderPriority | null = null;
|
|
355
|
+
let microtaskQueued = false;
|
|
356
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
357
|
+
let lastRenderTime = 0;
|
|
358
|
+
|
|
359
|
+
const now = (): number => runtime.now?.() ?? Date.now();
|
|
360
|
+
const enqueueMicrotask = runtime.queueMicrotask ?? queueMicrotask;
|
|
361
|
+
const startTimer = runtime.setTimeout ?? setTimeout;
|
|
362
|
+
const stopTimer = runtime.clearTimeout ?? clearTimeout;
|
|
363
|
+
|
|
364
|
+
const clearRenderTimer = (): void => {
|
|
365
|
+
if (timer) {
|
|
366
|
+
stopTimer(timer);
|
|
367
|
+
timer = null;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const flushRender = (): void => {
|
|
372
|
+
pendingPriority = null;
|
|
373
|
+
microtaskQueued = false;
|
|
374
|
+
clearRenderTimer();
|
|
375
|
+
runtime.syncTitle?.();
|
|
376
|
+
runtime.render();
|
|
377
|
+
lastRenderTime = now();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const schedulePendingRender = (): void => {
|
|
381
|
+
if (!pendingPriority) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (pendingPriority === "immediate") {
|
|
386
|
+
flushRender();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (pendingPriority === "normal") {
|
|
391
|
+
clearRenderTimer();
|
|
392
|
+
if (microtaskQueued) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
microtaskQueued = true;
|
|
396
|
+
enqueueMicrotask(() => {
|
|
397
|
+
microtaskQueued = false;
|
|
398
|
+
if (!pendingPriority) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
flushRender();
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (microtaskQueued) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const delay = Math.max(
|
|
411
|
+
0,
|
|
412
|
+
lastRenderTime + getRenderMinInterval(pendingPriority) - now(),
|
|
413
|
+
);
|
|
414
|
+
clearRenderTimer();
|
|
415
|
+
timer = startTimer(() => {
|
|
416
|
+
timer = null;
|
|
417
|
+
if (!pendingPriority) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
flushRender();
|
|
421
|
+
}, delay);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
requestRender: (priority = "normal") => {
|
|
426
|
+
pendingPriority = chooseHigherRenderPriority(pendingPriority, priority);
|
|
427
|
+
schedulePendingRender();
|
|
428
|
+
},
|
|
429
|
+
reset: () => {
|
|
430
|
+
pendingPriority = null;
|
|
431
|
+
microtaskQueued = false;
|
|
432
|
+
clearRenderTimer();
|
|
433
|
+
lastRenderTime = 0;
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const renderScheduler = createRenderScheduler({
|
|
439
|
+
render: () => {
|
|
440
|
+
cel.render();
|
|
441
|
+
},
|
|
442
|
+
syncTitle: () => {
|
|
443
|
+
syncTerminalTitle(titleState);
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
function requestRender(priority: UiRenderPriority = "normal"): void {
|
|
448
|
+
renderScheduler.requestRender(priority);
|
|
322
449
|
}
|
|
323
450
|
|
|
324
451
|
// ---------------------------------------------------------------------------
|
|
@@ -331,7 +458,7 @@ function startDividerAnimation(): void {
|
|
|
331
458
|
dividerTick = 0;
|
|
332
459
|
dividerTimer = setInterval(() => {
|
|
333
460
|
dividerTick++;
|
|
334
|
-
requestRender();
|
|
461
|
+
requestRender("animation");
|
|
335
462
|
}, DIVIDER_FRAME_MS);
|
|
336
463
|
}
|
|
337
464
|
|
|
@@ -412,7 +539,7 @@ export function buildConversationLog(
|
|
|
412
539
|
): Node[] {
|
|
413
540
|
return buildConversationLogNodes(
|
|
414
541
|
state,
|
|
415
|
-
getStreamingConversationState(),
|
|
542
|
+
agentController.getStreamingConversationState(),
|
|
416
543
|
getVisibleConversationStart(state.messages.length),
|
|
417
544
|
width,
|
|
418
545
|
);
|
|
@@ -428,7 +555,7 @@ function measureConversationHeight(
|
|
|
428
555
|
{ gap: CONVERSATION_GAP },
|
|
429
556
|
buildConversationLogNodes(
|
|
430
557
|
state,
|
|
431
|
-
getStreamingConversationState(),
|
|
558
|
+
agentController.getStreamingConversationState(),
|
|
432
559
|
startIndex,
|
|
433
560
|
width,
|
|
434
561
|
),
|
|
@@ -455,14 +582,12 @@ function prependConversationChunk(state: AppState, width: number): void {
|
|
|
455
582
|
function openOverlay(overlay: ActiveOverlay): void {
|
|
456
583
|
activeOverlay = overlay;
|
|
457
584
|
inputFocused = false;
|
|
458
|
-
requestRender();
|
|
459
585
|
}
|
|
460
586
|
|
|
461
587
|
/** Dismiss the active overlay and return focus to the input. */
|
|
462
588
|
function dismissOverlay(): void {
|
|
463
589
|
activeOverlay = null;
|
|
464
590
|
inputFocused = true;
|
|
465
|
-
requestRender();
|
|
466
591
|
}
|
|
467
592
|
|
|
468
593
|
function openPathAutocompleteOverlay(state: AppState): void {
|
|
@@ -500,7 +625,6 @@ function handleTabKeyPress(state: AppState): void {
|
|
|
500
625
|
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
501
626
|
if (completedInput) {
|
|
502
627
|
inputValue = completedInput;
|
|
503
|
-
requestRender();
|
|
504
628
|
return;
|
|
505
629
|
}
|
|
506
630
|
|
|
@@ -539,16 +663,16 @@ export function createInputController(state: AppState): InputController {
|
|
|
539
663
|
|
|
540
664
|
return {
|
|
541
665
|
onChange: (value) => {
|
|
666
|
+
if (inputValue === value) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
542
669
|
inputValue = value;
|
|
543
|
-
requestRender();
|
|
544
670
|
},
|
|
545
671
|
onFocus: () => {
|
|
546
672
|
inputFocused = true;
|
|
547
|
-
requestRender();
|
|
548
673
|
},
|
|
549
674
|
onBlur: () => {
|
|
550
675
|
inputFocused = false;
|
|
551
|
-
requestRender();
|
|
552
676
|
},
|
|
553
677
|
onKeyPress: (key) => {
|
|
554
678
|
if (key === "enter") {
|
|
@@ -556,13 +680,11 @@ export function createInputController(state: AppState): InputController {
|
|
|
556
680
|
|
|
557
681
|
if (isQuitInput(raw)) {
|
|
558
682
|
inputValue = "";
|
|
559
|
-
requestRender();
|
|
560
683
|
requestGracefulExit(state);
|
|
561
684
|
return false;
|
|
562
685
|
}
|
|
563
686
|
|
|
564
687
|
inputValue = "";
|
|
565
|
-
requestRender();
|
|
566
688
|
handleInput(raw, state);
|
|
567
689
|
return false;
|
|
568
690
|
}
|
|
@@ -641,63 +763,25 @@ function scrollConversationToBottom(): void {
|
|
|
641
763
|
stickToBottom = true;
|
|
642
764
|
}
|
|
643
765
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
)
|
|
648
|
-
if (state.session) {
|
|
649
|
-
appendMessage(state.db, state.session.id, message);
|
|
650
|
-
}
|
|
651
|
-
state.messages.push(message);
|
|
652
|
-
scrollConversationToBottom();
|
|
653
|
-
requestRender();
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Append a UI-only info message to the conversation log.
|
|
658
|
-
*
|
|
659
|
-
* When no persisted session exists yet, the message stays in memory and is
|
|
660
|
-
* backfilled if the user later starts a session by sending a message.
|
|
661
|
-
*
|
|
662
|
-
* @param text - Display text to append.
|
|
663
|
-
* @param state - Application state.
|
|
664
|
-
* @param format - Optional rich-text format hint for the content.
|
|
665
|
-
*/
|
|
666
|
-
function appendInfoMessage(
|
|
667
|
-
text: string,
|
|
668
|
-
state: AppState,
|
|
669
|
-
format?: UiInfoFormat,
|
|
670
|
-
): void {
|
|
671
|
-
appendUiMessage(createUiMessage(text, format), state);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/**
|
|
675
|
-
* Append a UI-only todo snapshot to the conversation log.
|
|
676
|
-
*
|
|
677
|
-
* When no persisted session exists yet, the message stays in memory and is
|
|
678
|
-
* backfilled if the user later starts a session by sending a message.
|
|
679
|
-
*
|
|
680
|
-
* @param todos - Todo snapshot to append.
|
|
681
|
-
* @param state - Application state.
|
|
682
|
-
*/
|
|
683
|
-
function appendTodoMessage(
|
|
684
|
-
todos: Parameters<typeof createUiTodoMessage>[0],
|
|
685
|
-
state: AppState,
|
|
686
|
-
): void {
|
|
687
|
-
appendUiMessage(createUiTodoMessage(todos), state);
|
|
688
|
-
}
|
|
766
|
+
const uiRuntimeHelpers = createUiRuntimeHelpers({
|
|
767
|
+
requestRender,
|
|
768
|
+
scrollConversationToBottom,
|
|
769
|
+
});
|
|
689
770
|
|
|
690
771
|
/** Command controller bound to the module-scoped UI runtime hooks. */
|
|
691
772
|
const commandController = createCommandController({
|
|
692
773
|
openOverlay,
|
|
693
774
|
dismissOverlay,
|
|
694
775
|
setInputValue: (value) => {
|
|
776
|
+
if (inputValue === value) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
695
779
|
inputValue = value;
|
|
696
780
|
},
|
|
697
|
-
appendInfoMessage,
|
|
698
|
-
appendTodoMessage,
|
|
781
|
+
appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
|
|
782
|
+
appendTodoMessage: uiRuntimeHelpers.appendTodoMessage,
|
|
699
783
|
scrollConversationToBottom,
|
|
700
|
-
|
|
784
|
+
requestRender,
|
|
701
785
|
reloadPromptContext,
|
|
702
786
|
openInBrowser,
|
|
703
787
|
});
|
|
@@ -708,10 +792,10 @@ const commandController = createCommandController({
|
|
|
708
792
|
|
|
709
793
|
/** Agent controller bound to the module-scoped UI runtime hooks. */
|
|
710
794
|
const agentController = createUiAgentController({
|
|
711
|
-
appendInfoMessage,
|
|
795
|
+
appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
|
|
712
796
|
handleCommand: (command, state) =>
|
|
713
797
|
commandController.handleCommand(command, state),
|
|
714
|
-
|
|
798
|
+
requestRender,
|
|
715
799
|
scrollConversationToBottom,
|
|
716
800
|
startDividerAnimation,
|
|
717
801
|
stopDividerAnimation,
|
|
@@ -830,8 +914,6 @@ function renderConversationLog(state: AppState, width: number): Node {
|
|
|
830
914
|
if (!stickToBottom && offset === 0 && visibleConversationStart > 0) {
|
|
831
915
|
prependConversationChunk(state, width);
|
|
832
916
|
}
|
|
833
|
-
|
|
834
|
-
requestRender();
|
|
835
917
|
},
|
|
836
918
|
},
|
|
837
919
|
buildConversationLog(state, width),
|
|
@@ -930,7 +1012,7 @@ export function startUI(state: AppState): void {
|
|
|
930
1012
|
if (state.running) {
|
|
931
1013
|
startDividerAnimation();
|
|
932
1014
|
}
|
|
933
|
-
requestRender();
|
|
1015
|
+
requestRender("immediate");
|
|
934
1016
|
});
|
|
935
1017
|
});
|
|
936
1018
|
const overlay = renderActiveOverlay(state);
|
|
@@ -948,6 +1030,6 @@ export function startUI(state: AppState): void {
|
|
|
948
1030
|
|
|
949
1031
|
// Show warnings from custom provider discovery
|
|
950
1032
|
for (const warning of state.startupWarnings) {
|
|
951
|
-
appendInfoMessage(warning, state);
|
|
1033
|
+
uiRuntimeHelpers.appendInfoMessage(warning, state);
|
|
952
1034
|
}
|
|
953
1035
|
}
|
package/src/ui/agent.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { isEmptyUserContent, stripSkillFrontmatter } from "./agent.ts";
|
|
3
|
-
|
|
4
|
-
describe("ui/agent", () => {
|
|
5
|
-
test("stripSkillFrontmatter removes frontmatter and keeps the skill body", () => {
|
|
6
|
-
const content = [
|
|
7
|
-
"---",
|
|
8
|
-
"name: code-review",
|
|
9
|
-
'description: "Review code for issues"',
|
|
10
|
-
"---",
|
|
11
|
-
"# Review Checklist",
|
|
12
|
-
"- Find bugs",
|
|
13
|
-
"",
|
|
14
|
-
].join("\n");
|
|
15
|
-
|
|
16
|
-
expect(stripSkillFrontmatter(content)).toBe(
|
|
17
|
-
"# Review Checklist\n- Find bugs\n",
|
|
18
|
-
);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("stripSkillFrontmatter leaves content without frontmatter unchanged", () => {
|
|
22
|
-
expect(stripSkillFrontmatter("# Skill\nUse this carefully\n")).toBe(
|
|
23
|
-
"# Skill\nUse this carefully\n",
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("isEmptyUserContent returns true for empty text-only content", () => {
|
|
28
|
-
expect(isEmptyUserContent(" \n\t")).toBe(true);
|
|
29
|
-
expect(
|
|
30
|
-
isEmptyUserContent([
|
|
31
|
-
{ type: "text", text: " " },
|
|
32
|
-
{ type: "text", text: "\n" },
|
|
33
|
-
]),
|
|
34
|
-
).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("isEmptyUserContent returns false when multipart content includes an image", () => {
|
|
38
|
-
expect(
|
|
39
|
-
isEmptyUserContent([
|
|
40
|
-
{ type: "text", text: " " },
|
|
41
|
-
{
|
|
42
|
-
type: "image",
|
|
43
|
-
data: Buffer.from("fake-png-data").toString("base64"),
|
|
44
|
-
mimeType: "image/png",
|
|
45
|
-
},
|
|
46
|
-
]),
|
|
47
|
-
).toBe(false);
|
|
48
|
-
});
|
|
49
|
-
});
|