@pi-archimedes/todo 2.2.0 → 2.4.0
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 +6 -6
- package/package.json +9 -2
- package/src/index.test.ts +160 -0
- package/src/index.ts +12 -2
- package/src/prepare-args.test.ts +274 -0
- package/src/prepare-args.ts +296 -0
- package/src/state-manager.test.ts +113 -42
- package/src/state-manager.ts +19 -18
- package/src/tool.test.ts +148 -0
- package/src/tool.ts +55 -32
- package/src/types.ts +11 -13
- package/src/ui/todo-widget.ts +9 -7
package/src/tool.test.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createManageTodoListTool } from "./tool.js";
|
|
4
|
+
import { TodoStateManager } from "./state-manager.js";
|
|
5
|
+
import type { TodoDetails } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const VALIDATION_ERROR_TEXT = [
|
|
8
|
+
'Validation failed for tool "manage_todo_list":',
|
|
9
|
+
' - todoList.0: must be object',
|
|
10
|
+
"",
|
|
11
|
+
"Received arguments:",
|
|
12
|
+
'{',
|
|
13
|
+
' "operation": "write",',
|
|
14
|
+
' "todoList": "this is definitely not a list, ascii only"',
|
|
15
|
+
"}",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
18
|
+
// Theme stub: render (token, text) as [token]text so assertions can
|
|
19
|
+
// tell apart the status colour, the glyph, and the label.
|
|
20
|
+
const theme = {
|
|
21
|
+
fg: (token: unknown, text: unknown) => `[${token}]${text}`,
|
|
22
|
+
strikethrough: (text: unknown) => `[s]${text}`,
|
|
23
|
+
} as unknown as Theme;
|
|
24
|
+
|
|
25
|
+
const tool = createManageTodoListTool(new TodoStateManager(), () => {});
|
|
26
|
+
|
|
27
|
+
function renderLines(result: AgentToolResult<TodoDetails | undefined>, expanded: boolean): string {
|
|
28
|
+
const view = tool.renderResult(result, { expanded, isPartial: false }, theme) as {
|
|
29
|
+
render(width: number): string[];
|
|
30
|
+
};
|
|
31
|
+
expect(view).toBeDefined();
|
|
32
|
+
return view.render(200).map((line) => line.trimEnd()).join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const harnessErrorResult = (text: string) =>
|
|
36
|
+
({
|
|
37
|
+
content: [{ type: "text" as const, text }],
|
|
38
|
+
// Exactly what the harness attaches to rejected calls (createErrorToolResult):
|
|
39
|
+
// a truthy empty details object.
|
|
40
|
+
details: {},
|
|
41
|
+
}) as unknown as AgentToolResult<TodoDetails | undefined>;
|
|
42
|
+
|
|
43
|
+
describe("renderResult — harness error shapes", () => {
|
|
44
|
+
it("collapsed validation failure → single red ✗ line, no raw argument dump", () => {
|
|
45
|
+
const lines = renderLines(harnessErrorResult(VALIDATION_ERROR_TEXT), false).split("\n");
|
|
46
|
+
expect(lines.length).toBe(1);
|
|
47
|
+
const [line] = lines;
|
|
48
|
+
expect(line).toContain("✗");
|
|
49
|
+
expect(line).toContain("invalid arguments");
|
|
50
|
+
expect(line).not.toContain("Validation failed");
|
|
51
|
+
expect(line).not.toContain("Received arguments");
|
|
52
|
+
expect(line).not.toContain("definitely not a list");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("expanded validation failure → the full harness message", () => {
|
|
56
|
+
const lines = renderLines(harnessErrorResult(VALIDATION_ERROR_TEXT), true);
|
|
57
|
+
expect(lines).toBe(VALIDATION_ERROR_TEXT);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("results without any details at all → same compact treatment", () => {
|
|
61
|
+
const noDetails = {
|
|
62
|
+
content: [{ type: "text" as const, text: "Tool execution was blocked" }],
|
|
63
|
+
} as unknown as AgentToolResult<TodoDetails | undefined>;
|
|
64
|
+
const [line] = renderLines(noDetails, false).split("\n");
|
|
65
|
+
expect(line).toContain("✗");
|
|
66
|
+
expect(line).toContain("blocked");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("non-validation errors stay short and line-less-newline", () => {
|
|
70
|
+
const [line] = renderLines(harnessErrorResult("Some multi-line\nharness message here"), false).split("\n");
|
|
71
|
+
expect(line).toContain("✗");
|
|
72
|
+
expect(line).not.toContain("\n");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("tool schema", () => {
|
|
77
|
+
const params = tool.parameters as unknown as {
|
|
78
|
+
properties: Record<string, { type?: string; enum?: string[]; items?: unknown }>;
|
|
79
|
+
required?: string[];
|
|
80
|
+
};
|
|
81
|
+
const itemSchema = params.properties.todoList?.items as unknown as {
|
|
82
|
+
properties: Record<string, { type?: string; enum?: string[] }>;
|
|
83
|
+
required?: string[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
it("declares content + canonical status enum, with no id/title and optional description", () => {
|
|
87
|
+
const props = itemSchema.properties;
|
|
88
|
+
expect(props.id).toBeUndefined();
|
|
89
|
+
expect(props.title).toBeUndefined();
|
|
90
|
+
expect(props.content?.type).toBe("string");
|
|
91
|
+
expect(props.status?.enum).toEqual(["pending", "in_progress", "completed"]);
|
|
92
|
+
expect(itemSchema.required).toContain("content");
|
|
93
|
+
expect(itemSchema.required).toContain("status");
|
|
94
|
+
expect(itemSchema.required).not.toContain("description");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the manage_todo_list tool name, op and prepareArguments wiring", () => {
|
|
98
|
+
expect(tool.name).toBe("manage_todo_list");
|
|
99
|
+
expect(typeof tool.prepareArguments).toBe("function");
|
|
100
|
+
expect(params.properties.operation?.enum).toEqual(["write", "read"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("model-facing description uses canonical status tokens only", () => {
|
|
104
|
+
expect(tool.description).not.toMatch(/not-started|in-progress/);
|
|
105
|
+
expect(tool.description).toContain("in_progress");
|
|
106
|
+
expect(tool.description).toContain("\"content\": \"Fix the auth middleware\"");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("renderResult — successful results unchanged", () => {
|
|
111
|
+
it("renders the completed counter from details.todos", () => {
|
|
112
|
+
const result = {
|
|
113
|
+
content: [{ type: "text" as const, text: "3/3 completed." }],
|
|
114
|
+
details: {
|
|
115
|
+
operation: "write" as const,
|
|
116
|
+
todos: [
|
|
117
|
+
{ content: "a", status: "completed" as const },
|
|
118
|
+
{ content: "b", status: "completed" as const },
|
|
119
|
+
{ content: "c", status: "completed" as const },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
} as unknown as AgentToolResult<TodoDetails>;
|
|
123
|
+
const [line] = renderLines(result, false).split("\n");
|
|
124
|
+
expect(line).toContain("3/3 completed");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("numbers items by array position and renders content", () => {
|
|
128
|
+
const result = {
|
|
129
|
+
content: [{ type: "text" as const, text: "1/3 completed." }],
|
|
130
|
+
details: {
|
|
131
|
+
operation: "write" as const,
|
|
132
|
+
todos: [
|
|
133
|
+
{ content: "First", status: "completed" as const },
|
|
134
|
+
{ content: "Second", status: "in_progress" as const },
|
|
135
|
+
{ content: "Third", status: "pending" as const },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
} as unknown as AgentToolResult<TodoDetails>;
|
|
139
|
+
const lines = renderLines(result, true);
|
|
140
|
+
expect(lines).toContain("[accent]1.");
|
|
141
|
+
expect(lines).toContain("[accent]2.");
|
|
142
|
+
expect(lines).toContain("[accent]3.");
|
|
143
|
+
expect(lines).toContain("[s]First");
|
|
144
|
+
expect(lines).toContain("[warning]Second");
|
|
145
|
+
expect(lines).toContain("[muted]Third");
|
|
146
|
+
expect(lines).not.toContain("undefined");
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/tool.ts
CHANGED
|
@@ -2,19 +2,22 @@ import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { AgentToolResult, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
+
import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
|
|
6
|
+
import { prepareTodoArguments } from "./prepare-args.js";
|
|
5
7
|
import type { TodoStateManager } from "./state-manager.js";
|
|
6
8
|
import { STATUS_ICONS } from "./types.js";
|
|
7
9
|
import type { TodoDetails } from "./types.js";
|
|
8
10
|
|
|
9
11
|
const TodoItemSchema = Type.Object({
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
description: Type.String({
|
|
13
|
-
description: "Detailed context, requirements, or implementation notes. Include file paths, specific methods, or acceptance criteria.",
|
|
12
|
+
content: Type.String({
|
|
13
|
+
description: "Short imperative label for the task (3-10 words). Displayed in UI. Example: \"Fix the auth middleware\".",
|
|
14
14
|
}),
|
|
15
|
-
status: StringEnum(["
|
|
16
|
-
description: "
|
|
15
|
+
status: StringEnum(["pending", "in_progress", "completed"] as const, {
|
|
16
|
+
description: "pending: Not begun | in_progress: Currently working (multiple allowed for parallel work/subagents) | completed: Fully finished with no blockers",
|
|
17
17
|
}),
|
|
18
|
+
description: Type.Optional(Type.String({
|
|
19
|
+
description: "Optional detailed context: file paths, specific methods, or acceptance criteria.",
|
|
20
|
+
})),
|
|
18
21
|
});
|
|
19
22
|
|
|
20
23
|
export const ManageTodoListParams = Type.Object({
|
|
@@ -36,7 +39,7 @@ When to use this tool:
|
|
|
36
39
|
- Complex multi-step work requiring planning and tracking
|
|
37
40
|
- When user provides multiple tasks or requests (numbered, comma-separated)
|
|
38
41
|
- After receiving new instructions that require multiple steps
|
|
39
|
-
- BEFORE starting work on any todo (mark as
|
|
42
|
+
- BEFORE starting work on any todo (mark as in_progress)
|
|
40
43
|
- IMMEDIATELY after completing each todo (mark completed individually)
|
|
41
44
|
- When breaking down larger tasks into smaller actionable steps
|
|
42
45
|
- To give users visibility into your progress and planning
|
|
@@ -48,14 +51,19 @@ When NOT to use:
|
|
|
48
51
|
|
|
49
52
|
CRITICAL workflow:
|
|
50
53
|
1. Plan tasks by writing todo list with specific, actionable items
|
|
51
|
-
2. Mark todo(s) as
|
|
54
|
+
2. Mark todo(s) as in_progress before starting work
|
|
52
55
|
3. Complete the work for that specific todo
|
|
53
56
|
4. Mark that todo as completed IMMEDIATELY
|
|
54
57
|
5. Move to next todo and repeat
|
|
55
58
|
|
|
59
|
+
Todo item shape:
|
|
60
|
+
{"content": "Fix the auth middleware", "status": "pending", "description": "optional: file paths, acceptance criteria"}
|
|
61
|
+
- content: the short displayed label — do NOT assign ids (numbering is order)
|
|
62
|
+
- status: exactly one of pending | in_progress | completed
|
|
63
|
+
|
|
56
64
|
Todo states:
|
|
57
|
-
-
|
|
58
|
-
-
|
|
65
|
+
- pending: Todo not yet begun
|
|
66
|
+
- in_progress: Currently working (multiple allowed for parallel work/subagents)
|
|
59
67
|
- completed: Finished successfully
|
|
60
68
|
|
|
61
69
|
IMPORTANT: Mark todos completed as soon as they are done. Do not batch completions.
|
|
@@ -68,6 +76,11 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
|
|
|
68
76
|
description: TOOL_DESCRIPTION,
|
|
69
77
|
parameters: ManageTodoListParams,
|
|
70
78
|
|
|
79
|
+
// Repair common model mistakes (stringified arrays, missing
|
|
80
|
+
// title/status, bare-string items) BEFORE schema validation. See
|
|
81
|
+
// prepare-args.ts.
|
|
82
|
+
prepareArguments: prepareTodoArguments,
|
|
83
|
+
|
|
71
84
|
async execute(
|
|
72
85
|
_toolCallId: string,
|
|
73
86
|
params: ManageTodoListInput,
|
|
@@ -135,58 +148,68 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
|
|
|
135
148
|
},
|
|
136
149
|
|
|
137
150
|
renderCall(args: ManageTodoListInput, theme: Theme) {
|
|
138
|
-
|
|
139
|
-
text += theme.fg("muted", args.operation);
|
|
140
|
-
|
|
141
|
-
if (args.operation === "write" && args.todoList) {
|
|
142
|
-
const count = args.todoList.length;
|
|
143
|
-
text += theme.fg("dim", ` (${count} item${count !== 1 ? "s" : ""})`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return new Text(text, 0, 0);
|
|
151
|
+
return new Text(renderToolHeader("todo", args.operation, theme), 0, 0);
|
|
147
152
|
},
|
|
148
153
|
|
|
149
154
|
renderResult(
|
|
150
155
|
result: AgentToolResult<TodoDetails | undefined>,
|
|
151
|
-
{ expanded }: ToolRenderResultOptions,
|
|
156
|
+
{ expanded, isPartial }: ToolRenderResultOptions,
|
|
152
157
|
theme: Theme
|
|
153
158
|
) {
|
|
154
159
|
const details = result.details;
|
|
155
|
-
|
|
160
|
+
|
|
161
|
+
// execute() always carries details.todos. Anything else — including
|
|
162
|
+
// the harness' rejection results, which attach details: {} — means
|
|
163
|
+
// execute() never ran (validation failure, blocked call, abort).
|
|
164
|
+
// Collapsed, show a single red ✗ line; the full message on expand.
|
|
165
|
+
if (!details || !Array.isArray(details.todos)) {
|
|
156
166
|
const first = result.content[0];
|
|
157
|
-
|
|
167
|
+
const text = first && "text" in first && typeof first.text === "string" ? first.text : "";
|
|
168
|
+
if (!expanded) {
|
|
169
|
+
const label = /validation failed for tool/i.test(text)
|
|
170
|
+
? "invalid arguments"
|
|
171
|
+
: text.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
172
|
+
return new Text(label ? renderStatusLabel("error", label, theme) : "", 0, 0);
|
|
173
|
+
}
|
|
174
|
+
return new Text(text, 0, 0);
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
if (details.error) {
|
|
161
|
-
return new Text(
|
|
178
|
+
return new Text(renderStatusLabel("error", details.error, theme), 0, 0);
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
const todos = details.todos;
|
|
165
182
|
const completed = todos.filter((t) => t.status === "completed").length;
|
|
166
183
|
const total = todos.length;
|
|
184
|
+
const label = `${completed}/${total} completed`;
|
|
185
|
+
|
|
186
|
+
if (isPartial) {
|
|
187
|
+
return new Text(renderStatusLabel("running", label, theme), 0, 0);
|
|
188
|
+
}
|
|
167
189
|
|
|
168
190
|
if (total === 0) {
|
|
169
191
|
return new Text(theme.fg("dim", "No todos"), 0, 0);
|
|
170
192
|
}
|
|
171
193
|
|
|
172
|
-
let text =
|
|
194
|
+
let text = renderStatusLabel("success", label, theme);
|
|
173
195
|
|
|
174
196
|
if (expanded) {
|
|
175
|
-
for (
|
|
176
|
-
const
|
|
197
|
+
for (let i = 0; i < todos.length; i++) {
|
|
198
|
+
const todo = todos[i]!;
|
|
199
|
+
const iconChar = (STATUS_ICONS[todo.status] ?? "?").trim();
|
|
177
200
|
const icon =
|
|
178
201
|
todo.status === "completed"
|
|
179
202
|
? theme.fg("success", iconChar)
|
|
180
|
-
: todo.status === "
|
|
203
|
+
: todo.status === "in_progress"
|
|
181
204
|
? theme.fg("warning", iconChar)
|
|
182
205
|
: theme.fg("dim", iconChar);
|
|
183
206
|
const title =
|
|
184
207
|
todo.status === "completed"
|
|
185
|
-
? theme.fg("dim", theme.strikethrough(todo.
|
|
186
|
-
: todo.status === "
|
|
187
|
-
? theme.fg("warning", todo.
|
|
188
|
-
: theme.fg("muted", todo.
|
|
189
|
-
text += `\n ${icon} ${theme.fg("accent", `${
|
|
208
|
+
? theme.fg("dim", theme.strikethrough(todo.content ?? ""))
|
|
209
|
+
: todo.status === "in_progress"
|
|
210
|
+
? theme.fg("warning", todo.content ?? "")
|
|
211
|
+
: theme.fg("muted", todo.content ?? "");
|
|
212
|
+
text += `\n ${icon} ${theme.fg("accent", `${i + 1}.`)} ${title}`;
|
|
190
213
|
}
|
|
191
214
|
}
|
|
192
215
|
|
package/src/types.ts
CHANGED
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
* Core types for the todo list extension.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** Status of a single todo item */
|
|
6
|
-
export type TodoStatus = "
|
|
5
|
+
/** Status of a single todo item (Claude Code aligned). */
|
|
6
|
+
export type TodoStatus = "pending" | "in_progress" | "completed";
|
|
7
7
|
|
|
8
|
-
/** A single todo item */
|
|
8
|
+
/** A single todo item. No `id` — display numbering is array position. */
|
|
9
9
|
export interface TodoItem {
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
/** Detailed context, requirements, or implementation notes. */
|
|
15
|
-
description: string;
|
|
10
|
+
/** Short imperative label of the task (3-10 words). Displayed in UI. */
|
|
11
|
+
content: string;
|
|
12
|
+
/** Optional detailed context: file paths, methods, acceptance criteria. */
|
|
13
|
+
description?: string;
|
|
16
14
|
/** Current status. */
|
|
17
15
|
status: TodoStatus;
|
|
18
16
|
}
|
|
@@ -29,7 +27,7 @@ export interface TodoStats {
|
|
|
29
27
|
total: number;
|
|
30
28
|
completed: number;
|
|
31
29
|
inProgress: number;
|
|
32
|
-
|
|
30
|
+
pending: number;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
/** Validation result */
|
|
@@ -40,7 +38,7 @@ export interface ValidationResult {
|
|
|
40
38
|
|
|
41
39
|
/** Status icons for each todo state */
|
|
42
40
|
export const STATUS_ICONS: Record<TodoStatus, string> = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
completed: "✓",
|
|
42
|
+
in_progress: "◉ ",
|
|
43
|
+
pending: "○",
|
|
46
44
|
};
|
package/src/ui/todo-widget.ts
CHANGED
|
@@ -52,7 +52,9 @@ export function updateWidget(
|
|
|
52
52
|
for (const source of sortedSources) {
|
|
53
53
|
const todos = currentSub.get(source)!;
|
|
54
54
|
if (todos.length > 0) {
|
|
55
|
-
|
|
55
|
+
// Per-child sources are subagent:<agent>:<uuid>; render only the agent
|
|
56
|
+
// name so the header stays `subagent (myagent)` regardless of suffix.
|
|
57
|
+
const agentName = source.replace("subagent:", "").split(":")[0];
|
|
56
58
|
columns.push({ header: `subagent (${agentName})`, todos });
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -103,7 +105,7 @@ export function updateWidget(
|
|
|
103
105
|
const todo = column.todos[todoIndex];
|
|
104
106
|
if (todo) {
|
|
105
107
|
const icon = getStatusIcon(todo.status, theme);
|
|
106
|
-
const idStr = theme.fg("accent", `${
|
|
108
|
+
const idStr = theme.fg("accent", `${todoIndex + 1}.`);
|
|
107
109
|
const title = formatTodoTitle(todo, theme);
|
|
108
110
|
cellText = ` ${icon} ${idStr} ${title}`;
|
|
109
111
|
} else {
|
|
@@ -142,18 +144,18 @@ export function updateWidget(
|
|
|
142
144
|
function getStatusIcon(status: TodoItem["status"], theme: Theme): string {
|
|
143
145
|
const icon = STATUS_ICONS[status] ?? "?";
|
|
144
146
|
if (status === "completed") return theme.fg("success", icon);
|
|
145
|
-
if (status === "
|
|
147
|
+
if (status === "in_progress") return theme.fg("warning", icon.trim());
|
|
146
148
|
return theme.fg("dim", icon);
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
function formatTodoTitle(todo: TodoItem, theme: Theme): string {
|
|
150
152
|
if (todo.status === "completed") {
|
|
151
|
-
return theme.fg("dim", theme.strikethrough(todo.
|
|
153
|
+
return theme.fg("dim", theme.strikethrough(todo.content ?? ""));
|
|
152
154
|
}
|
|
153
|
-
if (todo.status === "
|
|
154
|
-
return theme.fg("warning", todo.
|
|
155
|
+
if (todo.status === "in_progress") {
|
|
156
|
+
return theme.fg("warning", todo.content ?? "");
|
|
155
157
|
}
|
|
156
|
-
return theme.fg("muted", todo.
|
|
158
|
+
return theme.fg("muted", todo.content ?? "");
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
/** Clear the widget */
|