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.
- package/README.md +5 -2
- package/bun.lock +13 -13
- package/package.json +3 -3
- package/src/agent.ts +299 -41
- package/src/cli.ts +11 -2
- package/src/headless.ts +185 -47
- package/src/index.ts +105 -12
- package/src/input.ts +1 -0
- package/src/prompt.ts +86 -78
- package/src/session.ts +56 -8
- package/src/submit.ts +53 -11
- package/src/tools.ts +401 -44
- package/src/ui/agent.ts +16 -2
- package/src/ui/commands.test.ts +72 -0
- package/src/ui/commands.ts +13 -0
- package/src/ui/conversation.test.ts +91 -0
- package/src/ui/conversation.ts +122 -3
- package/src/ui/help.test.ts +1 -0
- package/src/ui/help.ts +1 -0
- package/src/ui/input.test.ts +29 -1
- package/src/ui/input.ts +45 -51
- package/src/ui/render-performance.test.ts +1 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +85 -18
package/src/ui/commands.test.ts
CHANGED
|
@@ -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 () => {},
|
package/src/ui/commands.ts
CHANGED
|
@@ -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" };
|
package/src/ui/conversation.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/ui/help.test.ts
CHANGED
|
@@ -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/input.test.ts
CHANGED
|
@@ -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
|
|