mini-coder 0.5.6 → 0.5.8

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.
@@ -91,6 +91,7 @@ function createTestState(): AppState {
91
91
  running: false,
92
92
  abortController: null,
93
93
  activeTurnPromise: null,
94
+ queuedUserMessages: [],
94
95
  showReasoning: true,
95
96
  verbose: false,
96
97
  versionLabel: "dev",
@@ -121,6 +122,7 @@ describe("ui/commands", () => {
121
122
  inputValue = value;
122
123
  },
123
124
  appendInfoMessage: () => {},
125
+ appendTodoMessage: () => {},
124
126
  scrollConversationToBottom: () => {},
125
127
  render: () => {},
126
128
  reloadPromptContext: async () => {},
@@ -160,6 +162,7 @@ describe("ui/commands", () => {
160
162
  inputValue = value;
161
163
  },
162
164
  appendInfoMessage: () => {},
165
+ appendTodoMessage: () => {},
163
166
  scrollConversationToBottom: () => {},
164
167
  render: () => {},
165
168
  reloadPromptContext: async () => {},
@@ -205,6 +208,7 @@ describe("ui/commands", () => {
205
208
  inputValue = value;
206
209
  },
207
210
  appendInfoMessage: () => {},
211
+ appendTodoMessage: () => {},
208
212
  scrollConversationToBottom: () => {},
209
213
  render: () => {},
210
214
  reloadPromptContext: async () => {},
@@ -243,6 +247,7 @@ describe("ui/commands", () => {
243
247
  },
244
248
  setInputValue: () => {},
245
249
  appendInfoMessage: () => {},
250
+ appendTodoMessage: () => {},
246
251
  scrollConversationToBottom: () => {},
247
252
  render: () => {},
248
253
  reloadPromptContext: async () => {},
@@ -309,6 +314,7 @@ describe("ui/commands", () => {
309
314
  },
310
315
  setInputValue: () => {},
311
316
  appendInfoMessage: () => {},
317
+ appendTodoMessage: () => {},
312
318
  scrollConversationToBottom: () => {
313
319
  scrollCalls += 1;
314
320
  },
@@ -392,6 +398,7 @@ describe("ui/commands", () => {
392
398
  dismissOverlay: () => {},
393
399
  setInputValue: () => {},
394
400
  appendInfoMessage: () => {},
401
+ appendTodoMessage: () => {},
395
402
  scrollConversationToBottom: () => {},
396
403
  render: () => {},
397
404
  reloadPromptContext: async () => {},
@@ -414,6 +421,7 @@ describe("ui/commands", () => {
414
421
  dismissOverlay: () => {},
415
422
  setInputValue: () => {},
416
423
  appendInfoMessage: () => {},
424
+ appendTodoMessage: () => {},
417
425
  scrollConversationToBottom: () => {},
418
426
  render: () => {},
419
427
  reloadPromptContext: async (nextState) => {
@@ -469,6 +477,7 @@ describe("ui/commands", () => {
469
477
  dismissOverlay: () => {},
470
478
  setInputValue: () => {},
471
479
  appendInfoMessage: () => {},
480
+ appendTodoMessage: () => {},
472
481
  scrollConversationToBottom: () => {},
473
482
  render: () => {},
474
483
  reloadPromptContext: async () => {},
@@ -494,6 +503,7 @@ describe("ui/commands", () => {
494
503
  dismissOverlay: () => {},
495
504
  setInputValue: () => {},
496
505
  appendInfoMessage: () => {},
506
+ appendTodoMessage: () => {},
497
507
  scrollConversationToBottom: () => {},
498
508
  render: () => {},
499
509
  reloadPromptContext: async () => {},
@@ -510,6 +520,66 @@ describe("ui/commands", () => {
510
520
  }
511
521
  });
512
522
 
523
+ test("/todo appends the current todo list without creating a session", () => {
524
+ const state = createTestState();
525
+ state.messages = [
526
+ {
527
+ role: "toolResult",
528
+ toolCallId: "todo-1",
529
+ toolName: "todoWrite",
530
+ content: [
531
+ {
532
+ type: "text",
533
+ text: JSON.stringify({
534
+ todos: [
535
+ { content: "Review prompt wording", status: "completed" },
536
+ { content: "Implement todo tools", status: "in_progress" },
537
+ ],
538
+ }),
539
+ },
540
+ ],
541
+ isError: false,
542
+ timestamp: 1,
543
+ },
544
+ ];
545
+ const appended: Array<{
546
+ todos: Array<{ content: string; status: string }>;
547
+ sessionId: string | null;
548
+ }> = [];
549
+ const controller = createCommandController({
550
+ openOverlay: () => {},
551
+ dismissOverlay: () => {},
552
+ setInputValue: () => {},
553
+ appendInfoMessage: () => {},
554
+ appendTodoMessage: (todos, nextState) => {
555
+ appended.push({
556
+ todos: todos.map((todo) => ({ ...todo })),
557
+ sessionId: nextState.session?.id ?? null,
558
+ });
559
+ },
560
+ scrollConversationToBottom: () => {},
561
+ render: () => {},
562
+ reloadPromptContext: async () => {},
563
+ openInBrowser: () => {},
564
+ });
565
+
566
+ try {
567
+ expect(controller.handleCommand("todo", state)).toBe(true);
568
+ expect(appended).toEqual([
569
+ {
570
+ todos: [
571
+ { content: "Review prompt wording", status: "completed" },
572
+ { content: "Implement todo tools", status: "in_progress" },
573
+ ],
574
+ sessionId: null,
575
+ },
576
+ ]);
577
+ expect(state.session).toBeNull();
578
+ } finally {
579
+ state.db.close();
580
+ }
581
+ });
582
+
513
583
  test("applyModelSelection updates the state and persists the default model", () => {
514
584
  const faux = registerFauxProvider();
515
585
  const state = createTestState();
@@ -519,6 +589,7 @@ describe("ui/commands", () => {
519
589
  dismissOverlay: () => {},
520
590
  setInputValue: () => {},
521
591
  appendInfoMessage: () => {},
592
+ appendTodoMessage: () => {},
522
593
  scrollConversationToBottom: () => {},
523
594
  render: () => {},
524
595
  reloadPromptContext: async () => {},
@@ -546,6 +617,7 @@ describe("ui/commands", () => {
546
617
  dismissOverlay: () => {},
547
618
  setInputValue: () => {},
548
619
  appendInfoMessage: () => {},
620
+ appendTodoMessage: () => {},
549
621
  scrollConversationToBottom: () => {},
550
622
  render: () => {},
551
623
  reloadPromptContext: async () => {},
@@ -29,6 +29,7 @@ import {
29
29
  undoLastTurn,
30
30
  } from "../session.ts";
31
31
  import { updateSettings } from "../settings.ts";
32
+ import { getTodoItems } from "../tools.ts";
32
33
  import { buildHelpText, COMMAND_DESCRIPTIONS } from "./help.ts";
33
34
  import { type ActiveOverlay, OVERLAY_MAX_VISIBLE } from "./overlay.ts";
34
35
  import { abbreviatePath } from "./status.ts";
@@ -51,6 +52,11 @@ interface UiCommandRuntime {
51
52
  setInputValue: (value: string) => void;
52
53
  /** Append a UI-only info message to the conversation log. */
53
54
  appendInfoMessage: (text: string, state: AppState) => void;
55
+ /** Append a UI-only todo snapshot to the conversation log. */
56
+ appendTodoMessage: (
57
+ todos: ReturnType<typeof getTodoItems>,
58
+ state: AppState,
59
+ ) => void;
54
60
  /** Re-enable stick-to-bottom behavior for the conversation log. */
55
61
  scrollConversationToBottom: () => void;
56
62
  /** Trigger a UI re-render. */
@@ -580,6 +586,10 @@ export function createCommandController(
580
586
  runtime.appendInfoMessage(buildHelpText(state), state);
581
587
  };
582
588
 
589
+ const handleTodoCommand = (state: AppState): void => {
590
+ runtime.appendTodoMessage(getTodoItems(state.messages), state);
591
+ };
592
+
583
593
  const handleCommand = (command: string, state: AppState): boolean => {
584
594
  switch (command) {
585
595
  case "model":
@@ -622,6 +632,9 @@ export function createCommandController(
622
632
  case "verbose":
623
633
  handleVerboseCommand(state);
624
634
  return true;
635
+ case "todo":
636
+ handleTodoCommand(state);
637
+ return true;
625
638
  case "help":
626
639
  handleHelpCommand(state);
627
640
  return true;
@@ -1057,6 +1057,33 @@ describe("ui/conversation", () => {
1057
1057
  expect(text).not.toContain("{");
1058
1058
  });
1059
1059
 
1060
+ test("renderAssistantMessage for a todoWrite tool call shows a todo-count summary instead of raw JSON", () => {
1061
+ // Arrange
1062
+ const assistant = {
1063
+ content: [
1064
+ fauxToolCall(
1065
+ "todoWrite",
1066
+ {
1067
+ todos: [
1068
+ { content: "Inspect headless JSON output", status: "completed" },
1069
+ { content: "Update the TUI preview", status: "in_progress" },
1070
+ ],
1071
+ },
1072
+ { id: "tool-1" },
1073
+ ),
1074
+ ],
1075
+ };
1076
+
1077
+ // Act
1078
+ const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
1079
+
1080
+ // Assert
1081
+ expect(text).toContain("todo write ->");
1082
+ expect(text).toContain("Updating todos... 2 todos updated");
1083
+ expect(text).not.toContain("{");
1084
+ expect(text).not.toContain('"todos"');
1085
+ });
1086
+
1060
1087
  test("renderAssistantMessage for an edit tool call shows both old and new content without diff prefixes", () => {
1061
1088
  // Arrange
1062
1089
  const assistant = {
@@ -1321,6 +1348,70 @@ describe("ui/conversation", () => {
1321
1348
  expect(text).not.toContain("error 17");
1322
1349
  });
1323
1350
 
1351
+ test("renderToolResult for todoWrite shows the full checklist even when verbose is off", async () => {
1352
+ const snapshot = JSON.stringify({
1353
+ todos: [
1354
+ { content: "Review prompt wording", status: "completed" },
1355
+ { content: "Implement todo tools", status: "in_progress" },
1356
+ { content: "Add /todo command", status: "pending" },
1357
+ { content: "Run the full verification suite", status: "pending" },
1358
+ ],
1359
+ });
1360
+
1361
+ const text = await renderVisibleText(
1362
+ renderToolResult("todoWrite", {}, snapshot, false, RENDER_OPTS),
1363
+ PREVIEW_WIDTH,
1364
+ 24,
1365
+ );
1366
+
1367
+ expect(text).toContain("todo write <-");
1368
+ expect(text).toContain("[x] Review prompt wording");
1369
+ expect(text).toContain("[~] Implement todo tools");
1370
+ expect(text).toContain("[ ] Add /todo command");
1371
+ expect(text.join(" ")).toContain("[ ] Run the full verification suite");
1372
+ expect(text.some((line) => line.startsWith("And "))).toBe(false);
1373
+ });
1374
+
1375
+ test("buildConversationLogNodes renders UI todo messages with the shared checklist block", async () => {
1376
+ const state = {
1377
+ messages: [
1378
+ {
1379
+ role: "ui" as const,
1380
+ kind: "todo" as const,
1381
+ todos: [
1382
+ { content: "Review prompt wording", status: "completed" as const },
1383
+ { content: "Implement todo tools", status: "in_progress" as const },
1384
+ { content: "Add /todo command", status: "pending" as const },
1385
+ ],
1386
+ timestamp: 1,
1387
+ },
1388
+ ],
1389
+ showReasoning: false,
1390
+ verbose: false,
1391
+ theme: DEFAULT_THEME,
1392
+ };
1393
+
1394
+ const text = await renderVisibleText(
1395
+ VStack(
1396
+ {},
1397
+ buildConversationLogNodes(
1398
+ state,
1399
+ { isStreaming: false, content: [], pendingToolResults: [] },
1400
+ 0,
1401
+ PREVIEW_WIDTH,
1402
+ ),
1403
+ ),
1404
+ PREVIEW_WIDTH,
1405
+ 24,
1406
+ );
1407
+
1408
+ expect(text).toContain("todo");
1409
+ expect(text).toContain("[x] Review prompt wording");
1410
+ expect(text).toContain("[~] Implement todo tools");
1411
+ expect(text).toContain("[ ] Add /todo command");
1412
+ expect(text.some((line) => line.includes('"todos"'))).toBe(false);
1413
+ });
1414
+
1324
1415
  test("renderToolResult for a generic plugin tool uses the shared result header", () => {
1325
1416
  // Arrange
1326
1417
  const args = { query: "session persistence sqlite turn numbering" };
@@ -23,6 +23,7 @@ import type {
23
23
  import type { AppState } from "../index.ts";
24
24
  import type { UiMessage } from "../session.ts";
25
25
  import type { Theme } from "../theme.ts";
26
+ import { parseTodoSnapshot, type TodoItem } from "../tools.ts";
26
27
  import { APP_NAME, DEV_VERSION_LABEL } from "../version.ts";
27
28
 
28
29
  /** Single blank-line gap used between conversation-level blocks. */
@@ -164,6 +165,8 @@ interface ToolBlockSpec {
164
165
  bodyLines: readonly ToolRenderLine[];
165
166
  /** Optional syntax-highlighted body rendered from the full unsplit source. */
166
167
  highlightedBody?: HighlightedBodySpec;
168
+ /** Optional custom body node rendered as-is. */
169
+ bodyNode?: Node;
167
170
  /** Whether `/verbose` preview rules apply to the body. */
168
171
  previewBody: boolean;
169
172
  }
@@ -412,6 +415,12 @@ function getToolArgString(args: Record<string, unknown>, key: string): string {
412
415
  return typeof value === "string" ? value : "";
413
416
  }
414
417
 
418
+ function formatTodoWriteCallSummary(args: Record<string, unknown>): string {
419
+ const todoCount = Array.isArray(args.todos) ? args.todos.length : 0;
420
+ const todoLabel = todoCount === 1 ? "todo" : "todos";
421
+ return `Updating todos... ${todoCount} ${todoLabel} updated`;
422
+ }
423
+
415
424
  /** Strip shell execution labels and normalize the exit line for the UI. */
416
425
  function normalizeShellOutput(output: string): string {
417
426
  return output
@@ -421,17 +430,18 @@ function normalizeShellOutput(output: string): string {
421
430
  }
422
431
 
423
432
  function getToolHeaderName(toolName: string): string {
424
- return toolName === "readImage" ? "read image" : toolName;
433
+ return toolName.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
425
434
  }
426
435
 
427
436
  function getToolHeaderColor(toolName: string, theme: Theme) {
428
437
  switch (toolName) {
429
438
  case "shell":
439
+ case "readImage":
440
+ case "todoWrite":
441
+ case "todoRead":
430
442
  return theme.accentText;
431
443
  case "edit":
432
444
  return theme.secondaryAccentText;
433
- case "readImage":
434
- return theme.accentText;
435
445
  default:
436
446
  return theme.secondaryAccentText ?? theme.toolText;
437
447
  }
@@ -684,6 +694,48 @@ function renderToolLines(
684
694
  return lines.map((line) => renderToolLine(line, theme));
685
695
  }
686
696
 
697
+ function getTodoMarker(todo: TodoItem): "[ ]" | "[~]" | "[x]" {
698
+ switch (todo.status) {
699
+ case "in_progress":
700
+ return "[~]";
701
+ case "completed":
702
+ return "[x]";
703
+ default:
704
+ return "[ ]";
705
+ }
706
+ }
707
+
708
+ function getTodoColor(todo: TodoItem, theme: Theme): Theme["toolText"] {
709
+ switch (todo.status) {
710
+ case "in_progress":
711
+ return theme.accentText;
712
+ case "completed":
713
+ return theme.diffAdded;
714
+ default:
715
+ return theme.toolText;
716
+ }
717
+ }
718
+
719
+ function renderTodoChecklist(todos: readonly TodoItem[], theme: Theme): Node {
720
+ if (todos.length === 0) {
721
+ return Text("No todo items.", {
722
+ fgColor: theme.mutedText,
723
+ italic: true,
724
+ wrap: "word",
725
+ });
726
+ }
727
+
728
+ return VStack(
729
+ {},
730
+ todos.map((todo) =>
731
+ Text(`${getTodoMarker(todo)} ${todo.content}`, {
732
+ fgColor: getTodoColor(todo, theme),
733
+ wrap: "word",
734
+ }),
735
+ ),
736
+ );
737
+ }
738
+
687
739
  function measureToolNodesHeight(lines: readonly Node[], width: number): number {
688
740
  if (lines.length === 0) {
689
741
  return 0;
@@ -778,6 +830,10 @@ function renderToolBody(
778
830
  spec: ToolBlockSpec,
779
831
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
780
832
  ): { body: Node | null; summary?: ToolRenderLine } {
833
+ if (spec.bodyNode) {
834
+ return { body: spec.bodyNode };
835
+ }
836
+
781
837
  if (spec.highlightedBody) {
782
838
  return renderToolBodyFromNodes(
783
839
  getHighlightedBodyLines(
@@ -868,6 +924,17 @@ function buildEditToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
868
924
  };
869
925
  }
870
926
 
927
+ function buildTodoWriteToolCallSpec(
928
+ args: Record<string, unknown>,
929
+ ): ToolBlockSpec {
930
+ return {
931
+ toolName: "todoWrite",
932
+ direction: "->",
933
+ bodyLines: [{ kind: "text", text: formatTodoWriteCallSummary(args) }],
934
+ previewBody: false,
935
+ };
936
+ }
937
+
871
938
  function buildReadImageToolCallSpec(
872
939
  args: Record<string, unknown>,
873
940
  ): ToolBlockSpec {
@@ -903,6 +970,9 @@ function buildToolCallSpec(
903
970
  if (toolName === "edit") {
904
971
  return buildEditToolCallSpec(args);
905
972
  }
973
+ if (toolName === "todoWrite") {
974
+ return buildTodoWriteToolCallSpec(args);
975
+ }
906
976
  if (toolName === "readImage") {
907
977
  return buildReadImageToolCallSpec(args);
908
978
  }
@@ -956,6 +1026,31 @@ function buildEditToolResultSpec(
956
1026
  };
957
1027
  }
958
1028
 
1029
+ function buildTodoToolResultSpec(
1030
+ toolName: string,
1031
+ content: ToolResultMessage["content"],
1032
+ isError: boolean,
1033
+ theme: Theme,
1034
+ ): ToolBlockSpec {
1035
+ if (isError) {
1036
+ return {
1037
+ toolName,
1038
+ direction: "<-",
1039
+ bodyLines: splitToolTextLines(getToolContentText(content), "error"),
1040
+ previewBody: false,
1041
+ };
1042
+ }
1043
+
1044
+ const todos = parseTodoSnapshot(getToolContentText(content)) ?? [];
1045
+ return {
1046
+ toolName,
1047
+ direction: "<-",
1048
+ bodyLines: [],
1049
+ bodyNode: renderTodoChecklist(todos, theme),
1050
+ previewBody: false,
1051
+ };
1052
+ }
1053
+
959
1054
  function buildReadImageToolResultSpec(
960
1055
  args: Record<string, unknown>,
961
1056
  content: ToolResultMessage["content"],
@@ -1008,6 +1103,12 @@ function renderToolResultContent(
1008
1103
  opts,
1009
1104
  );
1010
1105
  }
1106
+ if (toolName === "todoWrite" || toolName === "todoRead") {
1107
+ return renderToolBlock(
1108
+ buildTodoToolResultSpec(toolName, content, isError, opts.theme),
1109
+ opts,
1110
+ );
1111
+ }
1011
1112
  if (toolName === "readImage") {
1012
1113
  return renderToolBlock(
1013
1114
  buildReadImageToolResultSpec(args, content, isError),
@@ -1045,8 +1146,26 @@ export function renderToolResult(
1045
1146
  return renderToolResultContent(toolName, args, content, isError, opts);
1046
1147
  }
1047
1148
 
1149
+ function renderUiTodoMessage(
1150
+ msg: Extract<UiMessage, { kind: "todo" }>,
1151
+ theme: Theme,
1152
+ ): Node {
1153
+ return VStack({ padding: { x: 1 } }, [
1154
+ Text("todo", {
1155
+ fgColor: theme.secondaryAccentText ?? theme.toolText,
1156
+ bold: true,
1157
+ wrap: "word",
1158
+ }),
1159
+ renderTodoChecklist(msg.todos, theme),
1160
+ ]);
1161
+ }
1162
+
1048
1163
  /** Render an internal UI message in the conversation log. */
1049
1164
  function renderUiMessage(msg: UiMessage, theme: Theme): Node {
1165
+ if (msg.kind === "todo") {
1166
+ return renderUiTodoMessage(msg, theme);
1167
+ }
1168
+
1050
1169
  return VStack({ padding: { x: 1 } }, [
1051
1170
  Text(msg.content, {
1052
1171
  fgColor: theme.mutedText,
@@ -22,6 +22,7 @@ describe("ui/help", () => {
22
22
  expect(text).toContain(
23
23
  "/verbose Toggle verbose tool rendering (currently off)",
24
24
  );
25
+ expect(text).toContain("/todo Show the current todo list");
25
26
  });
26
27
 
27
28
  test("buildHelpText describes the current Escape behavior", () => {
package/src/ui/help.ts CHANGED
@@ -36,6 +36,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
36
36
  undo: "Undo last turn",
37
37
  reasoning: "Toggle thinking display",
38
38
  verbose: "Toggle verbose tool rendering",
39
+ todo: "Show the current todo list",
39
40
  login: "OAuth login",
40
41
  logout: "OAuth logout",
41
42
  help: "Show help",
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { DEFAULT_THEME } from "../theme.ts";
6
6
  import {
7
7
  autocompleteInputPath,
8
+ findInputPathMatches,
8
9
  type InputController,
9
10
  renderInputArea,
10
11
  } from "./input.ts";
@@ -60,7 +61,7 @@ describe("ui/input", () => {
60
61
  expect(placeholder.props.italic).toBe(true);
61
62
  });
62
63
 
63
- test("autocompleteInputPath completes the last file path token", () => {
64
+ test("autocompleteInputPath completes the last file path token when exactly one match is available", () => {
64
65
  const cwd = createTempDir();
65
66
  mkdirSync(join(cwd, "src"), { recursive: true });
66
67
  writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
@@ -70,6 +71,33 @@ describe("ui/input", () => {
70
71
  );
71
72
  });
72
73
 
74
+ test("autocompleteInputPath returns null when multiple matches are available", () => {
75
+ const cwd = createTempDir();
76
+ mkdirSync(join(cwd, "src"), { recursive: true });
77
+ writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
78
+ writeFileSync(join(cwd, "src", "utils.ts"), "", "utf-8");
79
+
80
+ expect(autocompleteInputPath("inspect src/u", cwd)).toBeNull();
81
+ });
82
+
83
+ test("findInputPathMatches returns sorted selectable matches for the last file path token", () => {
84
+ const cwd = createTempDir();
85
+ mkdirSync(join(cwd, "src"), { recursive: true });
86
+ writeFileSync(join(cwd, "src", "alpine.ts"), "", "utf-8");
87
+ writeFileSync(join(cwd, "src", "alpha.ts"), "", "utf-8");
88
+
89
+ expect(findInputPathMatches("inspect src/al", cwd)).toEqual([
90
+ {
91
+ label: "src/alpha.ts",
92
+ value: "inspect src/alpha.ts",
93
+ },
94
+ {
95
+ label: "src/alpine.ts",
96
+ value: "inspect src/alpine.ts",
97
+ },
98
+ ]);
99
+ });
100
+
73
101
  test("autocompleteInputPath returns null when no completion is available", () => {
74
102
  const cwd = createTempDir();
75
103