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/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
- buildConversationLogNodes,
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
- /** Maximum number of committed messages rendered before older history is chunked. */
86
- const CONVERSATION_CHUNK_MESSAGES = 50;
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
- resetUiAgentState();
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 collapseTerminalTitleText(content);
200
+ return collapseWhitespaceToNull(content);
199
201
  }
200
202
 
201
- const text = content
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
- const text = content
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
- function requestRender(): void {
320
- syncTerminalTitle(titleState);
321
- cel.render();
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 getLatestConversationChunkStart(messageCount: number): number {
393
- return Math.max(0, messageCount - CONVERSATION_CHUNK_MESSAGES);
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 getVisibleConversationStart(messageCount: number): number {
397
- if (stickToBottom) {
398
- return getLatestConversationChunkStart(messageCount);
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
- visibleConversationStart = Math.min(
402
- visibleConversationStart,
403
- getLatestConversationChunkStart(messageCount),
404
- );
405
- return visibleConversationStart;
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
- return buildConversationLogNodes(
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
- function measureConversationHeight(
422
- state: AppState,
423
- width: number,
424
- startIndex: number,
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
- function prependConversationChunk(state: AppState, width: number): void {
441
- const currentStart = visibleConversationStart;
442
- const nextStart = Math.max(0, currentStart - CONVERSATION_CHUNK_MESSAGES);
443
- const currentHeight = measureConversationHeight(state, width, currentStart);
444
- const nextHeight = measureConversationHeight(state, width, nextStart);
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
- visibleConversationStart = nextStart;
447
- scrollOffset += Math.max(0, nextHeight - currentHeight);
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
- inputValue = "";
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
- function appendUiMessage(
645
- message: AppState["messages"][number],
646
- state: AppState,
647
- ): void {
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
- render: requestRender,
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
- render: requestRender,
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 renderConversationLog(state: AppState, width: number): Node {
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
- renderDivider(state, cols),
1128
+ divider,
898
1129
 
899
1130
  // ── Input area ──
900
- renderInputArea(state.theme, inputController),
1131
+ input,
901
1132
 
902
1133
  // ── Status bar (1 line) ──
903
- renderStatusBar(state, cols),
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(state, cols, inputController, () => {
927
- suspendToBackground(() => {
928
- resumeTerminalUi();
929
- cel._getBuffer()?.clear();
930
- if (state.running) {
931
- startDividerAnimation();
932
- }
933
- requestRender();
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
  }