mini-coder 0.5.7 → 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.
@@ -122,6 +122,7 @@ describe("ui/commands", () => {
122
122
  inputValue = value;
123
123
  },
124
124
  appendInfoMessage: () => {},
125
+ appendTodoMessage: () => {},
125
126
  scrollConversationToBottom: () => {},
126
127
  render: () => {},
127
128
  reloadPromptContext: async () => {},
@@ -161,6 +162,7 @@ describe("ui/commands", () => {
161
162
  inputValue = value;
162
163
  },
163
164
  appendInfoMessage: () => {},
165
+ appendTodoMessage: () => {},
164
166
  scrollConversationToBottom: () => {},
165
167
  render: () => {},
166
168
  reloadPromptContext: async () => {},
@@ -206,6 +208,7 @@ describe("ui/commands", () => {
206
208
  inputValue = value;
207
209
  },
208
210
  appendInfoMessage: () => {},
211
+ appendTodoMessage: () => {},
209
212
  scrollConversationToBottom: () => {},
210
213
  render: () => {},
211
214
  reloadPromptContext: async () => {},
@@ -244,6 +247,7 @@ describe("ui/commands", () => {
244
247
  },
245
248
  setInputValue: () => {},
246
249
  appendInfoMessage: () => {},
250
+ appendTodoMessage: () => {},
247
251
  scrollConversationToBottom: () => {},
248
252
  render: () => {},
249
253
  reloadPromptContext: async () => {},
@@ -310,6 +314,7 @@ describe("ui/commands", () => {
310
314
  },
311
315
  setInputValue: () => {},
312
316
  appendInfoMessage: () => {},
317
+ appendTodoMessage: () => {},
313
318
  scrollConversationToBottom: () => {
314
319
  scrollCalls += 1;
315
320
  },
@@ -393,6 +398,7 @@ describe("ui/commands", () => {
393
398
  dismissOverlay: () => {},
394
399
  setInputValue: () => {},
395
400
  appendInfoMessage: () => {},
401
+ appendTodoMessage: () => {},
396
402
  scrollConversationToBottom: () => {},
397
403
  render: () => {},
398
404
  reloadPromptContext: async () => {},
@@ -415,6 +421,7 @@ describe("ui/commands", () => {
415
421
  dismissOverlay: () => {},
416
422
  setInputValue: () => {},
417
423
  appendInfoMessage: () => {},
424
+ appendTodoMessage: () => {},
418
425
  scrollConversationToBottom: () => {},
419
426
  render: () => {},
420
427
  reloadPromptContext: async (nextState) => {
@@ -470,6 +477,7 @@ describe("ui/commands", () => {
470
477
  dismissOverlay: () => {},
471
478
  setInputValue: () => {},
472
479
  appendInfoMessage: () => {},
480
+ appendTodoMessage: () => {},
473
481
  scrollConversationToBottom: () => {},
474
482
  render: () => {},
475
483
  reloadPromptContext: async () => {},
@@ -495,6 +503,7 @@ describe("ui/commands", () => {
495
503
  dismissOverlay: () => {},
496
504
  setInputValue: () => {},
497
505
  appendInfoMessage: () => {},
506
+ appendTodoMessage: () => {},
498
507
  scrollConversationToBottom: () => {},
499
508
  render: () => {},
500
509
  reloadPromptContext: async () => {},
@@ -511,6 +520,66 @@ describe("ui/commands", () => {
511
520
  }
512
521
  });
513
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
+
514
583
  test("applyModelSelection updates the state and persists the default model", () => {
515
584
  const faux = registerFauxProvider();
516
585
  const state = createTestState();
@@ -520,6 +589,7 @@ describe("ui/commands", () => {
520
589
  dismissOverlay: () => {},
521
590
  setInputValue: () => {},
522
591
  appendInfoMessage: () => {},
592
+ appendTodoMessage: () => {},
523
593
  scrollConversationToBottom: () => {},
524
594
  render: () => {},
525
595
  reloadPromptContext: async () => {},
@@ -547,6 +617,7 @@ describe("ui/commands", () => {
547
617
  dismissOverlay: () => {},
548
618
  setInputValue: () => {},
549
619
  appendInfoMessage: () => {},
620
+ appendTodoMessage: () => {},
550
621
  scrollConversationToBottom: () => {},
551
622
  render: () => {},
552
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",
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,
@@ -466,6 +470,18 @@ function scrollConversationToBottom(): void {
466
470
  stickToBottom = true;
467
471
  }
468
472
 
473
+ function appendUiMessage(
474
+ message: AppState["messages"][number],
475
+ state: AppState,
476
+ ): void {
477
+ if (state.session) {
478
+ appendMessage(state.db, state.session.id, message);
479
+ }
480
+ state.messages.push(message);
481
+ scrollConversationToBottom();
482
+ cel.render();
483
+ }
484
+
469
485
  /**
470
486
  * Append a UI-only info message to the conversation log.
471
487
  *
@@ -476,13 +492,23 @@ function scrollConversationToBottom(): void {
476
492
  * @param state - Application state.
477
493
  */
478
494
  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();
495
+ appendUiMessage(createUiMessage(text), state);
496
+ }
497
+
498
+ /**
499
+ * Append a UI-only todo snapshot to the conversation log.
500
+ *
501
+ * When no persisted session exists yet, the message stays in memory and is
502
+ * backfilled if the user later starts a session by sending a message.
503
+ *
504
+ * @param todos - Todo snapshot to append.
505
+ * @param state - Application state.
506
+ */
507
+ function appendTodoMessage(
508
+ todos: Parameters<typeof createUiTodoMessage>[0],
509
+ state: AppState,
510
+ ): void {
511
+ appendUiMessage(createUiTodoMessage(todos), state);
486
512
  }
487
513
 
488
514
  /** Command controller bound to the module-scoped UI runtime hooks. */
@@ -493,6 +519,7 @@ const commandController = createCommandController({
493
519
  inputValue = value;
494
520
  },
495
521
  appendInfoMessage,
522
+ appendTodoMessage,
496
523
  scrollConversationToBottom,
497
524
  render: () => {
498
525
  cel.render();