mini-coder 0.5.9 → 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 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; streaming deltas are omitted.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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/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 typeof record.content === "string";
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(content: string): UiInfoMessage {
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
  }
@@ -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 = [
@@ -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: (text: string, state: AppState) => void;
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: [
@@ -1161,14 +1161,28 @@ function renderUiTodoMessage(
1161
1161
  }
1162
1162
 
1163
1163
  /** Render an internal UI message in the conversation log. */
1164
- function renderUiMessage(msg: UiMessage, theme: Theme): Node {
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, theme);
1248
+ return renderUiMessage(message, renderOpts);
1235
1249
  }
1236
1250
  if (message.role === "user") {
1237
1251
  return renderUserMessage(message, theme);
@@ -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 includes current reasoning and verbose state", () => {
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
- `/reasoning Toggle thinking display (currently ${DEFAULT_SHOW_REASONING ? "on" : "off"})`,
22
+ `- \`/reasoning\` — Toggle thinking display _(currently ${DEFAULT_SHOW_REASONING ? "on" : "off"})_`,
21
23
  );
22
24
  expect(text).toContain(
23
- "/verbose Toggle verbose tool rendering (currently off)",
25
+ "- `/verbose` — Toggle verbose tool rendering _(currently off)_",
24
26
  );
25
- expect(text).toContain("/todo Show the current todo list");
27
+ expect(text).toContain("- `/todo` — Show the current todo list");
26
28
  });
27
29
 
28
- test("buildHelpText describes the current Escape behavior", () => {
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
- "Escape closes the current overlay and returns focus to the input",
47
+ "- `Tab` opens command autocomplete when the draft starts with `/`.",
43
48
  );
44
49
  expect(text).toContain(
45
- "With no overlay open, Escape interrupts the current turn",
50
+ "- Otherwise, `Tab` autocompletes file paths and can open a path picker when there are multiple matches.",
46
51
  );
47
- expect(text).toContain("Otherwise Escape does nothing");
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 Human-readable command description.
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(` /${command} ${getHelpCommandDescription(command, state)}`);
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
- ? `Providers: ${providerNames.join(", ")}`
93
- : "Providers: none (use /login)",
94
- );
95
-
96
- lines.push(
109
+ ? `- Providers: ${formatInlineCodeList(providerNames)}`
110
+ : "- Providers: none — use `/login`",
97
111
  state.model
98
- ? `Model: ${state.model.provider}/${state.model.id}`
99
- : "Model: none (use /model)",
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(` ${abbreviatePath(agentFile.path)}`);
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
- const description = skill.description ? ` ${skill.description}` : "";
115
- lines.push(` ${skill.name}${description}`);
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(` ${plugin.entry.name}`);
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 {
@@ -660,9 +661,14 @@ function appendUiMessage(
660
661
  *
661
662
  * @param text - Display text to append.
662
663
  * @param state - Application state.
664
+ * @param format - Optional rich-text format hint for the content.
663
665
  */
664
- function appendInfoMessage(text: string, state: AppState): void {
665
- appendUiMessage(createUiMessage(text), state);
666
+ function appendInfoMessage(
667
+ text: string,
668
+ state: AppState,
669
+ format?: UiInfoFormat,
670
+ ): void {
671
+ appendUiMessage(createUiMessage(text, format), state);
666
672
  }
667
673
 
668
674
  /**