mini-coder 0.5.14 → 0.6.0

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.
Files changed (70) hide show
  1. package/README.md +25 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +237 -1403
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -358
  8. package/src/index.ts +29 -1016
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -284
  11. package/src/session.ts +55 -1306
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -5
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/assistant-output.ts +0 -73
  39. package/src/cli.ts +0 -134
  40. package/src/delegation.ts +0 -238
  41. package/src/errors.ts +0 -15
  42. package/src/git.ts +0 -247
  43. package/src/input.ts +0 -168
  44. package/src/mcp.ts +0 -609
  45. package/src/paths.ts +0 -37
  46. package/src/session-message.ts +0 -385
  47. package/src/settings.ts +0 -449
  48. package/src/skills.ts +0 -271
  49. package/src/submit.ts +0 -376
  50. package/src/text.ts +0 -71
  51. package/src/theme.ts +0 -330
  52. package/src/tool-common.ts +0 -93
  53. package/src/tool-delegate.ts +0 -125
  54. package/src/tool-grep.ts +0 -606
  55. package/src/tool-read.ts +0 -313
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/ui.ts DELETED
@@ -1,1190 +0,0 @@
1
- /**
2
- * Terminal UI for mini-coder.
3
- *
4
- * Owns the cel-tui lifecycle (init/stop), renders the conversation log,
5
- * input area, animated divider, and status bar. Wires user input to the
6
- * agent loop and streams events back to the UI.
7
- *
8
- * @module
9
- */
10
-
11
- import { spawn } from "node:child_process";
12
- import { platform } from "node:os";
13
- import { Select } from "@cel-tui/components";
14
- import {
15
- cel,
16
- HStack,
17
- measureContentHeight,
18
- ProcessTerminal,
19
- Text,
20
- VStack,
21
- } from "@cel-tui/core";
22
- import type { Node } from "@cel-tui/types";
23
- import type { AppState } from "./index.ts";
24
- import { reloadPromptContext, shutdown } from "./index.ts";
25
- import { collapseWhitespaceToNull, joinTextBlocks } from "./text.ts";
26
- import type { Theme } from "./theme.ts";
27
- import { createUiAgentController } from "./ui/agent.ts";
28
- import { createCommandController } from "./ui/commands.ts";
29
- import {
30
- buildConversationLayoutSnapshot,
31
- CONVERSATION_GAP,
32
- resetConversationRenderCache,
33
- } from "./ui/conversation.ts";
34
- import type { InputController } from "./ui/input.ts";
35
- import {
36
- autocompleteInputPath,
37
- findInputPathMatches,
38
- renderInputArea as renderInputAreaNode,
39
- } from "./ui/input.ts";
40
- import {
41
- type ActiveOverlay,
42
- OVERLAY_MAX_VISIBLE,
43
- renderOverlay,
44
- } from "./ui/overlay.ts";
45
- import { createUiRuntimeHelpers, type UiRenderPriority } from "./ui/runtime.ts";
46
- import { renderStatusBar } from "./ui/status.ts";
47
-
48
- export type { InputController } from "./ui/input.ts";
49
-
50
- // ---------------------------------------------------------------------------
51
- // Constants
52
- // ---------------------------------------------------------------------------
53
-
54
- /** Divider animation speed (ms per frame). */
55
- const DIVIDER_FRAME_MS = 60;
56
-
57
- /** Width of the bright pulse segment in the animated divider. */
58
- const PULSE_WIDTH = 5;
59
-
60
- /** Number of trailing words shown in the idle terminal-title preview. */
61
- const TERMINAL_TITLE_WORD_COUNT = 5;
62
-
63
- /** Divider ticks spent on each animated terminal-title scanner frame. */
64
- const TERMINAL_TITLE_TICKS_PER_FRAME = 4;
65
-
66
- /** Frames used for the active terminal-title glow-scanner animation. */
67
- const TERMINAL_TITLE_FRAMES = [
68
- "[=o---]",
69
- "[-=o--]",
70
- "[--=o-]",
71
- "[---=o]",
72
- "[--o=-]",
73
- "[-o=--]",
74
- "[o=---]",
75
- ] as const;
76
-
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;
85
-
86
- /** Centralized interactive quit rules for keypresses and submitted input. */
87
- const QUIT_RULES: Readonly<{
88
- /** Submitted raw inputs that trigger graceful quit. */
89
- inputs: ReadonlySet<string>;
90
- /** Keypresses that always trigger graceful quit. */
91
- keysAlways: ReadonlySet<string>;
92
- /** Keypresses that trigger graceful quit only when input is empty. */
93
- keysWhenEmptyInput: ReadonlySet<string>;
94
- }> = {
95
- inputs: new Set([":q"]),
96
- keysAlways: new Set(["ctrl+c"]),
97
- keysWhenEmptyInput: new Set(["ctrl+d"]),
98
- };
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
-
108
- // ---------------------------------------------------------------------------
109
- // UI state (module-scoped, not in AppState)
110
- // ---------------------------------------------------------------------------
111
-
112
- /** Scroll position for the conversation log. */
113
- let scrollOffset = 0;
114
-
115
- /** Whether the log auto-scrolls to the bottom. */
116
- let stickToBottom = true;
117
-
118
- /** Current text in the input area. */
119
- let inputValue = "";
120
-
121
- /** Whether the visible input draft is temporarily readonly. */
122
- let inputReadOnly = false;
123
-
124
- /** Whether the text input is focused. */
125
- let inputFocused = true;
126
-
127
- /** Animated divider frame counter. */
128
- let dividerTick = 0;
129
-
130
- /** Divider animation timer handle. */
131
- let dividerTimer: ReturnType<typeof setInterval> | null = null;
132
-
133
- /** Whether stdin was already in raw mode before the TUI initialized. */
134
- let stdinWasRaw = false;
135
-
136
- /** Latest application state associated with the active terminal UI. */
137
- let titleState: AppState | null = null;
138
-
139
- /** Whether a cel viewport has rendered for the current UI session. */
140
- let titleViewportActive = false;
141
-
142
- /** Last terminal title written during the current UI session. */
143
- let lastTerminalTitle: string | null = null;
144
-
145
- // ---------------------------------------------------------------------------
146
- // Overlay state
147
- // ---------------------------------------------------------------------------
148
-
149
- /** Active overlay for interactive commands (/model, /effort, etc.). */
150
- let activeOverlay: ActiveOverlay | null = null;
151
-
152
- /** Determine whether a raw input line should trigger a graceful quit. */
153
- export function isQuitInput(raw: string): boolean {
154
- const trimmed = raw.trim();
155
- return QUIT_RULES.inputs.has(trimmed);
156
- }
157
-
158
- /** Determine whether a keypress should trigger a graceful quit. */
159
- export function isQuitKey(key: string, input: string): boolean {
160
- if (QUIT_RULES.keysAlways.has(key)) {
161
- return true;
162
- }
163
- if (input === "" && QUIT_RULES.keysWhenEmptyInput.has(key)) {
164
- return true;
165
- }
166
- return false;
167
- }
168
-
169
- /**
170
- * Reset all module-scoped UI state.
171
- *
172
- * Useful for reinitializing the UI and for keeping tests isolated.
173
- */
174
- export function resetUiState(): void {
175
- scrollOffset = 0;
176
- stickToBottom = true;
177
- inputValue = "";
178
- inputReadOnly = false;
179
- inputFocused = true;
180
- dividerTick = 0;
181
- stopDividerAnimation();
182
- renderScheduler.reset();
183
- agentController.reset();
184
- resetConversationRenderCache();
185
- activeOverlay = null;
186
- stdinWasRaw = false;
187
- titleState = null;
188
- titleViewportActive = false;
189
- lastTerminalTitle = null;
190
- }
191
-
192
- // ---------------------------------------------------------------------------
193
- // Terminal title
194
- // ---------------------------------------------------------------------------
195
-
196
- function getUserTerminalTitleText(
197
- content: Extract<AppState["messages"][number], { role: "user" }>["content"],
198
- ): string | null {
199
- if (typeof content === "string") {
200
- return collapseWhitespaceToNull(content);
201
- }
202
-
203
- return collapseWhitespaceToNull(joinTextBlocks(content));
204
- }
205
-
206
- function getAssistantTerminalTitleText(
207
- content: Extract<
208
- AppState["messages"][number],
209
- { role: "assistant" }
210
- >["content"],
211
- ): string | null {
212
- return collapseWhitespaceToNull(joinTextBlocks(content));
213
- }
214
-
215
- function truncateTerminalTitleTail(text: string): string {
216
- const words = text.split(" ");
217
- if (words.length <= TERMINAL_TITLE_WORD_COUNT) {
218
- return text;
219
- }
220
- return `...${words.slice(-TERMINAL_TITLE_WORD_COUNT).join(" ")}`;
221
- }
222
-
223
- function buildIdleTerminalTitle(state: Pick<AppState, "messages">): string {
224
- for (let index = state.messages.length - 1; index >= 0; index -= 1) {
225
- const message = state.messages[index];
226
- if (!message) {
227
- continue;
228
- }
229
-
230
- let text: string | null = null;
231
- switch (message.role) {
232
- case "user":
233
- text = getUserTerminalTitleText(message.content);
234
- break;
235
- case "assistant":
236
- text = getAssistantTerminalTitleText(message.content);
237
- break;
238
- case "toolResult":
239
- case "ui":
240
- break;
241
- }
242
-
243
- if (text) {
244
- return `mc - ${truncateTerminalTitleTail(text)}`;
245
- }
246
- }
247
-
248
- return "mc";
249
- }
250
-
251
- /**
252
- * Build the current terminal title from UI state.
253
- *
254
- * Idle titles show a short tail preview from the latest conversational text
255
- * message. Active turns show a stable-width glow scanner.
256
- *
257
- * @param state - Application state needed to derive the title.
258
- * @param animationTick - Divider animation tick used to pick the scanner frame.
259
- * @returns The terminal title text to write via cel-tui.
260
- */
261
- export function buildTerminalTitle(
262
- state: Pick<AppState, "messages" | "running">,
263
- animationTick = dividerTick,
264
- ): string {
265
- if (!state.running) {
266
- return buildIdleTerminalTitle(state);
267
- }
268
-
269
- const frameIndex =
270
- Math.floor(animationTick / TERMINAL_TITLE_TICKS_PER_FRAME) %
271
- TERMINAL_TITLE_FRAMES.length;
272
- return `mc - ${TERMINAL_TITLE_FRAMES[frameIndex]}`;
273
- }
274
-
275
- function syncTerminalTitle(
276
- state: Pick<AppState, "messages" | "running"> | null,
277
- ): void {
278
- if (!state || !titleViewportActive) {
279
- return;
280
- }
281
-
282
- const title = buildTerminalTitle(state);
283
- if (title === lastTerminalTitle) {
284
- return;
285
- }
286
-
287
- cel.setTitle(title);
288
- lastTerminalTitle = title;
289
- }
290
-
291
- function invalidateTerminalTitleCache(): void {
292
- lastTerminalTitle = null;
293
- }
294
-
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);
457
- }
458
-
459
- // ---------------------------------------------------------------------------
460
- // Divider animation
461
- // ---------------------------------------------------------------------------
462
-
463
- /** Start the scanning pulse animation on the divider. */
464
- function startDividerAnimation(): void {
465
- stopDividerAnimation();
466
- dividerTick = 0;
467
- dividerTimer = setInterval(() => {
468
- dividerTick++;
469
- requestRender("animation");
470
- }, DIVIDER_FRAME_MS);
471
- }
472
-
473
- /** Stop the divider animation. */
474
- function stopDividerAnimation(): void {
475
- if (dividerTimer) {
476
- clearInterval(dividerTimer);
477
- dividerTimer = null;
478
- }
479
- }
480
-
481
- /**
482
- * Render the animated divider.
483
- *
484
- * When the agent is working, a bright segment sweeps across the dimmed
485
- * line. When idle, it's a static dimmed line.
486
- */
487
- function renderDivider(state: AppState, width: number): Node {
488
- if (!state.running) {
489
- return Text("─", { repeat: "fill", fgColor: state.theme.divider });
490
- }
491
-
492
- const total = Math.max(width, 1);
493
- const pos = dividerTick % (total + PULSE_WIDTH);
494
- const pulseStart = Math.max(0, pos - PULSE_WIDTH);
495
- const pulseEnd = Math.min(pos, total);
496
- const pulseLen = pulseEnd - pulseStart;
497
- const beforeLen = pulseStart;
498
- const afterLen = total - pulseEnd;
499
-
500
- const segments: Node[] = [];
501
- if (beforeLen > 0) {
502
- segments.push(
503
- Text("─", { repeat: beforeLen, fgColor: state.theme.divider }),
504
- );
505
- }
506
- if (pulseLen > 0) {
507
- segments.push(
508
- Text("═", {
509
- repeat: pulseLen,
510
- fgColor: state.theme.dividerPulse,
511
- }),
512
- );
513
- }
514
- if (afterLen > 0) {
515
- segments.push(
516
- Text("─", { repeat: afterLen, fgColor: state.theme.divider }),
517
- );
518
- }
519
-
520
- return HStack({ height: 1 }, segments);
521
- }
522
-
523
- // ---------------------------------------------------------------------------
524
- // Conversation log
525
- // ---------------------------------------------------------------------------
526
-
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;
548
- }
549
-
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
- }
567
- }
568
-
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("")]);
604
- }
605
-
606
- /** Build the full conversation log as an array of nodes. */
607
- export function buildConversationLog(
608
- state: AppState,
609
- width = Number.POSITIVE_INFINITY,
610
- viewportHeight = Number.POSITIVE_INFINITY,
611
- ): Node[] {
612
- const layout = buildConversationLayoutSnapshot(
613
- state,
614
- agentController.getStreamingConversationState(),
615
- width,
616
- );
617
- const { items, prefixHeights } = layout;
618
- if (!Number.isFinite(viewportHeight) || items.length <= 1) {
619
- return items.map((item) => item.node);
620
- }
621
-
622
- const totalHeight = measureConversationSliceHeight(
623
- prefixHeights,
624
- 0,
625
- items.length,
626
- );
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));
647
-
648
- const topSpacerHeight = measureConversationSliceHeight(
649
- prefixHeights,
650
- 0,
651
- startIndex,
652
- );
653
- const bottomSpacerHeight = measureConversationSliceHeight(
654
- prefixHeights,
655
- endIndex,
656
- items.length,
657
- );
658
-
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;
672
- }
673
-
674
- // ---------------------------------------------------------------------------
675
- // Overlay rendering
676
- // ---------------------------------------------------------------------------
677
-
678
- /** Open an overlay and move focus away from the input. */
679
- function openOverlay(overlay: ActiveOverlay): void {
680
- activeOverlay = overlay;
681
- inputFocused = false;
682
- }
683
-
684
- /** Dismiss the active overlay and return focus to the input. */
685
- function dismissOverlay(): void {
686
- activeOverlay = null;
687
- inputFocused = true;
688
- }
689
-
690
- function openPathAutocompleteOverlay(state: AppState): void {
691
- const matches = findInputPathMatches(inputValue, state.cwd);
692
- if (matches.length <= 1) {
693
- return;
694
- }
695
-
696
- const select = Select({
697
- items: matches.map((match) => ({
698
- label: match.label,
699
- value: match.value,
700
- filterText: match.label,
701
- })),
702
- maxVisible: OVERLAY_MAX_VISIBLE,
703
- placeholder: "type to filter paths...",
704
- focused: true,
705
- highlightColor: state.theme.accentText,
706
- onSelect: (value) => {
707
- inputValue = value;
708
- dismissOverlay();
709
- },
710
- onKeyPress: (key) => {
711
- if (key === "escape") {
712
- dismissOverlay();
713
- return;
714
- }
715
- return false;
716
- },
717
- onBlur: dismissOverlay,
718
- });
719
-
720
- openOverlay({ select, title: "Path matches" });
721
- }
722
-
723
- function handleTabKeyPress(state: AppState): void {
724
- if (inputValue.startsWith("/")) {
725
- commandController.showCommandAutocomplete(state);
726
- return;
727
- }
728
-
729
- const completedInput = autocompleteInputPath(inputValue, state.cwd);
730
- if (completedInput) {
731
- inputValue = completedInput;
732
- return;
733
- }
734
-
735
- openPathAutocompleteOverlay(state);
736
- }
737
-
738
- /**
739
- * Render the active overlay when one is open.
740
- *
741
- * @param state - Application state.
742
- * @returns The rendered overlay node, or `null` when no overlay is active.
743
- */
744
- export function renderActiveOverlay(state: AppState): Node | null {
745
- if (!activeOverlay) {
746
- return null;
747
- }
748
- return renderOverlay(state.theme, activeOverlay);
749
- }
750
-
751
- // ---------------------------------------------------------------------------
752
- // Input area
753
- // ---------------------------------------------------------------------------
754
-
755
- /**
756
- * Create stable handlers for the main TextInput.
757
- *
758
- * cel-tui keys TextInput cursor/scroll state by the `onChange` function
759
- * reference, so these callbacks must be created once and reused across
760
- * renders.
761
- *
762
- * @param state - Application state used by the handlers.
763
- * @returns Stable callbacks for the controlled TextInput.
764
- */
765
- export function createInputController(state: AppState): InputController {
766
- titleState = state;
767
-
768
- return {
769
- onChange: (value) => {
770
- if (inputReadOnly || inputValue === value) {
771
- return;
772
- }
773
- inputValue = value;
774
- },
775
- onFocus: () => {
776
- inputFocused = true;
777
- },
778
- onBlur: () => {
779
- inputFocused = false;
780
- },
781
- onKeyPress: (key) => {
782
- if (inputReadOnly) {
783
- if (READONLY_INPUT_BUBBLE_KEYS.has(key)) {
784
- return;
785
- }
786
- return false;
787
- }
788
-
789
- if (key === "enter") {
790
- const raw = inputValue;
791
-
792
- if (isQuitInput(raw)) {
793
- inputValue = "";
794
- requestGracefulExit(state);
795
- return false;
796
- }
797
-
798
- const queuedInputCount = state.queuedUserMessages.length;
799
- handleInput(raw, state);
800
- if (state.queuedUserMessages.length > queuedInputCount) {
801
- inputReadOnly = true;
802
- } else {
803
- inputValue = "";
804
- }
805
- return false;
806
- }
807
- if (key === "tab") {
808
- handleTabKeyPress(state);
809
- return false;
810
- }
811
- },
812
- };
813
- }
814
-
815
- /**
816
- * Render the input area.
817
- *
818
- * @param theme - Active UI theme.
819
- * @param controller - Stable TextInput callbacks.
820
- * @returns The input area node.
821
- */
822
- export function renderInputArea(
823
- theme: Theme,
824
- controller: InputController,
825
- ): Node {
826
- return renderInputAreaNode(theme, controller, inputValue, inputFocused);
827
- }
828
-
829
- // ---------------------------------------------------------------------------
830
- // Runtime helpers and controllers
831
- // ---------------------------------------------------------------------------
832
-
833
- /** Browser process handle used by the platform opener helper. */
834
- interface BrowserOpenProcess {
835
- /** Detach the browser opener so the app does not wait on it. */
836
- unref: () => void;
837
- /** Optional error listener used by real child-process implementations. */
838
- on?: (event: "error", listener: (error: Error) => void) => void;
839
- }
840
-
841
- /** Optional runtime overrides for browser launching. */
842
- interface OpenInBrowserRuntime {
843
- /** Platform used to choose the opener binary. */
844
- platform?: NodeJS.Platform;
845
- /** Process launcher used for tests. */
846
- spawn?: (
847
- command: string,
848
- args: string[],
849
- options: { detached: boolean; stdio: "ignore" },
850
- ) => BrowserOpenProcess;
851
- }
852
-
853
- /** Open a URL in the user's default browser without invoking a shell. */
854
- export function openInBrowser(
855
- url: string,
856
- runtime?: OpenInBrowserRuntime,
857
- ): void {
858
- const command =
859
- (runtime?.platform ?? platform()) === "darwin" ? "open" : "xdg-open";
860
- const launch =
861
- runtime?.spawn ??
862
- ((
863
- cmd: string,
864
- args: string[],
865
- options: { detached: boolean; stdio: "ignore" },
866
- ) => {
867
- return spawn(cmd, args, options);
868
- });
869
- const child = launch(command, [url], {
870
- detached: true,
871
- stdio: "ignore",
872
- });
873
-
874
- child.on?.("error", () => {});
875
- child.unref();
876
- }
877
-
878
- function scrollConversationToBottom(): void {
879
- stickToBottom = true;
880
- }
881
-
882
- const uiRuntimeHelpers = createUiRuntimeHelpers({
883
- requestRender,
884
- scrollConversationToBottom,
885
- });
886
-
887
- /** Command controller bound to the module-scoped UI runtime hooks. */
888
- const commandController = createCommandController({
889
- openOverlay,
890
- dismissOverlay,
891
- setInputValue: (value) => {
892
- if (inputReadOnly || inputValue === value) {
893
- return;
894
- }
895
- inputValue = value;
896
- },
897
- appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
898
- appendTodoMessage: uiRuntimeHelpers.appendTodoMessage,
899
- scrollConversationToBottom,
900
- requestRender,
901
- reloadPromptContext,
902
- openInBrowser,
903
- });
904
-
905
- // ---------------------------------------------------------------------------
906
- // Agent loop wiring
907
- // ---------------------------------------------------------------------------
908
-
909
- /** Agent controller bound to the module-scoped UI runtime hooks. */
910
- const agentController = createUiAgentController({
911
- appendInfoMessage: uiRuntimeHelpers.appendInfoMessage,
912
- handleCommand: (command, state) =>
913
- commandController.handleCommand(command, state),
914
- requestRender,
915
- scrollConversationToBottom,
916
- clearQueuedInputDraft: () => {
917
- if (!inputReadOnly) {
918
- return;
919
- }
920
- inputReadOnly = false;
921
- inputValue = "";
922
- inputFocused = true;
923
- },
924
- startDividerAnimation,
925
- stopDividerAnimation,
926
- });
927
-
928
- /** Route raw user input through parseInput and dispatch accordingly. */
929
- export function handleInput(raw: string, state: AppState): void {
930
- titleState = state;
931
- agentController.handleInput(raw, state);
932
- }
933
-
934
- // ---------------------------------------------------------------------------
935
- // Graceful exit
936
- // ---------------------------------------------------------------------------
937
-
938
- /** Shut down cleanly and exit. */
939
- async function gracefulExit(state: AppState): Promise<void> {
940
- stopDividerAnimation();
941
- if (state.abortController) state.abortController.abort();
942
- cel.stop();
943
- await shutdown(state);
944
- process.exit(0);
945
- }
946
-
947
- /** Request a graceful exit and hard-fail if shutdown errors. */
948
- function requestGracefulExit(state: AppState): void {
949
- gracefulExit(state).catch(() => process.exit(1));
950
- }
951
-
952
- /** Restore the terminal to the shell before suspending. */
953
- function suspendTerminalUi(): void {
954
- stopDividerAnimation();
955
- process.stdout.write("\x1b[?1006l\x1b[?1000l");
956
- process.stdout.write("\x1b[<u");
957
- process.stdout.write("\x1b[?25h");
958
- process.stdout.write("\x1b[?1049l");
959
- process.stdin.pause();
960
- if (process.stdin.setRawMode) {
961
- process.stdin.setRawMode(stdinWasRaw);
962
- }
963
- }
964
-
965
- /** Re-enter the TUI terminal modes after a suspended process is resumed. */
966
- function resumeTerminalUi(): void {
967
- if (process.stdin.setRawMode) {
968
- process.stdin.setRawMode(true);
969
- }
970
- process.stdin.resume();
971
- process.stdout.write("\x1b[?1049h");
972
- process.stdout.write("\x1b[>1u");
973
- process.stdout.write("\x1b[?1000h\x1b[?1006h");
974
- process.stdout.write("\x1b[?25l");
975
- }
976
-
977
- /** Suspend the app to the background and restore the UI on SIGCONT. */
978
- export function suspendToBackground(
979
- resumeUi: () => void,
980
- runtime?: {
981
- stop?: () => void;
982
- onResume?: (resume: () => void) => void;
983
- suspend?: () => void;
984
- },
985
- ): void {
986
- const stop = runtime?.stop ?? suspendTerminalUi;
987
- const onResume =
988
- runtime?.onResume ??
989
- ((resume: () => void) => {
990
- process.once("SIGCONT", resume);
991
- });
992
- const suspend =
993
- runtime?.suspend ??
994
- (() => {
995
- process.kill(process.pid, "SIGTSTP");
996
- });
997
- const keepAlive = setInterval(() => {}, 1 << 30);
998
-
999
- stop();
1000
- onResume(() => {
1001
- clearInterval(keepAlive);
1002
- invalidateTerminalTitleCache();
1003
- resumeUi();
1004
- });
1005
-
1006
- try {
1007
- suspend();
1008
- } catch (error) {
1009
- clearInterval(keepAlive);
1010
- throw error;
1011
- }
1012
- }
1013
-
1014
- // ---------------------------------------------------------------------------
1015
- // Main
1016
- // ---------------------------------------------------------------------------
1017
-
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 {
1042
- return VStack(
1043
- {
1044
- flex: 1,
1045
- gap: CONVERSATION_GAP,
1046
- overflow: "scroll",
1047
- justifyContent: state.messages.length === 0 ? "center" : undefined,
1048
- scrollOffset: stickToBottom ? Infinity : scrollOffset,
1049
- onScroll: (offset, maxOffset) => {
1050
- scrollOffset = offset;
1051
- stickToBottom = offset >= maxOffset;
1052
- },
1053
- },
1054
- buildConversationLog(state, width, viewportHeight),
1055
- );
1056
- }
1057
-
1058
- /**
1059
- * Render the base application layout without overlays.
1060
- *
1061
- * The layout contains the conversation log, the animated divider, the input
1062
- * area, and the one-line pill-based status bar.
1063
- *
1064
- * @param state - Application state.
1065
- * @param cols - Current terminal width in columns.
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.
1069
- * @returns The base layout node.
1070
- */
1071
- export function renderBaseLayout(
1072
- state: AppState,
1073
- cols: number,
1074
- inputController: InputController,
1075
- onSuspend?: () => void,
1076
- rows = Number.POSITIVE_INFINITY,
1077
- ): Node {
1078
- titleState = state;
1079
- titleViewportActive = true;
1080
- if (lastTerminalTitle === null) {
1081
- syncTerminalTitle(state);
1082
- }
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
-
1095
- return VStack(
1096
- {
1097
- height: "100%",
1098
- onKeyPress: (key) => {
1099
- if (key === "ctrl+r") {
1100
- commandController.showInputHistoryOverlay(state);
1101
- return;
1102
- }
1103
- if (isQuitKey(key, inputValue)) {
1104
- requestGracefulExit(state);
1105
- return;
1106
- }
1107
- if (key === "ctrl+z") {
1108
- onSuspend?.();
1109
- return;
1110
- }
1111
- if (key === "escape") {
1112
- if (state.running) {
1113
- inputFocused = true;
1114
- if (state.abortController) {
1115
- state.abortController.abort();
1116
- }
1117
- }
1118
- return;
1119
- }
1120
- return false;
1121
- },
1122
- },
1123
- [
1124
- // ── Conversation log ──
1125
- renderConversationLog(state, cols, conversationViewportHeight),
1126
-
1127
- // ── Animated divider (pulse when agent is working) ──
1128
- divider,
1129
-
1130
- // ── Input area ──
1131
- input,
1132
-
1133
- // ── Status bar (1 line) ──
1134
- statusBar,
1135
- ],
1136
- );
1137
- }
1138
-
1139
- /**
1140
- * Start the terminal UI.
1141
- *
1142
- * Initializes cel-tui, sets up the viewport, and takes over the terminal.
1143
- * Does not return until the user exits.
1144
- *
1145
- * @param state - The initialized application state from {@link init}.
1146
- */
1147
- export function startUI(state: AppState): void {
1148
- resetUiState();
1149
- stdinWasRaw = process.stdin.isRaw || false;
1150
- titleState = state;
1151
- const terminal = new ProcessTerminal();
1152
- const inputController = createInputController(state);
1153
- cel.init(terminal);
1154
-
1155
- cel.viewport(() => {
1156
- const cols = terminal.columns;
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
- );
1173
- const overlay = renderActiveOverlay(state);
1174
-
1175
- if (overlay) {
1176
- return [base, overlay];
1177
- }
1178
- return base;
1179
- });
1180
- syncTerminalTitle(state);
1181
-
1182
- if (state.running) {
1183
- startDividerAnimation();
1184
- }
1185
-
1186
- // Show warnings from custom provider discovery
1187
- for (const warning of state.startupWarnings) {
1188
- uiRuntimeHelpers.appendInfoMessage(warning, state);
1189
- }
1190
- }