mini-coder 0.5.11 → 0.5.13
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 +107 -0
- package/PROGRESS.md +4 -0
- package/README.md +66 -21
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-baseline.sh +15 -0
- package/benchmark-loop.sh +19 -0
- package/bun.lock +265 -90
- package/package.json +8 -7
- package/skills-lock.json +15 -0
- package/src/agent.ts +20 -0
- package/src/cli.ts +2 -1
- package/src/headless.ts +152 -38
- package/src/index.ts +107 -143
- package/src/input.ts +13 -1
- package/src/mcp.ts +609 -0
- package/src/prompt.ts +10 -12
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +218 -22
- package/src/shared.ts +39 -0
- package/src/skills.ts +12 -3
- package/src/submit.ts +20 -25
- package/src/text.ts +71 -0
- package/src/theme.ts +186 -3
- package/src/tool-common.ts +93 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +1001 -0
- package/src/tools.ts +190 -995
- package/src/ui/agent.ts +206 -110
- package/src/ui/commands.test.ts +489 -17
- package/src/ui/commands.ts +231 -55
- package/src/ui/conversation.test.ts +585 -0
- package/src/ui/conversation.ts +878 -455
- package/src/ui/help.ts +27 -11
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +422 -185
- package/src/plugins.ts +0 -183
package/src/ui.ts
CHANGED
|
@@ -22,21 +22,12 @@ 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
|
+
buildConversationLayoutSnapshot,
|
|
40
31
|
CONVERSATION_GAP,
|
|
41
32
|
resetConversationRenderCache,
|
|
42
33
|
} from "./ui/conversation.ts";
|
|
@@ -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";
|
|
@@ -82,8 +74,14 @@ const TERMINAL_TITLE_FRAMES = [
|
|
|
82
74
|
"[o=---]",
|
|
83
75
|
] as const;
|
|
84
76
|
|
|
85
|
-
/**
|
|
86
|
-
const
|
|
77
|
+
/** Overscan applied above and below the viewport for virtualized log rendering. */
|
|
78
|
+
const CONVERSATION_OVERSCAN_ROWS = 6;
|
|
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;
|
|
87
85
|
|
|
88
86
|
/** Centralized interactive quit rules for keypresses and submitted input. */
|
|
89
87
|
const QUIT_RULES: Readonly<{
|
|
@@ -99,6 +97,14 @@ const QUIT_RULES: Readonly<{
|
|
|
99
97
|
keysWhenEmptyInput: new Set(["ctrl+d"]),
|
|
100
98
|
};
|
|
101
99
|
|
|
100
|
+
/** Keypresses that still bubble while the queued steering draft is readonly. */
|
|
101
|
+
const READONLY_INPUT_BUBBLE_KEYS = new Set([
|
|
102
|
+
"ctrl+c",
|
|
103
|
+
"ctrl+d",
|
|
104
|
+
"ctrl+z",
|
|
105
|
+
"escape",
|
|
106
|
+
]);
|
|
107
|
+
|
|
102
108
|
// ---------------------------------------------------------------------------
|
|
103
109
|
// UI state (module-scoped, not in AppState)
|
|
104
110
|
// ---------------------------------------------------------------------------
|
|
@@ -109,12 +115,12 @@ let scrollOffset = 0;
|
|
|
109
115
|
/** Whether the log auto-scrolls to the bottom. */
|
|
110
116
|
let stickToBottom = true;
|
|
111
117
|
|
|
112
|
-
/** First visible committed message when older history is chunked. */
|
|
113
|
-
let visibleConversationStart = 0;
|
|
114
|
-
|
|
115
118
|
/** Current text in the input area. */
|
|
116
119
|
let inputValue = "";
|
|
117
120
|
|
|
121
|
+
/** Whether the visible input draft is temporarily readonly. */
|
|
122
|
+
let inputReadOnly = false;
|
|
123
|
+
|
|
118
124
|
/** Whether the text input is focused. */
|
|
119
125
|
let inputFocused = true;
|
|
120
126
|
|
|
@@ -168,12 +174,13 @@ export function isQuitKey(key: string, input: string): boolean {
|
|
|
168
174
|
export function resetUiState(): void {
|
|
169
175
|
scrollOffset = 0;
|
|
170
176
|
stickToBottom = true;
|
|
171
|
-
visibleConversationStart = 0;
|
|
172
177
|
inputValue = "";
|
|
178
|
+
inputReadOnly = false;
|
|
173
179
|
inputFocused = true;
|
|
174
180
|
dividerTick = 0;
|
|
175
181
|
stopDividerAnimation();
|
|
176
|
-
|
|
182
|
+
renderScheduler.reset();
|
|
183
|
+
agentController.reset();
|
|
177
184
|
resetConversationRenderCache();
|
|
178
185
|
activeOverlay = null;
|
|
179
186
|
stdinWasRaw = false;
|
|
@@ -186,28 +193,14 @@ export function resetUiState(): void {
|
|
|
186
193
|
// Terminal title
|
|
187
194
|
// ---------------------------------------------------------------------------
|
|
188
195
|
|
|
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
196
|
function getUserTerminalTitleText(
|
|
195
197
|
content: Extract<AppState["messages"][number], { role: "user" }>["content"],
|
|
196
198
|
): string | null {
|
|
197
199
|
if (typeof content === "string") {
|
|
198
|
-
return
|
|
200
|
+
return collapseWhitespaceToNull(content);
|
|
199
201
|
}
|
|
200
202
|
|
|
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);
|
|
203
|
+
return collapseWhitespaceToNull(joinTextBlocks(content));
|
|
211
204
|
}
|
|
212
205
|
|
|
213
206
|
function getAssistantTerminalTitleText(
|
|
@@ -216,24 +209,7 @@ function getAssistantTerminalTitleText(
|
|
|
216
209
|
{ role: "assistant" }
|
|
217
210
|
>["content"],
|
|
218
211
|
): 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);
|
|
212
|
+
return collapseWhitespaceToNull(joinTextBlocks(content));
|
|
237
213
|
}
|
|
238
214
|
|
|
239
215
|
function truncateTerminalTitleTail(text: string): string {
|
|
@@ -316,9 +292,168 @@ function invalidateTerminalTitleCache(): void {
|
|
|
316
292
|
lastTerminalTitle = null;
|
|
317
293
|
}
|
|
318
294
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
295
|
+
interface RenderSchedulerRuntime {
|
|
296
|
+
/** Render-time clock used for throttling in tests and production. */
|
|
297
|
+
now?: () => number;
|
|
298
|
+
/** Microtask queue used for coalescing normal-priority flushes. */
|
|
299
|
+
queueMicrotask?: (callback: () => void) => void;
|
|
300
|
+
/** Timer primitive used for deferred stream/animation flushes. */
|
|
301
|
+
setTimeout?: typeof setTimeout;
|
|
302
|
+
/** Timer cancellation primitive paired with `setTimeout`. */
|
|
303
|
+
clearTimeout?: typeof clearTimeout;
|
|
304
|
+
/** Optional hook to sync the terminal title before rendering. */
|
|
305
|
+
syncTitle?: () => void;
|
|
306
|
+
/** cel render primitive invoked for each scheduled flush. */
|
|
307
|
+
render: () => void;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface RenderScheduler {
|
|
311
|
+
/** Schedule a render with the given priority. */
|
|
312
|
+
requestRender: (priority?: UiRenderPriority) => void;
|
|
313
|
+
/** Cancel any pending render work and reset throttling state. */
|
|
314
|
+
reset: () => void;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getRenderPriorityRank(priority: UiRenderPriority): number {
|
|
318
|
+
switch (priority) {
|
|
319
|
+
case "immediate":
|
|
320
|
+
return 3;
|
|
321
|
+
case "normal":
|
|
322
|
+
return 2;
|
|
323
|
+
case "stream":
|
|
324
|
+
return 1;
|
|
325
|
+
case "animation":
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getRenderMinInterval(priority: UiRenderPriority): number {
|
|
331
|
+
switch (priority) {
|
|
332
|
+
case "stream":
|
|
333
|
+
return STREAM_RENDER_MIN_INTERVAL_MS;
|
|
334
|
+
case "animation":
|
|
335
|
+
return ANIMATION_RENDER_MIN_INTERVAL_MS;
|
|
336
|
+
default:
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function chooseHigherRenderPriority(
|
|
342
|
+
left: UiRenderPriority | null,
|
|
343
|
+
right: UiRenderPriority,
|
|
344
|
+
): UiRenderPriority {
|
|
345
|
+
if (!left) {
|
|
346
|
+
return right;
|
|
347
|
+
}
|
|
348
|
+
return getRenderPriorityRank(left) >= getRenderPriorityRank(right)
|
|
349
|
+
? left
|
|
350
|
+
: right;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create the coalescing render scheduler used by the terminal UI.
|
|
355
|
+
*
|
|
356
|
+
* @param runtime - Render/timer hooks for production code and focused tests.
|
|
357
|
+
* @returns A scheduler that coalesces repeated render requests by priority.
|
|
358
|
+
*/
|
|
359
|
+
export function createRenderScheduler(
|
|
360
|
+
runtime: RenderSchedulerRuntime,
|
|
361
|
+
): RenderScheduler {
|
|
362
|
+
let pendingPriority: UiRenderPriority | null = null;
|
|
363
|
+
let microtaskQueued = false;
|
|
364
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
365
|
+
let lastRenderTime = 0;
|
|
366
|
+
|
|
367
|
+
const now = (): number => runtime.now?.() ?? Date.now();
|
|
368
|
+
const enqueueMicrotask = runtime.queueMicrotask ?? queueMicrotask;
|
|
369
|
+
const startTimer = runtime.setTimeout ?? setTimeout;
|
|
370
|
+
const stopTimer = runtime.clearTimeout ?? clearTimeout;
|
|
371
|
+
|
|
372
|
+
const clearRenderTimer = (): void => {
|
|
373
|
+
if (timer) {
|
|
374
|
+
stopTimer(timer);
|
|
375
|
+
timer = null;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const flushRender = (): void => {
|
|
380
|
+
pendingPriority = null;
|
|
381
|
+
microtaskQueued = false;
|
|
382
|
+
clearRenderTimer();
|
|
383
|
+
runtime.syncTitle?.();
|
|
384
|
+
runtime.render();
|
|
385
|
+
lastRenderTime = now();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const schedulePendingRender = (): void => {
|
|
389
|
+
if (!pendingPriority) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (pendingPriority === "immediate") {
|
|
394
|
+
flushRender();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (pendingPriority === "normal") {
|
|
399
|
+
clearRenderTimer();
|
|
400
|
+
if (microtaskQueued) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
microtaskQueued = true;
|
|
404
|
+
enqueueMicrotask(() => {
|
|
405
|
+
microtaskQueued = false;
|
|
406
|
+
if (!pendingPriority) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
flushRender();
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (microtaskQueued) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const delay = Math.max(
|
|
419
|
+
0,
|
|
420
|
+
lastRenderTime + getRenderMinInterval(pendingPriority) - now(),
|
|
421
|
+
);
|
|
422
|
+
clearRenderTimer();
|
|
423
|
+
timer = startTimer(() => {
|
|
424
|
+
timer = null;
|
|
425
|
+
if (!pendingPriority) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
flushRender();
|
|
429
|
+
}, delay);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
requestRender: (priority = "normal") => {
|
|
434
|
+
pendingPriority = chooseHigherRenderPriority(pendingPriority, priority);
|
|
435
|
+
schedulePendingRender();
|
|
436
|
+
},
|
|
437
|
+
reset: () => {
|
|
438
|
+
pendingPriority = null;
|
|
439
|
+
microtaskQueued = false;
|
|
440
|
+
clearRenderTimer();
|
|
441
|
+
lastRenderTime = 0;
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const renderScheduler = createRenderScheduler({
|
|
447
|
+
render: () => {
|
|
448
|
+
cel.render();
|
|
449
|
+
},
|
|
450
|
+
syncTitle: () => {
|
|
451
|
+
syncTerminalTitle(titleState);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
function requestRender(priority: UiRenderPriority = "normal"): void {
|
|
456
|
+
renderScheduler.requestRender(priority);
|
|
322
457
|
}
|
|
323
458
|
|
|
324
459
|
// ---------------------------------------------------------------------------
|
|
@@ -331,7 +466,7 @@ function startDividerAnimation(): void {
|
|
|
331
466
|
dividerTick = 0;
|
|
332
467
|
dividerTimer = setInterval(() => {
|
|
333
468
|
dividerTick++;
|
|
334
|
-
requestRender();
|
|
469
|
+
requestRender("animation");
|
|
335
470
|
}, DIVIDER_FRAME_MS);
|
|
336
471
|
}
|
|
337
472
|
|
|
@@ -389,62 +524,151 @@ function renderDivider(state: AppState, width: number): Node {
|
|
|
389
524
|
// Conversation log
|
|
390
525
|
// ---------------------------------------------------------------------------
|
|
391
526
|
|
|
392
|
-
function
|
|
393
|
-
|
|
527
|
+
function measureConversationSliceHeight(
|
|
528
|
+
prefixHeights: readonly number[],
|
|
529
|
+
startIndex: number,
|
|
530
|
+
endIndex: number,
|
|
531
|
+
): number {
|
|
532
|
+
const count = endIndex - startIndex;
|
|
533
|
+
if (count <= 0) {
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
return (
|
|
537
|
+
prefixHeights[endIndex]! -
|
|
538
|
+
prefixHeights[startIndex]! +
|
|
539
|
+
CONVERSATION_GAP * Math.max(0, count - 1)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function getConversationItemTop(
|
|
544
|
+
prefixHeights: readonly number[],
|
|
545
|
+
index: number,
|
|
546
|
+
): number {
|
|
547
|
+
return prefixHeights[index]! + CONVERSATION_GAP * index;
|
|
394
548
|
}
|
|
395
549
|
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
|
|
550
|
+
function findConversationStartIndex(
|
|
551
|
+
itemHeights: readonly { height: number }[],
|
|
552
|
+
prefixHeights: readonly number[],
|
|
553
|
+
offset: number,
|
|
554
|
+
): number {
|
|
555
|
+
let low = 0;
|
|
556
|
+
let high = itemHeights.length;
|
|
557
|
+
|
|
558
|
+
while (low < high) {
|
|
559
|
+
const mid = Math.floor((low + high) / 2);
|
|
560
|
+
const bottom =
|
|
561
|
+
getConversationItemTop(prefixHeights, mid) + itemHeights[mid]!.height;
|
|
562
|
+
if (bottom > offset) {
|
|
563
|
+
high = mid;
|
|
564
|
+
} else {
|
|
565
|
+
low = mid + 1;
|
|
566
|
+
}
|
|
399
567
|
}
|
|
400
568
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
569
|
+
return Math.min(low, Math.max(0, itemHeights.length - 1));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function findConversationEndIndex(
|
|
573
|
+
items: readonly { height: number }[],
|
|
574
|
+
prefixHeights: readonly number[],
|
|
575
|
+
offset: number,
|
|
576
|
+
): number {
|
|
577
|
+
let low = 0;
|
|
578
|
+
let high = items.length;
|
|
579
|
+
|
|
580
|
+
while (low < high) {
|
|
581
|
+
const mid = Math.floor((low + high) / 2);
|
|
582
|
+
if (getConversationItemTop(prefixHeights, mid) < offset) {
|
|
583
|
+
low = mid + 1;
|
|
584
|
+
} else {
|
|
585
|
+
high = mid;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return low;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderConversationSpacer(height: number): Node | null {
|
|
593
|
+
const rows = Math.max(0, Math.floor(height));
|
|
594
|
+
if (rows === 0) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const paddingY = Math.floor(rows / 2);
|
|
599
|
+
if (rows % 2 === 0) {
|
|
600
|
+
return VStack({ padding: { y: paddingY } }, []);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return VStack({ padding: { y: paddingY } }, [Text("")]);
|
|
406
604
|
}
|
|
407
605
|
|
|
408
606
|
/** Build the full conversation log as an array of nodes. */
|
|
409
607
|
export function buildConversationLog(
|
|
410
608
|
state: AppState,
|
|
411
609
|
width = Number.POSITIVE_INFINITY,
|
|
610
|
+
viewportHeight = Number.POSITIVE_INFINITY,
|
|
412
611
|
): Node[] {
|
|
413
|
-
|
|
612
|
+
const layout = buildConversationLayoutSnapshot(
|
|
414
613
|
state,
|
|
415
|
-
getStreamingConversationState(),
|
|
416
|
-
getVisibleConversationStart(state.messages.length),
|
|
614
|
+
agentController.getStreamingConversationState(),
|
|
417
615
|
width,
|
|
418
616
|
);
|
|
419
|
-
}
|
|
617
|
+
const { items, prefixHeights } = layout;
|
|
618
|
+
if (!Number.isFinite(viewportHeight) || items.length <= 1) {
|
|
619
|
+
return items.map((item) => item.node);
|
|
620
|
+
}
|
|
420
621
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
): number {
|
|
426
|
-
return measureContentHeight(
|
|
427
|
-
VStack(
|
|
428
|
-
{ gap: CONVERSATION_GAP },
|
|
429
|
-
buildConversationLogNodes(
|
|
430
|
-
state,
|
|
431
|
-
getStreamingConversationState(),
|
|
432
|
-
startIndex,
|
|
433
|
-
width,
|
|
434
|
-
),
|
|
435
|
-
),
|
|
436
|
-
{ width: Math.max(1, width) },
|
|
622
|
+
const totalHeight = measureConversationSliceHeight(
|
|
623
|
+
prefixHeights,
|
|
624
|
+
0,
|
|
625
|
+
items.length,
|
|
437
626
|
);
|
|
438
|
-
|
|
627
|
+
const visibleHeight = Math.max(1, Math.floor(viewportHeight));
|
|
628
|
+
const maxOffset = Math.max(0, totalHeight - visibleHeight);
|
|
629
|
+
const effectiveScrollOffset = stickToBottom
|
|
630
|
+
? maxOffset
|
|
631
|
+
: Math.max(0, Math.min(scrollOffset, maxOffset));
|
|
632
|
+
const paddedTop = Math.max(
|
|
633
|
+
0,
|
|
634
|
+
effectiveScrollOffset - CONVERSATION_OVERSCAN_ROWS,
|
|
635
|
+
);
|
|
636
|
+
const paddedBottom = Math.min(
|
|
637
|
+
totalHeight,
|
|
638
|
+
effectiveScrollOffset + visibleHeight + CONVERSATION_OVERSCAN_ROWS,
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
let startIndex = findConversationStartIndex(items, prefixHeights, paddedTop);
|
|
642
|
+
let endIndex = findConversationEndIndex(items, prefixHeights, paddedBottom);
|
|
643
|
+
if (endIndex <= startIndex) {
|
|
644
|
+
endIndex = Math.min(items.length, startIndex + 1);
|
|
645
|
+
}
|
|
646
|
+
startIndex = Math.min(startIndex, Math.max(0, endIndex - 1));
|
|
439
647
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
648
|
+
const topSpacerHeight = measureConversationSliceHeight(
|
|
649
|
+
prefixHeights,
|
|
650
|
+
0,
|
|
651
|
+
startIndex,
|
|
652
|
+
);
|
|
653
|
+
const bottomSpacerHeight = measureConversationSliceHeight(
|
|
654
|
+
prefixHeights,
|
|
655
|
+
endIndex,
|
|
656
|
+
items.length,
|
|
657
|
+
);
|
|
445
658
|
|
|
446
|
-
|
|
447
|
-
|
|
659
|
+
const nodes: Node[] = [];
|
|
660
|
+
const topSpacer = renderConversationSpacer(topSpacerHeight);
|
|
661
|
+
if (topSpacer) {
|
|
662
|
+
nodes.push(topSpacer);
|
|
663
|
+
}
|
|
664
|
+
for (let index = startIndex; index < endIndex; index++) {
|
|
665
|
+
nodes.push(items[index]!.node);
|
|
666
|
+
}
|
|
667
|
+
const bottomSpacer = renderConversationSpacer(bottomSpacerHeight);
|
|
668
|
+
if (bottomSpacer) {
|
|
669
|
+
nodes.push(bottomSpacer);
|
|
670
|
+
}
|
|
671
|
+
return nodes;
|
|
448
672
|
}
|
|
449
673
|
|
|
450
674
|
// ---------------------------------------------------------------------------
|
|
@@ -455,14 +679,12 @@ function prependConversationChunk(state: AppState, width: number): void {
|
|
|
455
679
|
function openOverlay(overlay: ActiveOverlay): void {
|
|
456
680
|
activeOverlay = overlay;
|
|
457
681
|
inputFocused = false;
|
|
458
|
-
requestRender();
|
|
459
682
|
}
|
|
460
683
|
|
|
461
684
|
/** Dismiss the active overlay and return focus to the input. */
|
|
462
685
|
function dismissOverlay(): void {
|
|
463
686
|
activeOverlay = null;
|
|
464
687
|
inputFocused = true;
|
|
465
|
-
requestRender();
|
|
466
688
|
}
|
|
467
689
|
|
|
468
690
|
function openPathAutocompleteOverlay(state: AppState): void {
|
|
@@ -485,6 +707,13 @@ function openPathAutocompleteOverlay(state: AppState): void {
|
|
|
485
707
|
inputValue = value;
|
|
486
708
|
dismissOverlay();
|
|
487
709
|
},
|
|
710
|
+
onKeyPress: (key) => {
|
|
711
|
+
if (key === "escape") {
|
|
712
|
+
dismissOverlay();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
},
|
|
488
717
|
onBlur: dismissOverlay,
|
|
489
718
|
});
|
|
490
719
|
|
|
@@ -500,7 +729,6 @@ function handleTabKeyPress(state: AppState): void {
|
|
|
500
729
|
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
501
730
|
if (completedInput) {
|
|
502
731
|
inputValue = completedInput;
|
|
503
|
-
requestRender();
|
|
504
732
|
return;
|
|
505
733
|
}
|
|
506
734
|
|
|
@@ -539,31 +767,41 @@ export function createInputController(state: AppState): InputController {
|
|
|
539
767
|
|
|
540
768
|
return {
|
|
541
769
|
onChange: (value) => {
|
|
770
|
+
if (inputReadOnly || inputValue === value) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
542
773
|
inputValue = value;
|
|
543
|
-
requestRender();
|
|
544
774
|
},
|
|
545
775
|
onFocus: () => {
|
|
546
776
|
inputFocused = true;
|
|
547
|
-
requestRender();
|
|
548
777
|
},
|
|
549
778
|
onBlur: () => {
|
|
550
779
|
inputFocused = false;
|
|
551
|
-
requestRender();
|
|
552
780
|
},
|
|
553
781
|
onKeyPress: (key) => {
|
|
782
|
+
if (inputReadOnly) {
|
|
783
|
+
if (READONLY_INPUT_BUBBLE_KEYS.has(key)) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
554
789
|
if (key === "enter") {
|
|
555
790
|
const raw = inputValue;
|
|
556
791
|
|
|
557
792
|
if (isQuitInput(raw)) {
|
|
558
793
|
inputValue = "";
|
|
559
|
-
requestRender();
|
|
560
794
|
requestGracefulExit(state);
|
|
561
795
|
return false;
|
|
562
796
|
}
|
|
563
797
|
|
|
564
|
-
|
|
565
|
-
requestRender();
|
|
798
|
+
const queuedInputCount = state.queuedUserMessages.length;
|
|
566
799
|
handleInput(raw, state);
|
|
800
|
+
if (state.queuedUserMessages.length > queuedInputCount) {
|
|
801
|
+
inputReadOnly = true;
|
|
802
|
+
} else {
|
|
803
|
+
inputValue = "";
|
|
804
|
+
}
|
|
567
805
|
return false;
|
|
568
806
|
}
|
|
569
807
|
if (key === "tab") {
|
|
@@ -641,63 +879,25 @@ function scrollConversationToBottom(): void {
|
|
|
641
879
|
stickToBottom = true;
|
|
642
880
|
}
|
|
643
881
|
|
|
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
|
-
}
|
|
882
|
+
const uiRuntimeHelpers = createUiRuntimeHelpers({
|
|
883
|
+
requestRender,
|
|
884
|
+
scrollConversationToBottom,
|
|
885
|
+
});
|
|
689
886
|
|
|
690
887
|
/** Command controller bound to the module-scoped UI runtime hooks. */
|
|
691
888
|
const commandController = createCommandController({
|
|
692
889
|
openOverlay,
|
|
693
890
|
dismissOverlay,
|
|
694
891
|
setInputValue: (value) => {
|
|
892
|
+
if (inputReadOnly || inputValue === value) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
695
895
|
inputValue = value;
|
|
696
896
|
},
|
|
697
|
-
appendInfoMessage,
|
|
698
|
-
appendTodoMessage,
|
|
897
|
+
appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
|
|
898
|
+
appendTodoMessage: uiRuntimeHelpers.appendTodoMessage,
|
|
699
899
|
scrollConversationToBottom,
|
|
700
|
-
|
|
900
|
+
requestRender,
|
|
701
901
|
reloadPromptContext,
|
|
702
902
|
openInBrowser,
|
|
703
903
|
});
|
|
@@ -708,11 +908,19 @@ const commandController = createCommandController({
|
|
|
708
908
|
|
|
709
909
|
/** Agent controller bound to the module-scoped UI runtime hooks. */
|
|
710
910
|
const agentController = createUiAgentController({
|
|
711
|
-
appendInfoMessage,
|
|
911
|
+
appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
|
|
712
912
|
handleCommand: (command, state) =>
|
|
713
913
|
commandController.handleCommand(command, state),
|
|
714
|
-
|
|
914
|
+
requestRender,
|
|
715
915
|
scrollConversationToBottom,
|
|
916
|
+
clearQueuedInputDraft: () => {
|
|
917
|
+
if (!inputReadOnly) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
inputReadOnly = false;
|
|
921
|
+
inputValue = "";
|
|
922
|
+
inputFocused = true;
|
|
923
|
+
},
|
|
716
924
|
startDividerAnimation,
|
|
717
925
|
stopDividerAnimation,
|
|
718
926
|
});
|
|
@@ -807,7 +1015,30 @@ export function suspendToBackground(
|
|
|
807
1015
|
// Main
|
|
808
1016
|
// ---------------------------------------------------------------------------
|
|
809
1017
|
|
|
810
|
-
function
|
|
1018
|
+
function getConversationViewportHeight(
|
|
1019
|
+
cols: number,
|
|
1020
|
+
rows: number,
|
|
1021
|
+
divider: Node,
|
|
1022
|
+
input: Node,
|
|
1023
|
+
statusBar: Node,
|
|
1024
|
+
): number {
|
|
1025
|
+
if (!Number.isFinite(rows)) {
|
|
1026
|
+
return Number.POSITIVE_INFINITY;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const width = Math.max(1, Math.floor(cols));
|
|
1030
|
+
const reservedHeight =
|
|
1031
|
+
measureContentHeight(divider, { width }) +
|
|
1032
|
+
measureContentHeight(input, { width }) +
|
|
1033
|
+
measureContentHeight(statusBar, { width });
|
|
1034
|
+
return Math.max(1, Math.floor(rows) - reservedHeight);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function renderConversationLog(
|
|
1038
|
+
state: AppState,
|
|
1039
|
+
width: number,
|
|
1040
|
+
viewportHeight: number,
|
|
1041
|
+
): Node {
|
|
811
1042
|
return VStack(
|
|
812
1043
|
{
|
|
813
1044
|
flex: 1,
|
|
@@ -816,25 +1047,11 @@ function renderConversationLog(state: AppState, width: number): Node {
|
|
|
816
1047
|
justifyContent: state.messages.length === 0 ? "center" : undefined,
|
|
817
1048
|
scrollOffset: stickToBottom ? Infinity : scrollOffset,
|
|
818
1049
|
onScroll: (offset, maxOffset) => {
|
|
819
|
-
const wasStickToBottom = stickToBottom;
|
|
820
1050
|
scrollOffset = offset;
|
|
821
|
-
|
|
822
|
-
if (wasStickToBottom && offset < maxOffset) {
|
|
823
|
-
visibleConversationStart = getLatestConversationChunkStart(
|
|
824
|
-
state.messages.length,
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
1051
|
stickToBottom = offset >= maxOffset;
|
|
829
|
-
|
|
830
|
-
if (!stickToBottom && offset === 0 && visibleConversationStart > 0) {
|
|
831
|
-
prependConversationChunk(state, width);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
requestRender();
|
|
835
1052
|
},
|
|
836
1053
|
},
|
|
837
|
-
buildConversationLog(state, width),
|
|
1054
|
+
buildConversationLog(state, width, viewportHeight),
|
|
838
1055
|
);
|
|
839
1056
|
}
|
|
840
1057
|
|
|
@@ -847,6 +1064,8 @@ function renderConversationLog(state: AppState, width: number): Node {
|
|
|
847
1064
|
* @param state - Application state.
|
|
848
1065
|
* @param cols - Current terminal width in columns.
|
|
849
1066
|
* @param inputController - Stable callbacks for the controlled TextInput.
|
|
1067
|
+
* @param onSuspend - Optional suspend handler for Ctrl+Z.
|
|
1068
|
+
* @param rows - Current terminal height in rows.
|
|
850
1069
|
* @returns The base layout node.
|
|
851
1070
|
*/
|
|
852
1071
|
export function renderBaseLayout(
|
|
@@ -854,6 +1073,7 @@ export function renderBaseLayout(
|
|
|
854
1073
|
cols: number,
|
|
855
1074
|
inputController: InputController,
|
|
856
1075
|
onSuspend?: () => void,
|
|
1076
|
+
rows = Number.POSITIVE_INFINITY,
|
|
857
1077
|
): Node {
|
|
858
1078
|
titleState = state;
|
|
859
1079
|
titleViewportActive = true;
|
|
@@ -861,6 +1081,17 @@ export function renderBaseLayout(
|
|
|
861
1081
|
syncTerminalTitle(state);
|
|
862
1082
|
}
|
|
863
1083
|
|
|
1084
|
+
const divider = renderDivider(state, cols);
|
|
1085
|
+
const input = renderInputArea(state.theme, inputController);
|
|
1086
|
+
const statusBar = renderStatusBar(state, cols);
|
|
1087
|
+
const conversationViewportHeight = getConversationViewportHeight(
|
|
1088
|
+
cols,
|
|
1089
|
+
rows,
|
|
1090
|
+
divider,
|
|
1091
|
+
input,
|
|
1092
|
+
statusBar,
|
|
1093
|
+
);
|
|
1094
|
+
|
|
864
1095
|
return VStack(
|
|
865
1096
|
{
|
|
866
1097
|
height: "100%",
|
|
@@ -891,16 +1122,16 @@ export function renderBaseLayout(
|
|
|
891
1122
|
},
|
|
892
1123
|
[
|
|
893
1124
|
// ── Conversation log ──
|
|
894
|
-
renderConversationLog(state, cols),
|
|
1125
|
+
renderConversationLog(state, cols, conversationViewportHeight),
|
|
895
1126
|
|
|
896
1127
|
// ── Animated divider (pulse when agent is working) ──
|
|
897
|
-
|
|
1128
|
+
divider,
|
|
898
1129
|
|
|
899
1130
|
// ── Input area ──
|
|
900
|
-
|
|
1131
|
+
input,
|
|
901
1132
|
|
|
902
1133
|
// ── Status bar (1 line) ──
|
|
903
|
-
|
|
1134
|
+
statusBar,
|
|
904
1135
|
],
|
|
905
1136
|
);
|
|
906
1137
|
}
|
|
@@ -923,16 +1154,22 @@ export function startUI(state: AppState): void {
|
|
|
923
1154
|
|
|
924
1155
|
cel.viewport(() => {
|
|
925
1156
|
const cols = terminal.columns;
|
|
926
|
-
const base = renderBaseLayout(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1157
|
+
const base = renderBaseLayout(
|
|
1158
|
+
state,
|
|
1159
|
+
cols,
|
|
1160
|
+
inputController,
|
|
1161
|
+
() => {
|
|
1162
|
+
suspendToBackground(() => {
|
|
1163
|
+
resumeTerminalUi();
|
|
1164
|
+
cel._getBuffer()?.clear();
|
|
1165
|
+
if (state.running) {
|
|
1166
|
+
startDividerAnimation();
|
|
1167
|
+
}
|
|
1168
|
+
requestRender("immediate");
|
|
1169
|
+
});
|
|
1170
|
+
},
|
|
1171
|
+
terminal.rows,
|
|
1172
|
+
);
|
|
936
1173
|
const overlay = renderActiveOverlay(state);
|
|
937
1174
|
|
|
938
1175
|
if (overlay) {
|
|
@@ -948,6 +1185,6 @@ export function startUI(state: AppState): void {
|
|
|
948
1185
|
|
|
949
1186
|
// Show warnings from custom provider discovery
|
|
950
1187
|
for (const warning of state.startupWarnings) {
|
|
951
|
-
appendInfoMessage(warning, state);
|
|
1188
|
+
uiRuntimeHelpers.appendInfoMessage(warning, state);
|
|
952
1189
|
}
|
|
953
1190
|
}
|