mini-coder 0.4.1 → 0.5.1

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 (51) hide show
  1. package/README.md +89 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +640 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +171 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +666 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +301 -0
  21. package/src/session.ts +1043 -0
  22. package/src/settings.ts +191 -0
  23. package/src/skills.ts +262 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +44 -0
  34. package/src/ui/help.ts +125 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +451 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +694 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
package/src/ui.ts ADDED
@@ -0,0 +1,694 @@
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 {
14
+ cel,
15
+ HStack,
16
+ measureContentHeight,
17
+ ProcessTerminal,
18
+ Text,
19
+ VStack,
20
+ } from "@cel-tui/core";
21
+ import type { Node } from "@cel-tui/types";
22
+ import type { AppState } from "./index.ts";
23
+ import { reloadPromptContext, shutdown } from "./index.ts";
24
+ import { appendMessage, createUiMessage } from "./session.ts";
25
+ import type { Theme } from "./theme.ts";
26
+ import {
27
+ createUiAgentController,
28
+ getStreamingConversationState,
29
+ resetUiAgentState,
30
+ } from "./ui/agent.ts";
31
+ import { createCommandController } from "./ui/commands.ts";
32
+ import {
33
+ buildConversationLogNodes,
34
+ CONVERSATION_GAP,
35
+ resetConversationRenderCache,
36
+ } from "./ui/conversation.ts";
37
+ import type { InputController } from "./ui/input.ts";
38
+ import {
39
+ autocompleteInputPath,
40
+ renderInputArea as renderInputAreaNode,
41
+ } from "./ui/input.ts";
42
+ import { type ActiveOverlay, renderOverlay } from "./ui/overlay.ts";
43
+ import { renderStatusBar } from "./ui/status.ts";
44
+
45
+ export type { InputController } from "./ui/input.ts";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Constants
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Divider animation speed (ms per frame). */
52
+ const DIVIDER_FRAME_MS = 60;
53
+
54
+ /** Width of the bright pulse segment in the animated divider. */
55
+ const PULSE_WIDTH = 5;
56
+
57
+ /** Maximum number of committed messages rendered before older history is chunked. */
58
+ const CONVERSATION_CHUNK_MESSAGES = 50;
59
+
60
+ /** Centralized interactive quit rules for keypresses and submitted input. */
61
+ const QUIT_RULES: Readonly<{
62
+ /** Submitted raw inputs that trigger graceful quit. */
63
+ inputs: ReadonlySet<string>;
64
+ /** Keypresses that always trigger graceful quit. */
65
+ keysAlways: ReadonlySet<string>;
66
+ /** Keypresses that trigger graceful quit only when input is empty. */
67
+ keysWhenEmptyInput: ReadonlySet<string>;
68
+ }> = {
69
+ inputs: new Set([":q"]),
70
+ keysAlways: new Set(["ctrl+c"]),
71
+ keysWhenEmptyInput: new Set(["ctrl+d"]),
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // UI state (module-scoped, not in AppState)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** Scroll position for the conversation log. */
79
+ let scrollOffset = 0;
80
+
81
+ /** Whether the log auto-scrolls to the bottom. */
82
+ let stickToBottom = true;
83
+
84
+ /** First visible committed message when older history is chunked. */
85
+ let visibleConversationStart = 0;
86
+
87
+ /** Current text in the input area. */
88
+ let inputValue = "";
89
+
90
+ /** Whether the text input is focused. */
91
+ let inputFocused = true;
92
+
93
+ /** Animated divider frame counter. */
94
+ let dividerTick = 0;
95
+
96
+ /** Divider animation timer handle. */
97
+ let dividerTimer: ReturnType<typeof setInterval> | null = null;
98
+
99
+ /** Whether stdin was already in raw mode before the TUI initialized. */
100
+ let stdinWasRaw = false;
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Overlay state
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** Active overlay for interactive commands (/model, /effort, etc.). */
107
+ let activeOverlay: ActiveOverlay | null = null;
108
+
109
+ /** Determine whether a raw input line should trigger a graceful quit. */
110
+ export function isQuitInput(raw: string): boolean {
111
+ const trimmed = raw.trim();
112
+ return QUIT_RULES.inputs.has(trimmed);
113
+ }
114
+
115
+ /** Determine whether a keypress should trigger a graceful quit. */
116
+ export function isQuitKey(key: string, input: string): boolean {
117
+ if (QUIT_RULES.keysAlways.has(key)) {
118
+ return true;
119
+ }
120
+ if (input === "" && QUIT_RULES.keysWhenEmptyInput.has(key)) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Reset all module-scoped UI state.
128
+ *
129
+ * Useful for reinitializing the UI and for keeping tests isolated.
130
+ */
131
+ export function resetUiState(): void {
132
+ scrollOffset = 0;
133
+ stickToBottom = true;
134
+ visibleConversationStart = 0;
135
+ inputValue = "";
136
+ inputFocused = true;
137
+ dividerTick = 0;
138
+ stopDividerAnimation();
139
+ resetUiAgentState();
140
+ resetConversationRenderCache();
141
+ activeOverlay = null;
142
+ stdinWasRaw = false;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Divider animation
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /** Start the scanning pulse animation on the divider. */
150
+ function startDividerAnimation(): void {
151
+ stopDividerAnimation();
152
+ dividerTick = 0;
153
+ dividerTimer = setInterval(() => {
154
+ dividerTick++;
155
+ cel.render();
156
+ }, DIVIDER_FRAME_MS);
157
+ }
158
+
159
+ /** Stop the divider animation. */
160
+ function stopDividerAnimation(): void {
161
+ if (dividerTimer) {
162
+ clearInterval(dividerTimer);
163
+ dividerTimer = null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Render the animated divider.
169
+ *
170
+ * When the agent is working, a bright segment sweeps across the dimmed
171
+ * line. When idle, it's a static dimmed line.
172
+ */
173
+ function renderDivider(state: AppState, width: number): Node {
174
+ if (!state.running) {
175
+ return Text("─", { repeat: "fill", fgColor: state.theme.divider });
176
+ }
177
+
178
+ const total = Math.max(width, 1);
179
+ const pos = dividerTick % (total + PULSE_WIDTH);
180
+ const pulseStart = Math.max(0, pos - PULSE_WIDTH);
181
+ const pulseEnd = Math.min(pos, total);
182
+ const pulseLen = pulseEnd - pulseStart;
183
+ const beforeLen = pulseStart;
184
+ const afterLen = total - pulseEnd;
185
+
186
+ const segments: Node[] = [];
187
+ if (beforeLen > 0) {
188
+ segments.push(
189
+ Text("─", { repeat: beforeLen, fgColor: state.theme.divider }),
190
+ );
191
+ }
192
+ if (pulseLen > 0) {
193
+ segments.push(
194
+ Text("═", {
195
+ repeat: pulseLen,
196
+ fgColor: state.theme.dividerPulse,
197
+ }),
198
+ );
199
+ }
200
+ if (afterLen > 0) {
201
+ segments.push(
202
+ Text("─", { repeat: afterLen, fgColor: state.theme.divider }),
203
+ );
204
+ }
205
+
206
+ return HStack({ height: 1 }, segments);
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Conversation log
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function getLatestConversationChunkStart(messageCount: number): number {
214
+ return Math.max(0, messageCount - CONVERSATION_CHUNK_MESSAGES);
215
+ }
216
+
217
+ function getVisibleConversationStart(messageCount: number): number {
218
+ if (stickToBottom) {
219
+ return getLatestConversationChunkStart(messageCount);
220
+ }
221
+
222
+ visibleConversationStart = Math.min(
223
+ visibleConversationStart,
224
+ getLatestConversationChunkStart(messageCount),
225
+ );
226
+ return visibleConversationStart;
227
+ }
228
+
229
+ /** Build the full conversation log as an array of nodes. */
230
+ export function buildConversationLog(
231
+ state: AppState,
232
+ width = Number.POSITIVE_INFINITY,
233
+ ): Node[] {
234
+ return buildConversationLogNodes(
235
+ state,
236
+ getStreamingConversationState(),
237
+ getVisibleConversationStart(state.messages.length),
238
+ width,
239
+ );
240
+ }
241
+
242
+ function measureConversationHeight(
243
+ state: AppState,
244
+ width: number,
245
+ startIndex: number,
246
+ ): number {
247
+ return measureContentHeight(
248
+ VStack(
249
+ { gap: CONVERSATION_GAP },
250
+ buildConversationLogNodes(
251
+ state,
252
+ getStreamingConversationState(),
253
+ startIndex,
254
+ width,
255
+ ),
256
+ ),
257
+ { width: Math.max(1, width) },
258
+ );
259
+ }
260
+
261
+ function prependConversationChunk(state: AppState, width: number): void {
262
+ const currentStart = visibleConversationStart;
263
+ const nextStart = Math.max(0, currentStart - CONVERSATION_CHUNK_MESSAGES);
264
+ const currentHeight = measureConversationHeight(state, width, currentStart);
265
+ const nextHeight = measureConversationHeight(state, width, nextStart);
266
+
267
+ visibleConversationStart = nextStart;
268
+ scrollOffset += Math.max(0, nextHeight - currentHeight);
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Overlay rendering
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /** Open an overlay and move focus away from the input. */
276
+ function openOverlay(overlay: ActiveOverlay): void {
277
+ activeOverlay = overlay;
278
+ inputFocused = false;
279
+ cel.render();
280
+ }
281
+
282
+ /** Dismiss the active overlay and return focus to the input. */
283
+ function dismissOverlay(): void {
284
+ activeOverlay = null;
285
+ inputFocused = true;
286
+ cel.render();
287
+ }
288
+
289
+ /**
290
+ * Render the active overlay when one is open.
291
+ *
292
+ * @param state - Application state.
293
+ * @returns The rendered overlay node, or `null` when no overlay is active.
294
+ */
295
+ export function renderActiveOverlay(state: AppState): Node | null {
296
+ if (!activeOverlay) {
297
+ return null;
298
+ }
299
+ return renderOverlay(state.theme, activeOverlay);
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Input area
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Create stable handlers for the main TextInput.
308
+ *
309
+ * cel-tui keys TextInput cursor/scroll state by the `onChange` function
310
+ * reference, so these callbacks must be created once and reused across
311
+ * renders.
312
+ *
313
+ * @param state - Application state used by the handlers.
314
+ * @returns Stable callbacks for the controlled TextInput.
315
+ */
316
+ export function createInputController(state: AppState): InputController {
317
+ return {
318
+ onChange: (value) => {
319
+ inputValue = value;
320
+ cel.render();
321
+ },
322
+ onFocus: () => {
323
+ inputFocused = true;
324
+ cel.render();
325
+ },
326
+ onBlur: () => {
327
+ inputFocused = false;
328
+ cel.render();
329
+ },
330
+ onKeyPress: (key) => {
331
+ if (key === "enter") {
332
+ const raw = inputValue;
333
+
334
+ if (isQuitInput(raw)) {
335
+ inputValue = "";
336
+ cel.render();
337
+ requestGracefulExit(state);
338
+ return false;
339
+ }
340
+
341
+ inputValue = "";
342
+ cel.render();
343
+ handleInput(raw, state);
344
+ return false;
345
+ }
346
+ if (key === "tab") {
347
+ if (inputValue.startsWith("/")) {
348
+ commandController.showCommandAutocomplete(state);
349
+ } else {
350
+ const completedInput = autocompleteInputPath(inputValue, state.cwd);
351
+ if (completedInput) {
352
+ inputValue = completedInput;
353
+ cel.render();
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+ },
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Render the input area.
364
+ *
365
+ * @param theme - Active UI theme.
366
+ * @param controller - Stable TextInput callbacks.
367
+ * @returns The input area node.
368
+ */
369
+ export function renderInputArea(
370
+ theme: Theme,
371
+ controller: InputController,
372
+ ): Node {
373
+ return renderInputAreaNode(theme, controller, inputValue, inputFocused);
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Runtime helpers and controllers
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /** Browser process handle used by the platform opener helper. */
381
+ interface BrowserOpenProcess {
382
+ /** Detach the browser opener so the app does not wait on it. */
383
+ unref: () => void;
384
+ /** Optional error listener used by real child-process implementations. */
385
+ on?: (event: "error", listener: (error: Error) => void) => void;
386
+ }
387
+
388
+ /** Optional runtime overrides for browser launching. */
389
+ interface OpenInBrowserRuntime {
390
+ /** Platform used to choose the opener binary. */
391
+ platform?: NodeJS.Platform;
392
+ /** Process launcher used for tests. */
393
+ spawn?: (
394
+ command: string,
395
+ args: string[],
396
+ options: { detached: boolean; stdio: "ignore" },
397
+ ) => BrowserOpenProcess;
398
+ }
399
+
400
+ /** Open a URL in the user's default browser without invoking a shell. */
401
+ export function openInBrowser(
402
+ url: string,
403
+ runtime?: OpenInBrowserRuntime,
404
+ ): void {
405
+ const command =
406
+ (runtime?.platform ?? platform()) === "darwin" ? "open" : "xdg-open";
407
+ const launch =
408
+ runtime?.spawn ??
409
+ ((
410
+ cmd: string,
411
+ args: string[],
412
+ options: { detached: boolean; stdio: "ignore" },
413
+ ) => {
414
+ return spawn(cmd, args, options);
415
+ });
416
+ const child = launch(command, [url], {
417
+ detached: true,
418
+ stdio: "ignore",
419
+ });
420
+
421
+ child.on?.("error", () => {});
422
+ child.unref();
423
+ }
424
+
425
+ function scrollConversationToBottom(): void {
426
+ stickToBottom = true;
427
+ }
428
+
429
+ /**
430
+ * Append a UI-only info message to the conversation log.
431
+ *
432
+ * When no persisted session exists yet, the message stays in memory and is
433
+ * backfilled if the user later starts a session by sending a message.
434
+ *
435
+ * @param text - Display text to append.
436
+ * @param state - Application state.
437
+ */
438
+ function appendInfoMessage(text: string, state: AppState): void {
439
+ const msg = createUiMessage(text);
440
+ if (state.session) {
441
+ appendMessage(state.db, state.session.id, msg);
442
+ }
443
+ state.messages.push(msg);
444
+ scrollConversationToBottom();
445
+ cel.render();
446
+ }
447
+
448
+ /** Command controller bound to the module-scoped UI runtime hooks. */
449
+ const commandController = createCommandController({
450
+ openOverlay,
451
+ dismissOverlay,
452
+ setInputValue: (value) => {
453
+ inputValue = value;
454
+ },
455
+ appendInfoMessage,
456
+ scrollConversationToBottom,
457
+ render: () => {
458
+ cel.render();
459
+ },
460
+ reloadPromptContext,
461
+ openInBrowser,
462
+ });
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Agent loop wiring
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /** Agent controller bound to the module-scoped UI runtime hooks. */
469
+ const agentController = createUiAgentController({
470
+ appendInfoMessage,
471
+ handleCommand: (command, state) =>
472
+ commandController.handleCommand(command, state),
473
+ render: () => {
474
+ cel.render();
475
+ },
476
+ scrollConversationToBottom,
477
+ startDividerAnimation,
478
+ stopDividerAnimation,
479
+ });
480
+
481
+ /** Route raw user input through parseInput and dispatch accordingly. */
482
+ export function handleInput(raw: string, state: AppState): void {
483
+ agentController.handleInput(raw, state);
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Graceful exit
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /** Shut down cleanly and exit. */
491
+ async function gracefulExit(state: AppState): Promise<void> {
492
+ stopDividerAnimation();
493
+ if (state.abortController) state.abortController.abort();
494
+ cel.stop();
495
+ await shutdown(state);
496
+ process.exit(0);
497
+ }
498
+
499
+ /** Request a graceful exit and hard-fail if shutdown errors. */
500
+ function requestGracefulExit(state: AppState): void {
501
+ gracefulExit(state).catch(() => process.exit(1));
502
+ }
503
+
504
+ /** Restore the terminal to the shell before suspending. */
505
+ function suspendTerminalUi(): void {
506
+ stopDividerAnimation();
507
+ process.stdout.write("\x1b[?1006l\x1b[?1000l");
508
+ process.stdout.write("\x1b[<u");
509
+ process.stdout.write("\x1b[?25h");
510
+ process.stdout.write("\x1b[?1049l");
511
+ process.stdin.pause();
512
+ if (process.stdin.setRawMode) {
513
+ process.stdin.setRawMode(stdinWasRaw);
514
+ }
515
+ }
516
+
517
+ /** Re-enter the TUI terminal modes after a suspended process is resumed. */
518
+ function resumeTerminalUi(): void {
519
+ if (process.stdin.setRawMode) {
520
+ process.stdin.setRawMode(true);
521
+ }
522
+ process.stdin.resume();
523
+ process.stdout.write("\x1b[?1049h");
524
+ process.stdout.write("\x1b[>1u");
525
+ process.stdout.write("\x1b[?1000h\x1b[?1006h");
526
+ process.stdout.write("\x1b[?25l");
527
+ }
528
+
529
+ /** Suspend the app to the background and restore the UI on SIGCONT. */
530
+ export function suspendToBackground(
531
+ resumeUi: () => void,
532
+ runtime?: {
533
+ stop?: () => void;
534
+ onResume?: (resume: () => void) => void;
535
+ suspend?: () => void;
536
+ },
537
+ ): void {
538
+ const stop = runtime?.stop ?? suspendTerminalUi;
539
+ const onResume =
540
+ runtime?.onResume ??
541
+ ((resume: () => void) => {
542
+ process.once("SIGCONT", resume);
543
+ });
544
+ const suspend =
545
+ runtime?.suspend ??
546
+ (() => {
547
+ process.kill(process.pid, "SIGTSTP");
548
+ });
549
+ const keepAlive = setInterval(() => {}, 1 << 30);
550
+
551
+ stop();
552
+ onResume(() => {
553
+ clearInterval(keepAlive);
554
+ resumeUi();
555
+ });
556
+
557
+ try {
558
+ suspend();
559
+ } catch (error) {
560
+ clearInterval(keepAlive);
561
+ throw error;
562
+ }
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Main
567
+ // ---------------------------------------------------------------------------
568
+
569
+ function renderConversationLog(state: AppState, width: number): Node {
570
+ return VStack(
571
+ {
572
+ flex: 1,
573
+ gap: CONVERSATION_GAP,
574
+ overflow: "scroll",
575
+ scrollbar: true,
576
+ scrollOffset: stickToBottom ? Infinity : scrollOffset,
577
+ onScroll: (offset, maxOffset) => {
578
+ const wasStickToBottom = stickToBottom;
579
+ scrollOffset = offset;
580
+
581
+ if (wasStickToBottom && offset < maxOffset) {
582
+ visibleConversationStart = getLatestConversationChunkStart(
583
+ state.messages.length,
584
+ );
585
+ }
586
+
587
+ stickToBottom = offset >= maxOffset;
588
+
589
+ if (!stickToBottom && offset === 0 && visibleConversationStart > 0) {
590
+ prependConversationChunk(state, width);
591
+ }
592
+
593
+ cel.render();
594
+ },
595
+ },
596
+ buildConversationLog(state, width),
597
+ );
598
+ }
599
+
600
+ /**
601
+ * Render the base application layout without overlays.
602
+ *
603
+ * The layout contains the conversation log, the animated divider, the input
604
+ * area, and the one-line pill-based status bar.
605
+ *
606
+ * @param state - Application state.
607
+ * @param cols - Current terminal width in columns.
608
+ * @param inputController - Stable callbacks for the controlled TextInput.
609
+ * @returns The base layout node.
610
+ */
611
+ export function renderBaseLayout(
612
+ state: AppState,
613
+ cols: number,
614
+ inputController: InputController,
615
+ onSuspend?: () => void,
616
+ ): Node {
617
+ return VStack(
618
+ {
619
+ height: "100%",
620
+ onKeyPress: (key) => {
621
+ if (key === "ctrl+r") {
622
+ commandController.showInputHistoryOverlay(state);
623
+ return;
624
+ }
625
+ if (isQuitKey(key, inputValue)) {
626
+ requestGracefulExit(state);
627
+ return;
628
+ }
629
+ if (key === "ctrl+z") {
630
+ onSuspend?.();
631
+ return;
632
+ }
633
+ if (key === "escape" && state.running) {
634
+ if (state.abortController) state.abortController.abort();
635
+ return;
636
+ }
637
+ return false;
638
+ },
639
+ },
640
+ [
641
+ // ── Conversation log ──
642
+ renderConversationLog(state, cols),
643
+
644
+ // ── Animated divider (pulse when agent is working) ──
645
+ renderDivider(state, cols),
646
+
647
+ // ── Input area ──
648
+ renderInputArea(state.theme, inputController),
649
+
650
+ // ── Status bar (1 line) ──
651
+ renderStatusBar(state, cols),
652
+ ],
653
+ );
654
+ }
655
+
656
+ /**
657
+ * Start the terminal UI.
658
+ *
659
+ * Initializes cel-tui, sets up the viewport, and takes over the terminal.
660
+ * Does not return until the user exits.
661
+ *
662
+ * @param state - The initialized application state from {@link init}.
663
+ */
664
+ export function startUI(state: AppState): void {
665
+ resetUiState();
666
+ stdinWasRaw = process.stdin.isRaw || false;
667
+ const terminal = new ProcessTerminal();
668
+ const inputController = createInputController(state);
669
+ cel.init(terminal);
670
+
671
+ cel.viewport(() => {
672
+ const cols = terminal.columns;
673
+ const base = renderBaseLayout(state, cols, inputController, () => {
674
+ suspendToBackground(() => {
675
+ resumeTerminalUi();
676
+ cel._getBuffer()?.clear();
677
+ if (state.running) {
678
+ startDividerAnimation();
679
+ }
680
+ cel.render();
681
+ });
682
+ });
683
+ const overlay = renderActiveOverlay(state);
684
+
685
+ if (overlay) {
686
+ return [base, overlay];
687
+ }
688
+ return base;
689
+ });
690
+
691
+ if (state.running) {
692
+ startDividerAnimation();
693
+ }
694
+ }