mini-coder 0.5.7 → 0.5.9

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,7 +22,11 @@ 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 { appendMessage, createUiMessage } from "./session.ts";
25
+ import {
26
+ appendMessage,
27
+ createUiMessage,
28
+ createUiTodoMessage,
29
+ } from "./session.ts";
26
30
  import type { Theme } from "./theme.ts";
27
31
  import {
28
32
  createUiAgentController,
@@ -60,6 +64,23 @@ const DIVIDER_FRAME_MS = 60;
60
64
  /** Width of the bright pulse segment in the animated divider. */
61
65
  const PULSE_WIDTH = 5;
62
66
 
67
+ /** Number of trailing words shown in the idle terminal-title preview. */
68
+ const TERMINAL_TITLE_WORD_COUNT = 5;
69
+
70
+ /** Divider ticks spent on each animated terminal-title scanner frame. */
71
+ const TERMINAL_TITLE_TICKS_PER_FRAME = 4;
72
+
73
+ /** Frames used for the active terminal-title glow-scanner animation. */
74
+ const TERMINAL_TITLE_FRAMES = [
75
+ "[=o---]",
76
+ "[-=o--]",
77
+ "[--=o-]",
78
+ "[---=o]",
79
+ "[--o=-]",
80
+ "[-o=--]",
81
+ "[o=---]",
82
+ ] as const;
83
+
63
84
  /** Maximum number of committed messages rendered before older history is chunked. */
64
85
  const CONVERSATION_CHUNK_MESSAGES = 50;
65
86
 
@@ -105,6 +126,15 @@ let dividerTimer: ReturnType<typeof setInterval> | null = null;
105
126
  /** Whether stdin was already in raw mode before the TUI initialized. */
106
127
  let stdinWasRaw = false;
107
128
 
129
+ /** Latest application state associated with the active terminal UI. */
130
+ let titleState: AppState | null = null;
131
+
132
+ /** Whether a cel viewport has rendered for the current UI session. */
133
+ let titleViewportActive = false;
134
+
135
+ /** Last terminal title written during the current UI session. */
136
+ let lastTerminalTitle: string | null = null;
137
+
108
138
  // ---------------------------------------------------------------------------
109
139
  // Overlay state
110
140
  // ---------------------------------------------------------------------------
@@ -146,6 +176,148 @@ export function resetUiState(): void {
146
176
  resetConversationRenderCache();
147
177
  activeOverlay = null;
148
178
  stdinWasRaw = false;
179
+ titleState = null;
180
+ titleViewportActive = false;
181
+ lastTerminalTitle = null;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Terminal title
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function collapseTerminalTitleText(text: string): string | null {
189
+ const collapsed = text.replace(/\s+/g, " ").trim();
190
+ return collapsed.length > 0 ? collapsed : null;
191
+ }
192
+
193
+ function getUserTerminalTitleText(
194
+ content: Extract<AppState["messages"][number], { role: "user" }>["content"],
195
+ ): string | null {
196
+ if (typeof content === "string") {
197
+ return collapseTerminalTitleText(content);
198
+ }
199
+
200
+ const text = content
201
+ .filter(
202
+ (block): block is Extract<(typeof content)[number], { type: "text" }> => {
203
+ return block.type === "text";
204
+ },
205
+ )
206
+ .map((block) => block.text)
207
+ .join(" ");
208
+
209
+ return collapseTerminalTitleText(text);
210
+ }
211
+
212
+ function getAssistantTerminalTitleText(
213
+ content: Extract<
214
+ AppState["messages"][number],
215
+ { role: "assistant" }
216
+ >["content"],
217
+ ): string | null {
218
+ const text = content
219
+ .filter(
220
+ (
221
+ block,
222
+ ): block is Extract<
223
+ Extract<
224
+ AppState["messages"][number],
225
+ { role: "assistant" }
226
+ >["content"][number],
227
+ { type: "text" }
228
+ > => {
229
+ return block.type === "text";
230
+ },
231
+ )
232
+ .map((block) => block.text)
233
+ .join(" ");
234
+
235
+ return collapseTerminalTitleText(text);
236
+ }
237
+
238
+ function truncateTerminalTitleTail(text: string): string {
239
+ const words = text.split(" ");
240
+ if (words.length <= TERMINAL_TITLE_WORD_COUNT) {
241
+ return text;
242
+ }
243
+ return `...${words.slice(-TERMINAL_TITLE_WORD_COUNT).join(" ")}`;
244
+ }
245
+
246
+ function buildIdleTerminalTitle(state: Pick<AppState, "messages">): string {
247
+ for (let index = state.messages.length - 1; index >= 0; index -= 1) {
248
+ const message = state.messages[index];
249
+ if (!message) {
250
+ continue;
251
+ }
252
+
253
+ let text: string | null = null;
254
+ switch (message.role) {
255
+ case "user":
256
+ text = getUserTerminalTitleText(message.content);
257
+ break;
258
+ case "assistant":
259
+ text = getAssistantTerminalTitleText(message.content);
260
+ break;
261
+ case "toolResult":
262
+ case "ui":
263
+ break;
264
+ }
265
+
266
+ if (text) {
267
+ return `mc - ${truncateTerminalTitleTail(text)}`;
268
+ }
269
+ }
270
+
271
+ return "mc";
272
+ }
273
+
274
+ /**
275
+ * Build the current terminal title from UI state.
276
+ *
277
+ * Idle titles show a short tail preview from the latest conversational text
278
+ * message. Active turns show a stable-width glow scanner.
279
+ *
280
+ * @param state - Application state needed to derive the title.
281
+ * @param animationTick - Divider animation tick used to pick the scanner frame.
282
+ * @returns The terminal title text to write via cel-tui.
283
+ */
284
+ export function buildTerminalTitle(
285
+ state: Pick<AppState, "messages" | "running">,
286
+ animationTick = dividerTick,
287
+ ): string {
288
+ if (!state.running) {
289
+ return buildIdleTerminalTitle(state);
290
+ }
291
+
292
+ const frameIndex =
293
+ Math.floor(animationTick / TERMINAL_TITLE_TICKS_PER_FRAME) %
294
+ TERMINAL_TITLE_FRAMES.length;
295
+ return `mc - ${TERMINAL_TITLE_FRAMES[frameIndex]}`;
296
+ }
297
+
298
+ function syncTerminalTitle(
299
+ state: Pick<AppState, "messages" | "running"> | null,
300
+ ): void {
301
+ if (!state || !titleViewportActive) {
302
+ return;
303
+ }
304
+
305
+ const title = buildTerminalTitle(state);
306
+ if (title === lastTerminalTitle) {
307
+ return;
308
+ }
309
+
310
+ cel.setTitle(title);
311
+ lastTerminalTitle = title;
312
+ }
313
+
314
+ function invalidateTerminalTitleCache(): void {
315
+ lastTerminalTitle = null;
316
+ }
317
+
318
+ function requestRender(): void {
319
+ syncTerminalTitle(titleState);
320
+ cel.render();
149
321
  }
150
322
 
151
323
  // ---------------------------------------------------------------------------
@@ -158,7 +330,7 @@ function startDividerAnimation(): void {
158
330
  dividerTick = 0;
159
331
  dividerTimer = setInterval(() => {
160
332
  dividerTick++;
161
- cel.render();
333
+ requestRender();
162
334
  }, DIVIDER_FRAME_MS);
163
335
  }
164
336
 
@@ -282,14 +454,14 @@ function prependConversationChunk(state: AppState, width: number): void {
282
454
  function openOverlay(overlay: ActiveOverlay): void {
283
455
  activeOverlay = overlay;
284
456
  inputFocused = false;
285
- cel.render();
457
+ requestRender();
286
458
  }
287
459
 
288
460
  /** Dismiss the active overlay and return focus to the input. */
289
461
  function dismissOverlay(): void {
290
462
  activeOverlay = null;
291
463
  inputFocused = true;
292
- cel.render();
464
+ requestRender();
293
465
  }
294
466
 
295
467
  function openPathAutocompleteOverlay(state: AppState): void {
@@ -327,7 +499,7 @@ function handleTabKeyPress(state: AppState): void {
327
499
  const completedInput = autocompleteInputPath(inputValue, state.cwd);
328
500
  if (completedInput) {
329
501
  inputValue = completedInput;
330
- cel.render();
502
+ requestRender();
331
503
  return;
332
504
  }
333
505
 
@@ -362,18 +534,20 @@ export function renderActiveOverlay(state: AppState): Node | null {
362
534
  * @returns Stable callbacks for the controlled TextInput.
363
535
  */
364
536
  export function createInputController(state: AppState): InputController {
537
+ titleState = state;
538
+
365
539
  return {
366
540
  onChange: (value) => {
367
541
  inputValue = value;
368
- cel.render();
542
+ requestRender();
369
543
  },
370
544
  onFocus: () => {
371
545
  inputFocused = true;
372
- cel.render();
546
+ requestRender();
373
547
  },
374
548
  onBlur: () => {
375
549
  inputFocused = false;
376
- cel.render();
550
+ requestRender();
377
551
  },
378
552
  onKeyPress: (key) => {
379
553
  if (key === "enter") {
@@ -381,13 +555,13 @@ export function createInputController(state: AppState): InputController {
381
555
 
382
556
  if (isQuitInput(raw)) {
383
557
  inputValue = "";
384
- cel.render();
558
+ requestRender();
385
559
  requestGracefulExit(state);
386
560
  return false;
387
561
  }
388
562
 
389
563
  inputValue = "";
390
- cel.render();
564
+ requestRender();
391
565
  handleInput(raw, state);
392
566
  return false;
393
567
  }
@@ -466,6 +640,18 @@ function scrollConversationToBottom(): void {
466
640
  stickToBottom = true;
467
641
  }
468
642
 
643
+ function appendUiMessage(
644
+ message: AppState["messages"][number],
645
+ state: AppState,
646
+ ): void {
647
+ if (state.session) {
648
+ appendMessage(state.db, state.session.id, message);
649
+ }
650
+ state.messages.push(message);
651
+ scrollConversationToBottom();
652
+ requestRender();
653
+ }
654
+
469
655
  /**
470
656
  * Append a UI-only info message to the conversation log.
471
657
  *
@@ -476,13 +662,23 @@ function scrollConversationToBottom(): void {
476
662
  * @param state - Application state.
477
663
  */
478
664
  function appendInfoMessage(text: string, state: AppState): void {
479
- const msg = createUiMessage(text);
480
- if (state.session) {
481
- appendMessage(state.db, state.session.id, msg);
482
- }
483
- state.messages.push(msg);
484
- scrollConversationToBottom();
485
- cel.render();
665
+ appendUiMessage(createUiMessage(text), state);
666
+ }
667
+
668
+ /**
669
+ * Append a UI-only todo snapshot to the conversation log.
670
+ *
671
+ * When no persisted session exists yet, the message stays in memory and is
672
+ * backfilled if the user later starts a session by sending a message.
673
+ *
674
+ * @param todos - Todo snapshot to append.
675
+ * @param state - Application state.
676
+ */
677
+ function appendTodoMessage(
678
+ todos: Parameters<typeof createUiTodoMessage>[0],
679
+ state: AppState,
680
+ ): void {
681
+ appendUiMessage(createUiTodoMessage(todos), state);
486
682
  }
487
683
 
488
684
  /** Command controller bound to the module-scoped UI runtime hooks. */
@@ -493,10 +689,9 @@ const commandController = createCommandController({
493
689
  inputValue = value;
494
690
  },
495
691
  appendInfoMessage,
692
+ appendTodoMessage,
496
693
  scrollConversationToBottom,
497
- render: () => {
498
- cel.render();
499
- },
694
+ render: requestRender,
500
695
  reloadPromptContext,
501
696
  openInBrowser,
502
697
  });
@@ -510,9 +705,7 @@ const agentController = createUiAgentController({
510
705
  appendInfoMessage,
511
706
  handleCommand: (command, state) =>
512
707
  commandController.handleCommand(command, state),
513
- render: () => {
514
- cel.render();
515
- },
708
+ render: requestRender,
516
709
  scrollConversationToBottom,
517
710
  startDividerAnimation,
518
711
  stopDividerAnimation,
@@ -520,6 +713,7 @@ const agentController = createUiAgentController({
520
713
 
521
714
  /** Route raw user input through parseInput and dispatch accordingly. */
522
715
  export function handleInput(raw: string, state: AppState): void {
716
+ titleState = state;
523
717
  agentController.handleInput(raw, state);
524
718
  }
525
719
 
@@ -591,6 +785,7 @@ export function suspendToBackground(
591
785
  stop();
592
786
  onResume(() => {
593
787
  clearInterval(keepAlive);
788
+ invalidateTerminalTitleCache();
594
789
  resumeUi();
595
790
  });
596
791
 
@@ -630,7 +825,7 @@ function renderConversationLog(state: AppState, width: number): Node {
630
825
  prependConversationChunk(state, width);
631
826
  }
632
827
 
633
- cel.render();
828
+ requestRender();
634
829
  },
635
830
  },
636
831
  buildConversationLog(state, width),
@@ -654,6 +849,12 @@ export function renderBaseLayout(
654
849
  inputController: InputController,
655
850
  onSuspend?: () => void,
656
851
  ): Node {
852
+ titleState = state;
853
+ titleViewportActive = true;
854
+ if (lastTerminalTitle === null) {
855
+ syncTerminalTitle(state);
856
+ }
857
+
657
858
  return VStack(
658
859
  {
659
860
  height: "100%",
@@ -709,6 +910,7 @@ export function renderBaseLayout(
709
910
  export function startUI(state: AppState): void {
710
911
  resetUiState();
711
912
  stdinWasRaw = process.stdin.isRaw || false;
913
+ titleState = state;
712
914
  const terminal = new ProcessTerminal();
713
915
  const inputController = createInputController(state);
714
916
  cel.init(terminal);
@@ -722,7 +924,7 @@ export function startUI(state: AppState): void {
722
924
  if (state.running) {
723
925
  startDividerAnimation();
724
926
  }
725
- cel.render();
927
+ requestRender();
726
928
  });
727
929
  });
728
930
  const overlay = renderActiveOverlay(state);
@@ -732,6 +934,7 @@ export function startUI(state: AppState): void {
732
934
  }
733
935
  return base;
734
936
  });
937
+ syncTerminalTitle(state);
735
938
 
736
939
  if (state.running) {
737
940
  startDividerAnimation();