mini-coder 0.5.8 → 0.5.10
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 +1 -1
- package/package.json +1 -1
- package/src/input.ts +1 -1
- package/src/prompt.ts +1 -0
- package/src/session.ts +15 -2
- package/src/tools.ts +2 -1
- package/src/ui/commands.test.ts +38 -0
- package/src/ui/commands.ts +7 -2
- package/src/ui/conversation.test.ts +51 -0
- package/src/ui/conversation.ts +18 -4
- package/src/ui/help.test.ts +23 -8
- package/src/ui/help.ts +47 -33
- package/src/ui.ts +202 -20
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ $ printf '%s\n' 'fix the failing tests' | mc
|
|
|
103
103
|
- Starts when `-p/--prompt` is provided or when stdin or stdout is not a TTY.
|
|
104
104
|
- If stdout is redirected but stdin is still interactive, pass `-p`; headless mode will not fall back to an interactive prompt.
|
|
105
105
|
- Uses the same parser as the TUI for plain text, `/skill:name`, and standalone image paths.
|
|
106
|
-
- With `--json`, writes NDJSON events for completed assistant/tool-result messages plus `done` / `error` / `aborted` outcomes;
|
|
106
|
+
- With `--json`, writes NDJSON events for completed assistant/tool-result messages plus `done` / `error` / `aborted` outcomes; queued `user_message` events may also appear. Streaming deltas are omitted.
|
|
107
107
|
- Headless runs still persist like normal sessions and show up in `/session` history for that working directory.
|
|
108
108
|
- Interactive slash commands such as `/model`, `/session`, and `/help` are not available in headless mode.
|
|
109
109
|
|
package/package.json
CHANGED
package/src/input.ts
CHANGED
|
@@ -17,7 +17,6 @@ import { extname, isAbsolute, join } from "node:path";
|
|
|
17
17
|
|
|
18
18
|
/** All recognized slash commands. */
|
|
19
19
|
export const COMMANDS = [
|
|
20
|
-
"model",
|
|
21
20
|
"session",
|
|
22
21
|
"new",
|
|
23
22
|
"fork",
|
|
@@ -28,6 +27,7 @@ export const COMMANDS = [
|
|
|
28
27
|
"login",
|
|
29
28
|
"logout",
|
|
30
29
|
"help",
|
|
30
|
+
"model",
|
|
31
31
|
"effort",
|
|
32
32
|
] as const;
|
|
33
33
|
|
package/src/prompt.ts
CHANGED
|
@@ -246,6 +246,7 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
|
|
|
246
246
|
"- Check requirements, and plan your changes before editing code.",
|
|
247
247
|
"- Implement the necessary changes, following good practices and proper error handling.",
|
|
248
248
|
"- Always verify your changes using compilation, testing, and manual verification when possible.",
|
|
249
|
+
"- When verifying with build or test commands, avoid leaving generated binaries or scratch artifacts in the requested output location; use temporary paths or remove them before finishing.",
|
|
249
250
|
"- Do not leave helpers, tests, or any other form of temporary files; clean up after yourself and leave no trace.",
|
|
250
251
|
"- Ensure you match the requested output exactly. This applies to file names, directory structure, number of files, output formats, and all other details.",
|
|
251
252
|
'- "Polish" is not optional; it counts just as much as solving the task.',
|
package/src/session.ts
CHANGED
|
@@ -95,6 +95,9 @@ interface AppendPromptHistoryOpts {
|
|
|
95
95
|
sessionId?: string;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Rich-text format hints supported by persisted UI info messages. */
|
|
99
|
+
export type UiInfoFormat = "markdown";
|
|
100
|
+
|
|
98
101
|
/** A persisted UI-only info message shown in the conversation log. */
|
|
99
102
|
export interface UiInfoMessage {
|
|
100
103
|
/** Identifies this as an internal UI message. */
|
|
@@ -103,6 +106,8 @@ export interface UiInfoMessage {
|
|
|
103
106
|
kind: "info";
|
|
104
107
|
/** Display text shown in the conversation log. */
|
|
105
108
|
content: string;
|
|
109
|
+
/** Optional rich-text format hint for the content. */
|
|
110
|
+
format?: UiInfoFormat;
|
|
106
111
|
/** Unix timestamp in milliseconds. */
|
|
107
112
|
timestamp: number;
|
|
108
113
|
}
|
|
@@ -513,7 +518,10 @@ function isUiMessageRecord(value: unknown): value is UiMessage {
|
|
|
513
518
|
}
|
|
514
519
|
|
|
515
520
|
if (record.kind === "info") {
|
|
516
|
-
return
|
|
521
|
+
return (
|
|
522
|
+
typeof record.content === "string" &&
|
|
523
|
+
(record.format === undefined || record.format === "markdown")
|
|
524
|
+
);
|
|
517
525
|
}
|
|
518
526
|
|
|
519
527
|
return (
|
|
@@ -629,13 +637,18 @@ export function truncateSessions(
|
|
|
629
637
|
* Create a persisted UI info message.
|
|
630
638
|
*
|
|
631
639
|
* @param content - Display text shown in the conversation log.
|
|
640
|
+
* @param format - Optional rich-text format hint for the content.
|
|
632
641
|
* @returns A new {@link UiInfoMessage}.
|
|
633
642
|
*/
|
|
634
|
-
export function createUiMessage(
|
|
643
|
+
export function createUiMessage(
|
|
644
|
+
content: string,
|
|
645
|
+
format?: UiInfoFormat,
|
|
646
|
+
): UiInfoMessage {
|
|
635
647
|
return {
|
|
636
648
|
role: "ui",
|
|
637
649
|
kind: "info",
|
|
638
650
|
content,
|
|
651
|
+
...(format ? { format } : {}),
|
|
639
652
|
timestamp: Date.now(),
|
|
640
653
|
};
|
|
641
654
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1574,7 +1574,8 @@ export const shellTool: Tool = {
|
|
|
1574
1574
|
name: "shell",
|
|
1575
1575
|
description:
|
|
1576
1576
|
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
1577
|
-
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands."
|
|
1577
|
+
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands. " +
|
|
1578
|
+
"Commands mutate the real working directory, so direct verification outputs to temporary paths or clean them up before finishing.",
|
|
1578
1579
|
parameters: Type.Object({
|
|
1579
1580
|
command: Type.String({ description: "The shell command to execute" }),
|
|
1580
1581
|
}),
|
package/src/ui/commands.test.ts
CHANGED
|
@@ -520,6 +520,44 @@ describe("ui/commands", () => {
|
|
|
520
520
|
}
|
|
521
521
|
});
|
|
522
522
|
|
|
523
|
+
test("/help appends markdown help without creating a session", () => {
|
|
524
|
+
const state = createTestState();
|
|
525
|
+
const appended: Array<{
|
|
526
|
+
text: string;
|
|
527
|
+
format: string | undefined;
|
|
528
|
+
sessionId: string | null;
|
|
529
|
+
}> = [];
|
|
530
|
+
const controller = createCommandController({
|
|
531
|
+
openOverlay: () => {},
|
|
532
|
+
dismissOverlay: () => {},
|
|
533
|
+
setInputValue: () => {},
|
|
534
|
+
appendInfoMessage: (text, nextState, format) => {
|
|
535
|
+
appended.push({
|
|
536
|
+
text,
|
|
537
|
+
format,
|
|
538
|
+
sessionId: nextState.session?.id ?? null,
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
appendTodoMessage: () => {},
|
|
542
|
+
scrollConversationToBottom: () => {},
|
|
543
|
+
render: () => {},
|
|
544
|
+
reloadPromptContext: async () => {},
|
|
545
|
+
openInBrowser: () => {},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
expect(controller.handleCommand("help", state)).toBe(true);
|
|
550
|
+
expect(appended).toHaveLength(1);
|
|
551
|
+
expect(appended[0]?.text).toContain("# Help");
|
|
552
|
+
expect(appended[0]?.text).toContain("## Commands");
|
|
553
|
+
expect(appended[0]?.format).toBe("markdown");
|
|
554
|
+
expect(appended[0]?.sessionId).toBeNull();
|
|
555
|
+
expect(state.session).toBeNull();
|
|
556
|
+
} finally {
|
|
557
|
+
state.db.close();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
523
561
|
test("/todo appends the current todo list without creating a session", () => {
|
|
524
562
|
const state = createTestState();
|
|
525
563
|
state.messages = [
|
package/src/ui/commands.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
listSessions,
|
|
27
27
|
loadMessages,
|
|
28
28
|
type SessionListEntry,
|
|
29
|
+
type UiInfoFormat,
|
|
29
30
|
undoLastTurn,
|
|
30
31
|
} from "../session.ts";
|
|
31
32
|
import { updateSettings } from "../settings.ts";
|
|
@@ -51,7 +52,11 @@ interface UiCommandRuntime {
|
|
|
51
52
|
/** Update the current input draft. */
|
|
52
53
|
setInputValue: (value: string) => void;
|
|
53
54
|
/** Append a UI-only info message to the conversation log. */
|
|
54
|
-
appendInfoMessage: (
|
|
55
|
+
appendInfoMessage: (
|
|
56
|
+
text: string,
|
|
57
|
+
state: AppState,
|
|
58
|
+
format?: UiInfoFormat,
|
|
59
|
+
) => void;
|
|
55
60
|
/** Append a UI-only todo snapshot to the conversation log. */
|
|
56
61
|
appendTodoMessage: (
|
|
57
62
|
todos: ReturnType<typeof getTodoItems>,
|
|
@@ -583,7 +588,7 @@ export function createCommandController(
|
|
|
583
588
|
};
|
|
584
589
|
|
|
585
590
|
const handleHelpCommand = (state: AppState): void => {
|
|
586
|
-
runtime.appendInfoMessage(buildHelpText(state), state);
|
|
591
|
+
runtime.appendInfoMessage(buildHelpText(state), state, "markdown");
|
|
587
592
|
};
|
|
588
593
|
|
|
589
594
|
const handleTodoCommand = (state: AppState): void => {
|
|
@@ -1372,6 +1372,57 @@ describe("ui/conversation", () => {
|
|
|
1372
1372
|
expect(text.some((line) => line.startsWith("And "))).toBe(false);
|
|
1373
1373
|
});
|
|
1374
1374
|
|
|
1375
|
+
test("buildConversationLogNodes syntax-highlights markdown-formatted UI info messages", async () => {
|
|
1376
|
+
const theme = {
|
|
1377
|
+
...DEFAULT_THEME,
|
|
1378
|
+
accentText: "color14",
|
|
1379
|
+
secondaryAccentText: "color09",
|
|
1380
|
+
diffAdded: "color10",
|
|
1381
|
+
} satisfies typeof DEFAULT_THEME;
|
|
1382
|
+
const state = {
|
|
1383
|
+
messages: [
|
|
1384
|
+
{
|
|
1385
|
+
role: "ui" as const,
|
|
1386
|
+
kind: "info" as const,
|
|
1387
|
+
format: "markdown" as const,
|
|
1388
|
+
content: "# Help\n\n## Commands\n\n- `/model` — Select a model",
|
|
1389
|
+
timestamp: 1,
|
|
1390
|
+
},
|
|
1391
|
+
],
|
|
1392
|
+
showReasoning: false,
|
|
1393
|
+
verbose: false,
|
|
1394
|
+
theme,
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const rows = await renderBufferRows(
|
|
1398
|
+
VStack(
|
|
1399
|
+
{},
|
|
1400
|
+
buildConversationLogNodes(
|
|
1401
|
+
state,
|
|
1402
|
+
{ isStreaming: false, content: [], pendingToolResults: [] },
|
|
1403
|
+
0,
|
|
1404
|
+
PREVIEW_WIDTH,
|
|
1405
|
+
),
|
|
1406
|
+
),
|
|
1407
|
+
PREVIEW_WIDTH,
|
|
1408
|
+
24,
|
|
1409
|
+
);
|
|
1410
|
+
const headingRow = rows.find((row) => row.text.includes("# Help"));
|
|
1411
|
+
const bulletRow = rows.find((row) => row.text.includes("- `/model`"));
|
|
1412
|
+
|
|
1413
|
+
expect(headingRow).toBeDefined();
|
|
1414
|
+
expect(bulletRow).toBeDefined();
|
|
1415
|
+
expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
|
|
1416
|
+
theme.accentText ?? null,
|
|
1417
|
+
);
|
|
1418
|
+
expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
|
|
1419
|
+
theme.secondaryAccentText ?? null,
|
|
1420
|
+
);
|
|
1421
|
+
expect(bulletRow?.fgColors[bulletRow.text.indexOf("`")]).toBe(
|
|
1422
|
+
theme.diffAdded ?? null,
|
|
1423
|
+
);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1375
1426
|
test("buildConversationLogNodes renders UI todo messages with the shared checklist block", async () => {
|
|
1376
1427
|
const state = {
|
|
1377
1428
|
messages: [
|
package/src/ui/conversation.ts
CHANGED
|
@@ -1161,14 +1161,28 @@ function renderUiTodoMessage(
|
|
|
1161
1161
|
}
|
|
1162
1162
|
|
|
1163
1163
|
/** Render an internal UI message in the conversation log. */
|
|
1164
|
-
function renderUiMessage(
|
|
1164
|
+
function renderUiMessage(
|
|
1165
|
+
msg: UiMessage,
|
|
1166
|
+
opts: Pick<ConversationRenderOpts, "previewWidth" | "theme">,
|
|
1167
|
+
): Node {
|
|
1165
1168
|
if (msg.kind === "todo") {
|
|
1166
|
-
return renderUiTodoMessage(msg, theme);
|
|
1169
|
+
return renderUiTodoMessage(msg, opts.theme);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (msg.format === "markdown") {
|
|
1173
|
+
const markdown = renderMarkdownTextBlock(
|
|
1174
|
+
msg.content,
|
|
1175
|
+
opts.theme,
|
|
1176
|
+
opts.previewWidth,
|
|
1177
|
+
);
|
|
1178
|
+
if (markdown) {
|
|
1179
|
+
return markdown;
|
|
1180
|
+
}
|
|
1167
1181
|
}
|
|
1168
1182
|
|
|
1169
1183
|
return VStack({ padding: { x: 1 } }, [
|
|
1170
1184
|
Text(msg.content, {
|
|
1171
|
-
fgColor: theme.mutedText,
|
|
1185
|
+
fgColor: opts.theme.mutedText,
|
|
1172
1186
|
italic: true,
|
|
1173
1187
|
wrap: "word",
|
|
1174
1188
|
}),
|
|
@@ -1231,7 +1245,7 @@ function renderConversationMessage(
|
|
|
1231
1245
|
theme: Theme,
|
|
1232
1246
|
): Node | null {
|
|
1233
1247
|
if (message.role === "ui") {
|
|
1234
|
-
return renderUiMessage(message,
|
|
1248
|
+
return renderUiMessage(message, renderOpts);
|
|
1235
1249
|
}
|
|
1236
1250
|
if (message.role === "user") {
|
|
1237
1251
|
return renderUserMessage(message, theme);
|
package/src/ui/help.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_SHOW_REASONING } from "../settings.ts";
|
|
|
3
3
|
import { buildHelpText, type HelpRenderState } from "./help.ts";
|
|
4
4
|
|
|
5
5
|
describe("ui/help", () => {
|
|
6
|
-
test("buildHelpText
|
|
6
|
+
test("buildHelpText formats commands as markdown list items with current state", () => {
|
|
7
7
|
const helpState: HelpRenderState = {
|
|
8
8
|
providers: new Map(),
|
|
9
9
|
model: null,
|
|
@@ -16,16 +16,18 @@ describe("ui/help", () => {
|
|
|
16
16
|
|
|
17
17
|
const text = buildHelpText(helpState);
|
|
18
18
|
|
|
19
|
+
expect(text).toContain("# Help");
|
|
20
|
+
expect(text).toContain("## Commands");
|
|
19
21
|
expect(text).toContain(
|
|
20
|
-
|
|
22
|
+
`- \`/reasoning\` — Toggle thinking display _(currently ${DEFAULT_SHOW_REASONING ? "on" : "off"})_`,
|
|
21
23
|
);
|
|
22
24
|
expect(text).toContain(
|
|
23
|
-
"
|
|
25
|
+
"- `/verbose` — Toggle verbose tool rendering _(currently off)_",
|
|
24
26
|
);
|
|
25
|
-
expect(text).toContain("
|
|
27
|
+
expect(text).toContain("- `/todo` — Show the current todo list");
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
test("buildHelpText
|
|
30
|
+
test("buildHelpText lists the supported keyboard shortcuts in markdown", () => {
|
|
29
31
|
const helpState: HelpRenderState = {
|
|
30
32
|
providers: new Map(),
|
|
31
33
|
model: null,
|
|
@@ -38,13 +40,26 @@ describe("ui/help", () => {
|
|
|
38
40
|
|
|
39
41
|
const text = buildHelpText(helpState);
|
|
40
42
|
|
|
43
|
+
expect(text).toContain("## Keyboard");
|
|
44
|
+
expect(text).toContain("- `Enter` submits the current draft.");
|
|
45
|
+
expect(text).toContain("- `Shift+Enter` inserts a newline.");
|
|
41
46
|
expect(text).toContain(
|
|
42
|
-
"
|
|
47
|
+
"- `Tab` opens command autocomplete when the draft starts with `/`.",
|
|
43
48
|
);
|
|
44
49
|
expect(text).toContain(
|
|
45
|
-
"
|
|
50
|
+
"- Otherwise, `Tab` autocompletes file paths and can open a path picker when there are multiple matches.",
|
|
46
51
|
);
|
|
47
|
-
expect(text).toContain("
|
|
52
|
+
expect(text).toContain("- `Ctrl+R` opens global input history search.");
|
|
53
|
+
expect(text).toContain(
|
|
54
|
+
"- `Escape` closes the current overlay and returns focus to the input.",
|
|
55
|
+
);
|
|
56
|
+
expect(text).toContain(
|
|
57
|
+
"- With no overlay open, `Escape` interrupts the current turn.",
|
|
58
|
+
);
|
|
59
|
+
expect(text).toContain("- Otherwise, `Escape` does nothing.");
|
|
60
|
+
expect(text).toContain("- `Ctrl+C` exits gracefully.");
|
|
61
|
+
expect(text).toContain("- `Ctrl+D` exits when the input is empty.");
|
|
62
|
+
expect(text).toContain("- `Ctrl+Z` suspends the app to the background.");
|
|
48
63
|
expect(text).not.toContain("Escape blurs the input first");
|
|
49
64
|
});
|
|
50
65
|
});
|
package/src/ui/help.ts
CHANGED
|
@@ -47,7 +47,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
47
47
|
*
|
|
48
48
|
* @param command - Command name.
|
|
49
49
|
* @param state - Help-relevant application state.
|
|
50
|
-
* @returns
|
|
50
|
+
* @returns Markdown-ready command description.
|
|
51
51
|
*/
|
|
52
52
|
function getHelpCommandDescription(
|
|
53
53
|
command: (typeof COMMANDS)[number],
|
|
@@ -55,72 +55,86 @@ function getHelpCommandDescription(
|
|
|
55
55
|
): string {
|
|
56
56
|
const description = COMMAND_DESCRIPTIONS[command] ?? "";
|
|
57
57
|
if (command === "reasoning") {
|
|
58
|
-
return `${description} (currently ${state.showReasoning ? "on" : "off"})`;
|
|
58
|
+
return `${description} _(currently ${state.showReasoning ? "on" : "off"})_`;
|
|
59
59
|
}
|
|
60
60
|
if (command === "verbose") {
|
|
61
|
-
return `${description} (currently ${state.verbose ? "on" : "off"})`;
|
|
61
|
+
return `${description} _(currently ${state.verbose ? "on" : "off"})_`;
|
|
62
62
|
}
|
|
63
63
|
return description;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function formatInlineCode(text: string): string {
|
|
67
|
+
return `\`${text}\``;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatInlineCodeList(items: readonly string[]): string {
|
|
71
|
+
return items.map((item) => formatInlineCode(item)).join(", ");
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
/**
|
|
67
75
|
* Build the `/help` text shown in the conversation log.
|
|
68
76
|
*
|
|
69
77
|
* @param state - Help-relevant application state.
|
|
70
|
-
* @returns Multi-line help text for display.
|
|
78
|
+
* @returns Multi-line markdown help text for display.
|
|
71
79
|
*/
|
|
72
80
|
export function buildHelpText(state: HelpRenderState): string {
|
|
73
|
-
const lines: string[] = [];
|
|
81
|
+
const lines: string[] = ["# Help", "", "## Commands", ""];
|
|
74
82
|
|
|
75
|
-
lines.push("Commands:");
|
|
76
83
|
for (const command of COMMANDS) {
|
|
77
|
-
lines.push(
|
|
84
|
+
lines.push(
|
|
85
|
+
`- ${formatInlineCode(`/${command}`)} — ${getHelpCommandDescription(command, state)}`,
|
|
86
|
+
);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
const providerNames = Array.from(state.providers.keys());
|
|
81
|
-
lines.push("");
|
|
82
|
-
lines.push("Note:");
|
|
83
|
-
lines.push(
|
|
84
|
-
" Escape closes the current overlay and returns focus to the input.",
|
|
85
|
-
);
|
|
86
|
-
lines.push(" With no overlay open, Escape interrupts the current turn.");
|
|
87
|
-
lines.push(" Otherwise Escape does nothing.");
|
|
88
|
-
|
|
89
|
-
lines.push("");
|
|
90
90
|
lines.push(
|
|
91
|
+
"",
|
|
92
|
+
"## Keyboard",
|
|
93
|
+
"",
|
|
94
|
+
"- `Enter` submits the current draft.",
|
|
95
|
+
"- `Shift+Enter` inserts a newline.",
|
|
96
|
+
"- `Tab` opens command autocomplete when the draft starts with `/`.",
|
|
97
|
+
"- Otherwise, `Tab` autocompletes file paths and can open a path picker when there are multiple matches.",
|
|
98
|
+
"- `Ctrl+R` opens global input history search.",
|
|
99
|
+
"- `Escape` closes the current overlay and returns focus to the input.",
|
|
100
|
+
"- With no overlay open, `Escape` interrupts the current turn.",
|
|
101
|
+
"- Otherwise, `Escape` does nothing.",
|
|
102
|
+
"- `Ctrl+C` exits gracefully.",
|
|
103
|
+
"- `Ctrl+D` exits when the input is empty.",
|
|
104
|
+
"- `Ctrl+Z` suspends the app to the background.",
|
|
105
|
+
"",
|
|
106
|
+
"## Current state",
|
|
107
|
+
"",
|
|
91
108
|
providerNames.length > 0
|
|
92
|
-
?
|
|
93
|
-
: "Providers: none
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
lines.push(
|
|
109
|
+
? `- Providers: ${formatInlineCodeList(providerNames)}`
|
|
110
|
+
: "- Providers: none — use `/login`",
|
|
97
111
|
state.model
|
|
98
|
-
?
|
|
99
|
-
: "Model: none
|
|
112
|
+
? `- Model: ${formatInlineCode(`${state.model.provider}/${state.model.id}`)}`
|
|
113
|
+
: "- Model: none — use `/model`",
|
|
100
114
|
);
|
|
101
115
|
|
|
102
116
|
if (state.agentsMd.length > 0) {
|
|
103
|
-
lines.push("");
|
|
104
|
-
lines.push("AGENTS.md files:");
|
|
117
|
+
lines.push("", "## Loaded `AGENTS.md` files", "");
|
|
105
118
|
for (const agentFile of state.agentsMd) {
|
|
106
|
-
lines.push(
|
|
119
|
+
lines.push(`- ${formatInlineCode(abbreviatePath(agentFile.path))}`);
|
|
107
120
|
}
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
if (state.skills.length > 0) {
|
|
111
|
-
lines.push("");
|
|
112
|
-
lines.push("Skills:");
|
|
124
|
+
lines.push("", "## Skills", "");
|
|
113
125
|
for (const skill of state.skills) {
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
lines.push(
|
|
127
|
+
skill.description
|
|
128
|
+
? `- ${formatInlineCode(skill.name)} — ${skill.description}`
|
|
129
|
+
: `- ${formatInlineCode(skill.name)}`,
|
|
130
|
+
);
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
if (state.plugins.length > 0) {
|
|
120
|
-
lines.push("");
|
|
121
|
-
lines.push("Plugins:");
|
|
135
|
+
lines.push("", "## Plugins", "");
|
|
122
136
|
for (const plugin of state.plugins) {
|
|
123
|
-
lines.push(
|
|
137
|
+
lines.push(`- ${formatInlineCode(plugin.entry.name)}`);
|
|
124
138
|
}
|
|
125
139
|
}
|
|
126
140
|
|
package/src/ui.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
appendMessage,
|
|
27
27
|
createUiMessage,
|
|
28
28
|
createUiTodoMessage,
|
|
29
|
+
type UiInfoFormat,
|
|
29
30
|
} from "./session.ts";
|
|
30
31
|
import type { Theme } from "./theme.ts";
|
|
31
32
|
import {
|
|
@@ -64,6 +65,23 @@ const DIVIDER_FRAME_MS = 60;
|
|
|
64
65
|
/** Width of the bright pulse segment in the animated divider. */
|
|
65
66
|
const PULSE_WIDTH = 5;
|
|
66
67
|
|
|
68
|
+
/** Number of trailing words shown in the idle terminal-title preview. */
|
|
69
|
+
const TERMINAL_TITLE_WORD_COUNT = 5;
|
|
70
|
+
|
|
71
|
+
/** Divider ticks spent on each animated terminal-title scanner frame. */
|
|
72
|
+
const TERMINAL_TITLE_TICKS_PER_FRAME = 4;
|
|
73
|
+
|
|
74
|
+
/** Frames used for the active terminal-title glow-scanner animation. */
|
|
75
|
+
const TERMINAL_TITLE_FRAMES = [
|
|
76
|
+
"[=o---]",
|
|
77
|
+
"[-=o--]",
|
|
78
|
+
"[--=o-]",
|
|
79
|
+
"[---=o]",
|
|
80
|
+
"[--o=-]",
|
|
81
|
+
"[-o=--]",
|
|
82
|
+
"[o=---]",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
67
85
|
/** Maximum number of committed messages rendered before older history is chunked. */
|
|
68
86
|
const CONVERSATION_CHUNK_MESSAGES = 50;
|
|
69
87
|
|
|
@@ -109,6 +127,15 @@ let dividerTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
109
127
|
/** Whether stdin was already in raw mode before the TUI initialized. */
|
|
110
128
|
let stdinWasRaw = false;
|
|
111
129
|
|
|
130
|
+
/** Latest application state associated with the active terminal UI. */
|
|
131
|
+
let titleState: AppState | null = null;
|
|
132
|
+
|
|
133
|
+
/** Whether a cel viewport has rendered for the current UI session. */
|
|
134
|
+
let titleViewportActive = false;
|
|
135
|
+
|
|
136
|
+
/** Last terminal title written during the current UI session. */
|
|
137
|
+
let lastTerminalTitle: string | null = null;
|
|
138
|
+
|
|
112
139
|
// ---------------------------------------------------------------------------
|
|
113
140
|
// Overlay state
|
|
114
141
|
// ---------------------------------------------------------------------------
|
|
@@ -150,6 +177,148 @@ export function resetUiState(): void {
|
|
|
150
177
|
resetConversationRenderCache();
|
|
151
178
|
activeOverlay = null;
|
|
152
179
|
stdinWasRaw = false;
|
|
180
|
+
titleState = null;
|
|
181
|
+
titleViewportActive = false;
|
|
182
|
+
lastTerminalTitle = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Terminal title
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
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
|
+
function getUserTerminalTitleText(
|
|
195
|
+
content: Extract<AppState["messages"][number], { role: "user" }>["content"],
|
|
196
|
+
): string | null {
|
|
197
|
+
if (typeof content === "string") {
|
|
198
|
+
return collapseTerminalTitleText(content);
|
|
199
|
+
}
|
|
200
|
+
|
|
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);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getAssistantTerminalTitleText(
|
|
214
|
+
content: Extract<
|
|
215
|
+
AppState["messages"][number],
|
|
216
|
+
{ role: "assistant" }
|
|
217
|
+
>["content"],
|
|
218
|
+
): 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);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function truncateTerminalTitleTail(text: string): string {
|
|
240
|
+
const words = text.split(" ");
|
|
241
|
+
if (words.length <= TERMINAL_TITLE_WORD_COUNT) {
|
|
242
|
+
return text;
|
|
243
|
+
}
|
|
244
|
+
return `...${words.slice(-TERMINAL_TITLE_WORD_COUNT).join(" ")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildIdleTerminalTitle(state: Pick<AppState, "messages">): string {
|
|
248
|
+
for (let index = state.messages.length - 1; index >= 0; index -= 1) {
|
|
249
|
+
const message = state.messages[index];
|
|
250
|
+
if (!message) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let text: string | null = null;
|
|
255
|
+
switch (message.role) {
|
|
256
|
+
case "user":
|
|
257
|
+
text = getUserTerminalTitleText(message.content);
|
|
258
|
+
break;
|
|
259
|
+
case "assistant":
|
|
260
|
+
text = getAssistantTerminalTitleText(message.content);
|
|
261
|
+
break;
|
|
262
|
+
case "toolResult":
|
|
263
|
+
case "ui":
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (text) {
|
|
268
|
+
return `mc - ${truncateTerminalTitleTail(text)}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return "mc";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build the current terminal title from UI state.
|
|
277
|
+
*
|
|
278
|
+
* Idle titles show a short tail preview from the latest conversational text
|
|
279
|
+
* message. Active turns show a stable-width glow scanner.
|
|
280
|
+
*
|
|
281
|
+
* @param state - Application state needed to derive the title.
|
|
282
|
+
* @param animationTick - Divider animation tick used to pick the scanner frame.
|
|
283
|
+
* @returns The terminal title text to write via cel-tui.
|
|
284
|
+
*/
|
|
285
|
+
export function buildTerminalTitle(
|
|
286
|
+
state: Pick<AppState, "messages" | "running">,
|
|
287
|
+
animationTick = dividerTick,
|
|
288
|
+
): string {
|
|
289
|
+
if (!state.running) {
|
|
290
|
+
return buildIdleTerminalTitle(state);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const frameIndex =
|
|
294
|
+
Math.floor(animationTick / TERMINAL_TITLE_TICKS_PER_FRAME) %
|
|
295
|
+
TERMINAL_TITLE_FRAMES.length;
|
|
296
|
+
return `mc - ${TERMINAL_TITLE_FRAMES[frameIndex]}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function syncTerminalTitle(
|
|
300
|
+
state: Pick<AppState, "messages" | "running"> | null,
|
|
301
|
+
): void {
|
|
302
|
+
if (!state || !titleViewportActive) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const title = buildTerminalTitle(state);
|
|
307
|
+
if (title === lastTerminalTitle) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
cel.setTitle(title);
|
|
312
|
+
lastTerminalTitle = title;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function invalidateTerminalTitleCache(): void {
|
|
316
|
+
lastTerminalTitle = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function requestRender(): void {
|
|
320
|
+
syncTerminalTitle(titleState);
|
|
321
|
+
cel.render();
|
|
153
322
|
}
|
|
154
323
|
|
|
155
324
|
// ---------------------------------------------------------------------------
|
|
@@ -162,7 +331,7 @@ function startDividerAnimation(): void {
|
|
|
162
331
|
dividerTick = 0;
|
|
163
332
|
dividerTimer = setInterval(() => {
|
|
164
333
|
dividerTick++;
|
|
165
|
-
|
|
334
|
+
requestRender();
|
|
166
335
|
}, DIVIDER_FRAME_MS);
|
|
167
336
|
}
|
|
168
337
|
|
|
@@ -286,14 +455,14 @@ function prependConversationChunk(state: AppState, width: number): void {
|
|
|
286
455
|
function openOverlay(overlay: ActiveOverlay): void {
|
|
287
456
|
activeOverlay = overlay;
|
|
288
457
|
inputFocused = false;
|
|
289
|
-
|
|
458
|
+
requestRender();
|
|
290
459
|
}
|
|
291
460
|
|
|
292
461
|
/** Dismiss the active overlay and return focus to the input. */
|
|
293
462
|
function dismissOverlay(): void {
|
|
294
463
|
activeOverlay = null;
|
|
295
464
|
inputFocused = true;
|
|
296
|
-
|
|
465
|
+
requestRender();
|
|
297
466
|
}
|
|
298
467
|
|
|
299
468
|
function openPathAutocompleteOverlay(state: AppState): void {
|
|
@@ -331,7 +500,7 @@ function handleTabKeyPress(state: AppState): void {
|
|
|
331
500
|
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
332
501
|
if (completedInput) {
|
|
333
502
|
inputValue = completedInput;
|
|
334
|
-
|
|
503
|
+
requestRender();
|
|
335
504
|
return;
|
|
336
505
|
}
|
|
337
506
|
|
|
@@ -366,18 +535,20 @@ export function renderActiveOverlay(state: AppState): Node | null {
|
|
|
366
535
|
* @returns Stable callbacks for the controlled TextInput.
|
|
367
536
|
*/
|
|
368
537
|
export function createInputController(state: AppState): InputController {
|
|
538
|
+
titleState = state;
|
|
539
|
+
|
|
369
540
|
return {
|
|
370
541
|
onChange: (value) => {
|
|
371
542
|
inputValue = value;
|
|
372
|
-
|
|
543
|
+
requestRender();
|
|
373
544
|
},
|
|
374
545
|
onFocus: () => {
|
|
375
546
|
inputFocused = true;
|
|
376
|
-
|
|
547
|
+
requestRender();
|
|
377
548
|
},
|
|
378
549
|
onBlur: () => {
|
|
379
550
|
inputFocused = false;
|
|
380
|
-
|
|
551
|
+
requestRender();
|
|
381
552
|
},
|
|
382
553
|
onKeyPress: (key) => {
|
|
383
554
|
if (key === "enter") {
|
|
@@ -385,13 +556,13 @@ export function createInputController(state: AppState): InputController {
|
|
|
385
556
|
|
|
386
557
|
if (isQuitInput(raw)) {
|
|
387
558
|
inputValue = "";
|
|
388
|
-
|
|
559
|
+
requestRender();
|
|
389
560
|
requestGracefulExit(state);
|
|
390
561
|
return false;
|
|
391
562
|
}
|
|
392
563
|
|
|
393
564
|
inputValue = "";
|
|
394
|
-
|
|
565
|
+
requestRender();
|
|
395
566
|
handleInput(raw, state);
|
|
396
567
|
return false;
|
|
397
568
|
}
|
|
@@ -479,7 +650,7 @@ function appendUiMessage(
|
|
|
479
650
|
}
|
|
480
651
|
state.messages.push(message);
|
|
481
652
|
scrollConversationToBottom();
|
|
482
|
-
|
|
653
|
+
requestRender();
|
|
483
654
|
}
|
|
484
655
|
|
|
485
656
|
/**
|
|
@@ -490,9 +661,14 @@ function appendUiMessage(
|
|
|
490
661
|
*
|
|
491
662
|
* @param text - Display text to append.
|
|
492
663
|
* @param state - Application state.
|
|
664
|
+
* @param format - Optional rich-text format hint for the content.
|
|
493
665
|
*/
|
|
494
|
-
function appendInfoMessage(
|
|
495
|
-
|
|
666
|
+
function appendInfoMessage(
|
|
667
|
+
text: string,
|
|
668
|
+
state: AppState,
|
|
669
|
+
format?: UiInfoFormat,
|
|
670
|
+
): void {
|
|
671
|
+
appendUiMessage(createUiMessage(text, format), state);
|
|
496
672
|
}
|
|
497
673
|
|
|
498
674
|
/**
|
|
@@ -521,9 +697,7 @@ const commandController = createCommandController({
|
|
|
521
697
|
appendInfoMessage,
|
|
522
698
|
appendTodoMessage,
|
|
523
699
|
scrollConversationToBottom,
|
|
524
|
-
render:
|
|
525
|
-
cel.render();
|
|
526
|
-
},
|
|
700
|
+
render: requestRender,
|
|
527
701
|
reloadPromptContext,
|
|
528
702
|
openInBrowser,
|
|
529
703
|
});
|
|
@@ -537,9 +711,7 @@ const agentController = createUiAgentController({
|
|
|
537
711
|
appendInfoMessage,
|
|
538
712
|
handleCommand: (command, state) =>
|
|
539
713
|
commandController.handleCommand(command, state),
|
|
540
|
-
render:
|
|
541
|
-
cel.render();
|
|
542
|
-
},
|
|
714
|
+
render: requestRender,
|
|
543
715
|
scrollConversationToBottom,
|
|
544
716
|
startDividerAnimation,
|
|
545
717
|
stopDividerAnimation,
|
|
@@ -547,6 +719,7 @@ const agentController = createUiAgentController({
|
|
|
547
719
|
|
|
548
720
|
/** Route raw user input through parseInput and dispatch accordingly. */
|
|
549
721
|
export function handleInput(raw: string, state: AppState): void {
|
|
722
|
+
titleState = state;
|
|
550
723
|
agentController.handleInput(raw, state);
|
|
551
724
|
}
|
|
552
725
|
|
|
@@ -618,6 +791,7 @@ export function suspendToBackground(
|
|
|
618
791
|
stop();
|
|
619
792
|
onResume(() => {
|
|
620
793
|
clearInterval(keepAlive);
|
|
794
|
+
invalidateTerminalTitleCache();
|
|
621
795
|
resumeUi();
|
|
622
796
|
});
|
|
623
797
|
|
|
@@ -657,7 +831,7 @@ function renderConversationLog(state: AppState, width: number): Node {
|
|
|
657
831
|
prependConversationChunk(state, width);
|
|
658
832
|
}
|
|
659
833
|
|
|
660
|
-
|
|
834
|
+
requestRender();
|
|
661
835
|
},
|
|
662
836
|
},
|
|
663
837
|
buildConversationLog(state, width),
|
|
@@ -681,6 +855,12 @@ export function renderBaseLayout(
|
|
|
681
855
|
inputController: InputController,
|
|
682
856
|
onSuspend?: () => void,
|
|
683
857
|
): Node {
|
|
858
|
+
titleState = state;
|
|
859
|
+
titleViewportActive = true;
|
|
860
|
+
if (lastTerminalTitle === null) {
|
|
861
|
+
syncTerminalTitle(state);
|
|
862
|
+
}
|
|
863
|
+
|
|
684
864
|
return VStack(
|
|
685
865
|
{
|
|
686
866
|
height: "100%",
|
|
@@ -736,6 +916,7 @@ export function renderBaseLayout(
|
|
|
736
916
|
export function startUI(state: AppState): void {
|
|
737
917
|
resetUiState();
|
|
738
918
|
stdinWasRaw = process.stdin.isRaw || false;
|
|
919
|
+
titleState = state;
|
|
739
920
|
const terminal = new ProcessTerminal();
|
|
740
921
|
const inputController = createInputController(state);
|
|
741
922
|
cel.init(terminal);
|
|
@@ -749,7 +930,7 @@ export function startUI(state: AppState): void {
|
|
|
749
930
|
if (state.running) {
|
|
750
931
|
startDividerAnimation();
|
|
751
932
|
}
|
|
752
|
-
|
|
933
|
+
requestRender();
|
|
753
934
|
});
|
|
754
935
|
});
|
|
755
936
|
const overlay = renderActiveOverlay(state);
|
|
@@ -759,6 +940,7 @@ export function startUI(state: AppState): void {
|
|
|
759
940
|
}
|
|
760
941
|
return base;
|
|
761
942
|
});
|
|
943
|
+
syncTerminalTitle(state);
|
|
762
944
|
|
|
763
945
|
if (state.running) {
|
|
764
946
|
startDividerAnimation();
|