mini-coder 0.5.11 → 0.5.12

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