@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.
@@ -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
- id: Type.Number({ description: "Unique identifier for the todo. Use sequential numbers starting from 1." }),
11
- title: Type.String({ description: "Concise action-oriented todo label (3-7 words). Displayed in UI." }),
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(["not-started", "in-progress", "completed"] as const, {
16
- description: "not-started: Not begun | in-progress: Currently working (multiple allowed for parallel work) | completed: Fully finished with no blockers",
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 in-progress)
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 in-progress before starting work
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
- - not-started: Todo not yet begun
58
- - in-progress: Currently working (multiple allowed for parallel work/subagents)
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
- let text = theme.fg("toolTitle", theme.bold("manage_todo_list "));
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
- if (!details) {
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
- return new Text(first && "text" in first ? first.text : "", 0, 0);
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(theme.fg("error", `✗ ${details.error}`), 0, 0);
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 = theme.fg("success", "✓ ") + theme.fg("muted", `${completed}/${total} completed`);
194
+ let text = renderStatusLabel("success", label, theme);
173
195
 
174
196
  if (expanded) {
175
- for (const todo of todos) {
176
- const iconChar = STATUS_ICONS[todo.status] ?? "?";
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 === "in-progress"
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.title))
186
- : todo.status === "in-progress"
187
- ? theme.fg("warning", todo.title)
188
- : theme.fg("muted", todo.title);
189
- text += `\n ${icon} ${theme.fg("accent", `${todo.id}.`)} ${title}`;
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 = "not-started" | "in-progress" | "completed";
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
- /** Sequential identifier starting from 1 */
11
- id: number;
12
- /** Concise action-oriented label (3-7 words). Displayed in UI. */
13
- title: string;
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
- notStarted: number;
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
- "completed": "✓",
44
- "in-progress": "◉ ",
45
- "not-started": "○",
41
+ completed: "✓",
42
+ in_progress: "◉ ",
43
+ pending: "○",
46
44
  };
@@ -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
- const agentName = source.replace("subagent:", "");
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", `${todo.id}.`);
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 === "in-progress") return theme.fg("warning", icon.trim());
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.title));
153
+ return theme.fg("dim", theme.strikethrough(todo.content ?? ""));
152
154
  }
153
- if (todo.status === "in-progress") {
154
- return theme.fg("warning", todo.title);
155
+ if (todo.status === "in_progress") {
156
+ return theme.fg("warning", todo.content ?? "");
155
157
  }
156
- return theme.fg("muted", todo.title);
158
+ return theme.fg("muted", todo.content ?? "");
157
159
  }
158
160
 
159
161
  /** Clear the widget */