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/README.md +5 -2
- package/package.json +1 -1
- package/src/agent.ts +206 -60
- package/src/cli.ts +11 -2
- package/src/headless.ts +185 -47
- package/src/index.ts +78 -10
- package/src/input.ts +1 -0
- package/src/prompt.ts +8 -5
- package/src/session.ts +56 -8
- package/src/submit.ts +0 -5
- package/src/tools.ts +243 -3
- package/src/ui/commands.test.ts +71 -0
- package/src/ui/commands.ts +13 -0
- package/src/ui/conversation.test.ts +91 -0
- package/src/ui/conversation.ts +122 -3
- package/src/ui/help.test.ts +1 -0
- package/src/ui/help.ts +1 -0
- package/src/ui.ts +228 -25
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
+
requestRender();
|
|
369
543
|
},
|
|
370
544
|
onFocus: () => {
|
|
371
545
|
inputFocused = true;
|
|
372
|
-
|
|
546
|
+
requestRender();
|
|
373
547
|
},
|
|
374
548
|
onBlur: () => {
|
|
375
549
|
inputFocused = false;
|
|
376
|
-
|
|
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
|
-
|
|
558
|
+
requestRender();
|
|
385
559
|
requestGracefulExit(state);
|
|
386
560
|
return false;
|
|
387
561
|
}
|
|
388
562
|
|
|
389
563
|
inputValue = "";
|
|
390
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|