@ian-pascoe/pi-todo 0.4.0 → 0.6.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 +5 -1
- package/package.json +7 -4
- package/src/pi-todo-extension.ts +42 -77
- package/src/todo-list.ts +128 -36
- package/src/todo-render.ts +153 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`@ian-pascoe/pi-todo` gives Pi agents a minimal, session-native **Todo List** without imposing a planning workflow.
|
|
4
4
|
|
|
5
|
-
Requires Node `>=22.19.0` and Pi `>=
|
|
5
|
+
Requires Node `>=22.19.0` and Pi `>=1.1.0`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -22,11 +22,14 @@ The `todo` tool supports five actions:
|
|
|
22
22
|
| `add` | `title`, optional `description` and `status` | Adds a Task |
|
|
23
23
|
| `add` | `tasks: [{ title, description? }]`, optional `status` | Adds several Tasks at once |
|
|
24
24
|
| `update` | `id`, plus fields to change | Updates a Task |
|
|
25
|
+
| `update` | `updates: [{ id, status?, title?, description? }]` | Updates several Tasks at once |
|
|
25
26
|
| `remove` | `id` | Removes one Task |
|
|
26
27
|
| `clear` | — | Removes every Task and resets IDs |
|
|
27
28
|
|
|
28
29
|
A batch `add` creates every Task in `tasks` atomically, with sequential IDs, and the result lists them. It writes one `pi-todo-state` session entry, so the tool group that made it projects a single Todo List Snapshot. If any title or description is empty, or the Task IDs would run out, it creates no Task. `tasks` must hold at least one entry and replaces `title`/`description`: a request that gives both `tasks` and `title` or a non-null `description` is rejected rather than guessed at. Per-Task fields other than `title` and `description` are rejected too. A top-level `status` applies to every new Task.
|
|
29
30
|
|
|
31
|
+
A batch `update` changes every Task in `updates` atomically and the result lists them in request order. It writes one `pi-todo-state` session entry, so the tool group that made it projects a single Todo List Snapshot. If any ID is unknown or repeated, any title or description is empty, or any entry provides no title, description, or status, it changes no Task. `updates` must hold at least one entry and replaces `id`, `title`, `description`, and `status`: a request that gives both `updates` and any of those (a `null` description counts as absent) is rejected rather than guessed at. Per-Task fields other than `id`, `status`, `title`, and `description` are rejected too.
|
|
32
|
+
|
|
30
33
|
A Pi `codemode` script receives the result as an object (the tool declares an `outputSchema`) instead of the text (its field names are already single words, so they match Pi's snake_case convention); the model still reads the same text, and failures still throw:
|
|
31
34
|
|
|
32
35
|
| Action | Script value |
|
|
@@ -34,6 +37,7 @@ A Pi `codemode` script receives the result as an object (the tool declares an `o
|
|
|
34
37
|
| `list` | `{ action: "list", tasks }`, each Task `{ id, title, description?, status }` |
|
|
35
38
|
| `add` / `update` | `{ action, task }` with the resulting Task |
|
|
36
39
|
| batch `add` | `{ action: "add", tasks }` with the created Tasks in ID order |
|
|
40
|
+
| batch `update` | `{ action: "update", tasks }` with the changed Tasks in request order |
|
|
37
41
|
| `remove` | `{ action: "remove", id }` |
|
|
38
42
|
| `clear` | `{ action: "clear", cleared }` with the number of Tasks removed |
|
|
39
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Minimal session-native Todo List for Pi agents.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,10 +32,13 @@
|
|
|
32
32
|
"access": "public",
|
|
33
33
|
"provenance": true
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ian-pascoe/pi-utils": "^0.5.0"
|
|
37
|
+
},
|
|
35
38
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-ai": ">=
|
|
37
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
38
|
-
"@earendil-works/pi-tui": ">=
|
|
39
|
+
"@earendil-works/pi-ai": ">=1.1.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
41
|
+
"@earendil-works/pi-tui": ">=1.1.0",
|
|
39
42
|
"typebox": "*"
|
|
40
43
|
},
|
|
41
44
|
"engines": {
|
package/src/pi-todo-extension.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { type Component
|
|
3
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import { noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
6
|
import { projectTodoContext, todoStateFromEntry } from "./todo-context.js";
|
|
6
7
|
import {
|
|
@@ -10,14 +11,13 @@ import {
|
|
|
10
11
|
TODO_ACTIONS,
|
|
11
12
|
TODO_STATUSES,
|
|
12
13
|
TodoToolOutputSchema,
|
|
13
|
-
todoStatusMarker,
|
|
14
14
|
type TodoActionInput,
|
|
15
15
|
type TodoStateSnapshot,
|
|
16
|
-
type TodoStatus,
|
|
17
16
|
type TodoTask,
|
|
18
17
|
TodoOperationError,
|
|
19
18
|
type TodoToolDetails,
|
|
20
19
|
} from "./todo-list.js";
|
|
20
|
+
import { renderTodoCall, renderTodoResult, renderTodoWidget } from "./todo-render.js";
|
|
21
21
|
import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
|
|
22
22
|
|
|
23
23
|
const TODO_STATE_ENTRY_TYPE = "pi-todo-state";
|
|
@@ -33,8 +33,6 @@ function assertTodoJournalReadable(context: ExtensionContext): void {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const TODO_WIDGET_ID = "pi-todo";
|
|
36
|
-
const TODO_WIDGET_STATUS_ORDER: readonly TodoStatus[] = ["active", "pending", "completed"];
|
|
37
|
-
const TODO_COLLAPSED_TASK_LIMIT = 5;
|
|
38
36
|
|
|
39
37
|
const TodoParameters = Type.Object({
|
|
40
38
|
action: StringEnum(TODO_ACTIONS),
|
|
@@ -42,16 +40,18 @@ const TodoParameters = Type.Object({
|
|
|
42
40
|
Type.Integer({
|
|
43
41
|
minimum: 1,
|
|
44
42
|
maximum: Number.MAX_SAFE_INTEGER,
|
|
45
|
-
description: "Task ID for update or remove",
|
|
43
|
+
description: "Task ID for update or remove; not allowed with updates",
|
|
46
44
|
}),
|
|
47
45
|
),
|
|
48
46
|
title: Type.Optional(
|
|
49
|
-
Type.String({
|
|
47
|
+
Type.String({
|
|
48
|
+
description: "Task title for add or update; not allowed with tasks or updates",
|
|
49
|
+
}),
|
|
50
50
|
),
|
|
51
51
|
description: Type.Optional(
|
|
52
52
|
Type.Union([Type.String(), Type.Null()], {
|
|
53
53
|
description:
|
|
54
|
-
"Optional Task description; null removes it during update; not allowed with tasks",
|
|
54
|
+
"Optional Task description; null removes it during update; not allowed with tasks or updates",
|
|
55
55
|
}),
|
|
56
56
|
),
|
|
57
57
|
tasks: Type.Optional(
|
|
@@ -70,8 +70,36 @@ const TodoParameters = Type.Object({
|
|
|
70
70
|
},
|
|
71
71
|
),
|
|
72
72
|
),
|
|
73
|
+
updates: Type.Optional(
|
|
74
|
+
Type.Array(
|
|
75
|
+
Type.Object(
|
|
76
|
+
{
|
|
77
|
+
id: Type.Integer({
|
|
78
|
+
minimum: 1,
|
|
79
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
80
|
+
description: "Task ID",
|
|
81
|
+
}),
|
|
82
|
+
status: Type.Optional(StringEnum(TODO_STATUSES, { description: "New Task status" })),
|
|
83
|
+
title: Type.Optional(Type.String({ description: "New Task title" })),
|
|
84
|
+
description: Type.Optional(
|
|
85
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
86
|
+
description: "New Task description; null removes it",
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
{ additionalProperties: false },
|
|
91
|
+
),
|
|
92
|
+
{
|
|
93
|
+
minItems: 1,
|
|
94
|
+
description:
|
|
95
|
+
"Task changes to apply in one call, all or none, one per Task ID; use instead of id, title, description, and status",
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
),
|
|
73
99
|
status: Type.Optional(
|
|
74
|
-
StringEnum(TODO_STATUSES, {
|
|
100
|
+
StringEnum(TODO_STATUSES, {
|
|
101
|
+
description: "Task status; for add, applies to every new Task; not allowed with updates",
|
|
102
|
+
}),
|
|
75
103
|
),
|
|
76
104
|
});
|
|
77
105
|
|
|
@@ -83,19 +111,6 @@ function restoreTodoState(context: ExtensionContext): TodoStateSnapshot {
|
|
|
83
111
|
return createEmptyTodoState();
|
|
84
112
|
}
|
|
85
113
|
|
|
86
|
-
function renderTodoTaskLine(task: TodoTask, theme: Theme): string {
|
|
87
|
-
const markerColor =
|
|
88
|
-
task.status === "active" ? "accent" : task.status === "completed" ? "success" : "muted";
|
|
89
|
-
const title =
|
|
90
|
-
task.status === "active"
|
|
91
|
-
? theme.bold(task.title)
|
|
92
|
-
: task.status === "completed"
|
|
93
|
-
? theme.strikethrough(task.title)
|
|
94
|
-
: task.title;
|
|
95
|
-
const titleColor = task.status === "completed" ? "dim" : "text";
|
|
96
|
-
return `${theme.fg(markerColor, todoStatusMarker(task.status))} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(titleColor, title)}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
114
|
class TodoWidget implements Component {
|
|
100
115
|
constructor(
|
|
101
116
|
private readonly tasks: readonly TodoTask[],
|
|
@@ -105,25 +120,7 @@ class TodoWidget implements Component {
|
|
|
105
120
|
invalidate(): void {}
|
|
106
121
|
|
|
107
122
|
render(width: number): string[] {
|
|
108
|
-
|
|
109
|
-
const pending = this.tasks.filter((task) => task.status === "pending").length;
|
|
110
|
-
const completed = this.tasks.length - active - pending;
|
|
111
|
-
const header =
|
|
112
|
-
this.theme.fg("toolTitle", this.theme.bold("TODO")) +
|
|
113
|
-
this.theme.fg("muted", ` ${active} active · ${pending} pending · ${completed} completed`);
|
|
114
|
-
const orderedTasks = this.tasks.toSorted((left, right) => {
|
|
115
|
-
const statusDifference =
|
|
116
|
-
TODO_WIDGET_STATUS_ORDER.indexOf(left.status) -
|
|
117
|
-
TODO_WIDGET_STATUS_ORDER.indexOf(right.status);
|
|
118
|
-
return statusDifference === 0 ? left.id - right.id : statusDifference;
|
|
119
|
-
});
|
|
120
|
-
const lines = [header];
|
|
121
|
-
for (const task of orderedTasks.slice(0, TODO_COLLAPSED_TASK_LIMIT)) {
|
|
122
|
-
lines.push(renderTodoTaskLine(task, this.theme));
|
|
123
|
-
}
|
|
124
|
-
const remaining = orderedTasks.length - TODO_COLLAPSED_TASK_LIMIT;
|
|
125
|
-
if (remaining > 0) lines.push(this.theme.fg("dim", `… ${remaining} more`));
|
|
126
|
-
return lines.map((line) => truncateToWidth(line, width, "…"));
|
|
123
|
+
return renderTodoWidget(this.tasks, this.theme, width);
|
|
127
124
|
}
|
|
128
125
|
}
|
|
129
126
|
|
|
@@ -210,41 +207,9 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
210
207
|
structuredContent: structuredClone(result.details),
|
|
211
208
|
};
|
|
212
209
|
},
|
|
213
|
-
renderCall: (params, theme) =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (params.action === "add" && params.title) text += theme.fg("dim", ` "${params.title}"`);
|
|
217
|
-
if (params.action === "add" && params.tasks) {
|
|
218
|
-
const count = params.tasks.length;
|
|
219
|
-
text += theme.fg("dim", ` ${formatTaskCount(count)}`);
|
|
220
|
-
}
|
|
221
|
-
return new Text(text, 0, 0);
|
|
222
|
-
},
|
|
223
|
-
renderResult: (result, { expanded }, theme) => {
|
|
224
|
-
const text = result.content.find((item) => item.type === "text");
|
|
225
|
-
if (!result.details) {
|
|
226
|
-
return new Text(theme.fg("error", text?.text ?? "Todo operation failed"), 0, 0);
|
|
227
|
-
}
|
|
228
|
-
if ("tasks" in result.details) {
|
|
229
|
-
const { action, tasks } = result.details;
|
|
230
|
-
if (tasks.length === 0) return new Text(theme.fg("dim", "Todo List is empty"), 0, 0);
|
|
231
|
-
const visibleTasks = expanded ? tasks : tasks.slice(0, TODO_COLLAPSED_TASK_LIMIT);
|
|
232
|
-
const heading = `${action === "add" ? "Added " : ""}${formatTaskCount(tasks.length)}:`;
|
|
233
|
-
const lines = [theme.fg("muted", heading)];
|
|
234
|
-
for (const task of visibleTasks) {
|
|
235
|
-
lines.push(renderTodoTaskLine(task, theme));
|
|
236
|
-
if (expanded && task.description) {
|
|
237
|
-
lines.push(
|
|
238
|
-
...task.description.split("\n").map((line) => theme.fg("dim", ` ${line}`)),
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
const remaining = tasks.length - visibleTasks.length;
|
|
243
|
-
if (remaining > 0) lines.push(theme.fg("dim", `… ${remaining} more`));
|
|
244
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
245
|
-
}
|
|
246
|
-
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text?.text ?? "Done"), 0, 0);
|
|
247
|
-
},
|
|
210
|
+
renderCall: (params, theme, context) => renderTodoCall(params, theme, context),
|
|
211
|
+
renderResult: (result, options, theme, context) =>
|
|
212
|
+
renderTodoResult(result, options, theme, context),
|
|
248
213
|
});
|
|
249
214
|
|
|
250
215
|
pi.registerCommand("todo", {
|
|
@@ -259,7 +224,7 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
259
224
|
return;
|
|
260
225
|
}
|
|
261
226
|
if (context.mode !== "tui") {
|
|
262
|
-
context.ui.notify("/todo clear requires interactive mode", "error");
|
|
227
|
+
context.ui.notify(noticeText("Todo", "/todo clear requires interactive mode"), "error");
|
|
263
228
|
return;
|
|
264
229
|
}
|
|
265
230
|
await context.waitForIdle();
|
package/src/todo-list.ts
CHANGED
|
@@ -38,6 +38,14 @@ export type TodoTaskDraft = {
|
|
|
38
38
|
readonly description?: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
/** One Task change within a batch `update`. */
|
|
42
|
+
export type TodoTaskChange = {
|
|
43
|
+
readonly id: number;
|
|
44
|
+
readonly title?: string;
|
|
45
|
+
readonly description?: string | null;
|
|
46
|
+
readonly status?: TodoStatus;
|
|
47
|
+
};
|
|
48
|
+
|
|
41
49
|
/** Boundary input shared by the Todo tool's five operations. */
|
|
42
50
|
export type TodoActionInput = {
|
|
43
51
|
readonly action: TodoAction;
|
|
@@ -45,17 +53,18 @@ export type TodoActionInput = {
|
|
|
45
53
|
readonly title?: string;
|
|
46
54
|
readonly description?: string | null;
|
|
47
55
|
readonly tasks?: readonly TodoTaskDraft[];
|
|
56
|
+
readonly updates?: readonly TodoTaskChange[];
|
|
48
57
|
readonly status?: TodoStatus;
|
|
49
58
|
};
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
61
|
* Render details and the tool's structured result. The action, plus `tasks` versus `task` for
|
|
53
|
-
* `add`, discriminates the list from the Task(s) a mutation added or updated, the ID
|
|
54
|
-
* and the count it cleared. A batch `add` returns the Tasks it created as `tasks`; a
|
|
55
|
-
* returns `task`.
|
|
62
|
+
* `add` and `update`, discriminates the list from the Task(s) a mutation added or updated, the ID
|
|
63
|
+
* it removed, and the count it cleared. A batch `add` returns the Tasks it created as `tasks`; a
|
|
64
|
+
* single `add` returns `task`; likewise a batch `update` returns the Tasks it changed as `tasks`.
|
|
56
65
|
*/
|
|
57
66
|
export type TodoToolDetails =
|
|
58
|
-
| { readonly action: "list" | "add"; readonly tasks: readonly TodoTask[] }
|
|
67
|
+
| { readonly action: "list" | "add" | "update"; readonly tasks: readonly TodoTask[] }
|
|
59
68
|
| { readonly action: "add" | "update"; readonly task: TodoTask }
|
|
60
69
|
| { readonly action: "remove"; readonly id: TodoTaskId }
|
|
61
70
|
| { readonly action: "clear"; readonly cleared: number };
|
|
@@ -74,14 +83,16 @@ const TodoTaskRecord = Type.Object({
|
|
|
74
83
|
/**
|
|
75
84
|
* JSON Schema of the `todo` tool's `structuredContent`, which codemode scripts receive instead of
|
|
76
85
|
* the model-facing text. Flat so Pi's one-line script declaration stays compact; `action` (and, for
|
|
77
|
-
* `add`, whether the request carried `tasks`) says which other field is
|
|
86
|
+
* `add` and `update`, whether the request carried `tasks` or `updates`) says which other field is
|
|
87
|
+
* present.
|
|
78
88
|
*/
|
|
79
89
|
export const TodoToolOutputSchema = Type.Object(
|
|
80
90
|
{
|
|
81
91
|
action: StringEnum(TODO_ACTIONS, { description: "The operation that ran" }),
|
|
82
92
|
tasks: Type.Optional(
|
|
83
93
|
Type.Array(TodoTaskRecord, {
|
|
84
|
-
description:
|
|
94
|
+
description:
|
|
95
|
+
"list: every Task in ID order; add with tasks: the Tasks created, in ID order; update with updates: the Tasks changed, in request order",
|
|
85
96
|
}),
|
|
86
97
|
),
|
|
87
98
|
task: Type.Optional(TodoTaskRecord),
|
|
@@ -201,7 +212,9 @@ function parseTodoDraft(
|
|
|
201
212
|
return { title: trimmedTitle, description: trimmedDescription };
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
function isTodoActionResult
|
|
215
|
+
function isTodoActionResult<T extends object>(
|
|
216
|
+
value: T | TodoActionResult,
|
|
217
|
+
): value is TodoActionResult {
|
|
205
218
|
return "ok" in value;
|
|
206
219
|
}
|
|
207
220
|
|
|
@@ -258,6 +271,107 @@ function addTasks(
|
|
|
258
271
|
};
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
/** Replaces each changed Task in place, keeping Task order and the next ID. */
|
|
275
|
+
function replaceTasks(state: TodoStateSnapshot, changed: readonly TodoTask[]): TodoStateSnapshot {
|
|
276
|
+
const byId = new Map(changed.map((task) => [task.id, task]));
|
|
277
|
+
return {
|
|
278
|
+
nextId: state.nextId,
|
|
279
|
+
tasks: state.tasks.map((candidate) => byId.get(candidate.id) ?? candidate),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Applies one change to a Task; `path` (e.g. `updates[1]`) locates a batch entry in failures. */
|
|
284
|
+
function changeTask(
|
|
285
|
+
task: TodoTask,
|
|
286
|
+
change: Pick<TodoTaskChange, "title" | "description" | "status">,
|
|
287
|
+
path: string | undefined,
|
|
288
|
+
): TodoTask | TodoActionResult {
|
|
289
|
+
if (
|
|
290
|
+
change.title === undefined &&
|
|
291
|
+
change.description === undefined &&
|
|
292
|
+
change.status === undefined
|
|
293
|
+
) {
|
|
294
|
+
return todoOperationFailure(
|
|
295
|
+
"update",
|
|
296
|
+
path === undefined
|
|
297
|
+
? "Todo update failed: provide a title, description, or status"
|
|
298
|
+
: `Todo update failed: ${path} must provide a title, description, or status`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const title = change.title === undefined ? task.title : change.title.trim();
|
|
302
|
+
if (!title) {
|
|
303
|
+
return todoOperationFailure(
|
|
304
|
+
"update",
|
|
305
|
+
`Todo update failed: ${path ? `${path}.` : ""}title must not be empty`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const description =
|
|
309
|
+
change.description === undefined ? task.description : change.description?.trim();
|
|
310
|
+
if (change.description !== undefined && change.description !== null && !description) {
|
|
311
|
+
return todoOperationFailure(
|
|
312
|
+
"update",
|
|
313
|
+
`Todo update failed: ${path ? `${path}.` : ""}description must not be empty`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return newTodoTask(task.id, title, description, change.status ?? task.status);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Validates every change before applying any, so a batch is all-or-nothing. */
|
|
320
|
+
function updateTasks(
|
|
321
|
+
state: TodoStateSnapshot,
|
|
322
|
+
input: TodoActionInput & { readonly updates: readonly TodoTaskChange[] },
|
|
323
|
+
): TodoActionResult {
|
|
324
|
+
// Models often send null for omitted fields; Pi strips those except where null is valid, so a
|
|
325
|
+
// null description here is treated as absent, as for a batch add.
|
|
326
|
+
if (
|
|
327
|
+
input.id !== undefined ||
|
|
328
|
+
input.title !== undefined ||
|
|
329
|
+
(input.description ?? undefined) !== undefined ||
|
|
330
|
+
input.status !== undefined
|
|
331
|
+
) {
|
|
332
|
+
return todoOperationFailure(
|
|
333
|
+
"update",
|
|
334
|
+
"Todo update failed: provide either id and fields or updates, not both",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (input.updates.length === 0) {
|
|
338
|
+
return todoOperationFailure("update", "Todo update failed: updates must not be empty");
|
|
339
|
+
}
|
|
340
|
+
const seen = new Map<number, number>();
|
|
341
|
+
const changed: TodoTask[] = [];
|
|
342
|
+
for (const [index, change] of input.updates.entries()) {
|
|
343
|
+
const path = `updates[${index}]`;
|
|
344
|
+
const id = parseTodoTaskId(change.id);
|
|
345
|
+
if (id === undefined) {
|
|
346
|
+
return todoOperationFailure(
|
|
347
|
+
"update",
|
|
348
|
+
`Todo update failed: ${path}.id must be a positive safe integer`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const first = seen.get(id);
|
|
352
|
+
if (first !== undefined) {
|
|
353
|
+
return todoOperationFailure(
|
|
354
|
+
"update",
|
|
355
|
+
`Todo update failed: ${path}.id #${id} duplicates updates[${first}].id`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
seen.set(id, index);
|
|
359
|
+
const task = state.tasks.find((candidate) => candidate.id === id);
|
|
360
|
+
if (!task) {
|
|
361
|
+
return todoOperationFailure("update", `Todo update failed: ${path}.id #${id} was not found`);
|
|
362
|
+
}
|
|
363
|
+
const result = changeTask(task, change, path);
|
|
364
|
+
if (isTodoActionResult(result)) return result;
|
|
365
|
+
changed.push(result);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
state: replaceTasks(state, changed),
|
|
370
|
+
message: `Updated ${formatTaskCount(changed.length)}\n${formatTodoList(changed)}`,
|
|
371
|
+
details: { action: "update", tasks: changed },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
261
375
|
/** Applies one validated-by-schema tool request without performing session or UI effects. */
|
|
262
376
|
export function applyTodoAction(
|
|
263
377
|
state: TodoStateSnapshot,
|
|
@@ -280,6 +394,9 @@ export function applyTodoAction(
|
|
|
280
394
|
case "update":
|
|
281
395
|
case "remove": {
|
|
282
396
|
const action = input.action;
|
|
397
|
+
if (action === "update" && input.updates !== undefined) {
|
|
398
|
+
return updateTasks(state, { ...input, updates: input.updates });
|
|
399
|
+
}
|
|
283
400
|
if (input.id === undefined) {
|
|
284
401
|
return todoOperationFailure(action, `Todo ${action} failed: id is required`);
|
|
285
402
|
}
|
|
@@ -308,38 +425,13 @@ export function applyTodoAction(
|
|
|
308
425
|
details: { action: "remove", id: task.id },
|
|
309
426
|
};
|
|
310
427
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
input.description === undefined &&
|
|
314
|
-
input.status === undefined
|
|
315
|
-
) {
|
|
316
|
-
return todoOperationFailure(
|
|
317
|
-
"update",
|
|
318
|
-
"Todo update failed: provide a title, description, or status",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
const title = input.title === undefined ? task.title : input.title.trim();
|
|
322
|
-
if (!title) {
|
|
323
|
-
return todoOperationFailure("update", "Todo update failed: title must not be empty");
|
|
324
|
-
}
|
|
325
|
-
const description =
|
|
326
|
-
input.description === undefined ? task.description : input.description?.trim();
|
|
327
|
-
if (input.description !== undefined && input.description !== null && !description) {
|
|
328
|
-
return todoOperationFailure("update", "Todo update failed: description must not be empty");
|
|
329
|
-
}
|
|
330
|
-
const updatedTask: TodoTask = description
|
|
331
|
-
? { id: task.id, title, description, status: input.status ?? task.status }
|
|
332
|
-
: { id: task.id, title, status: input.status ?? task.status };
|
|
428
|
+
const changed = changeTask(task, input, undefined);
|
|
429
|
+
if (isTodoActionResult(changed)) return changed;
|
|
333
430
|
return {
|
|
334
431
|
ok: true,
|
|
335
|
-
state:
|
|
336
|
-
nextId: state.nextId,
|
|
337
|
-
tasks: state.tasks.map((candidate) =>
|
|
338
|
-
candidate.id === updatedTask.id ? updatedTask : candidate,
|
|
339
|
-
),
|
|
340
|
-
},
|
|
432
|
+
state: replaceTasks(state, [changed]),
|
|
341
433
|
message: `Updated Task #${task.id}`,
|
|
342
|
-
details: { action: "update", task:
|
|
434
|
+
details: { action: "update", task: changed },
|
|
343
435
|
};
|
|
344
436
|
}
|
|
345
437
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
appendDurationFooter,
|
|
5
|
+
callDurationFooter,
|
|
6
|
+
COLLAPSED_LINES,
|
|
7
|
+
type DurationContext,
|
|
8
|
+
expandHint,
|
|
9
|
+
previewBody,
|
|
10
|
+
SEPARATOR,
|
|
11
|
+
toolHeader,
|
|
12
|
+
type UiTheme,
|
|
13
|
+
widgetLines,
|
|
14
|
+
} from "@ian-pascoe/pi-utils/ui";
|
|
15
|
+
import {
|
|
16
|
+
formatTaskCount,
|
|
17
|
+
type TodoActionInput,
|
|
18
|
+
type TodoTask,
|
|
19
|
+
type TodoToolDetails,
|
|
20
|
+
} from "./todo-list.js";
|
|
21
|
+
|
|
22
|
+
/** The theme methods the Todo renderers draw with. */
|
|
23
|
+
export type TodoRenderTheme = Pick<UiTheme, "fg" | "bold" | "strikethrough">;
|
|
24
|
+
|
|
25
|
+
const TODO_WIDGET_STATUS_ORDER: readonly TodoTask["status"][] = ["active", "pending", "completed"];
|
|
26
|
+
|
|
27
|
+
const CHECKBOX_FILL = { pending: " ", active: ">", completed: "x" } as const;
|
|
28
|
+
const CHECKBOX_COLOR = { pending: "muted", active: "accent", completed: "success" } as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One Task row. The `[ ]`/`[>]`/`[x]` checkbox is the documented exception to the no-marks rule
|
|
32
|
+
* for tool rows: it shows the Task's state, not the tool's outcome.
|
|
33
|
+
*/
|
|
34
|
+
export function renderTodoTaskLine(task: TodoTask, theme: TodoRenderTheme): string {
|
|
35
|
+
const box =
|
|
36
|
+
theme.fg("dim", "[") +
|
|
37
|
+
theme.fg(CHECKBOX_COLOR[task.status], CHECKBOX_FILL[task.status]) +
|
|
38
|
+
theme.fg("dim", "]");
|
|
39
|
+
const title =
|
|
40
|
+
task.status === "active"
|
|
41
|
+
? theme.bold(task.title)
|
|
42
|
+
: task.status === "completed"
|
|
43
|
+
? theme.strikethrough(task.title)
|
|
44
|
+
: task.title;
|
|
45
|
+
const titleColor = task.status === "completed" ? "dim" : "text";
|
|
46
|
+
return `${box} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(titleColor, title)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The Todo Widget lines: Active Tasks first, then pending, then completed, in at most 10 lines. */
|
|
50
|
+
export function renderTodoWidget(
|
|
51
|
+
tasks: readonly TodoTask[],
|
|
52
|
+
theme: TodoRenderTheme,
|
|
53
|
+
width: number,
|
|
54
|
+
): string[] {
|
|
55
|
+
const active = tasks.filter((task) => task.status === "active").length;
|
|
56
|
+
const pending = tasks.filter((task) => task.status === "pending").length;
|
|
57
|
+
const completed = tasks.length - active - pending;
|
|
58
|
+
const orderedTasks = tasks.toSorted((left, right) => {
|
|
59
|
+
const statusDifference =
|
|
60
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(left.status) -
|
|
61
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(right.status);
|
|
62
|
+
return statusDifference === 0 ? left.id - right.id : statusDifference;
|
|
63
|
+
});
|
|
64
|
+
return widgetLines(theme, {
|
|
65
|
+
title: "Todo",
|
|
66
|
+
counts: [`${active} active`, `${pending} pending`, `${completed} completed`].join(SEPARATOR),
|
|
67
|
+
rows: orderedTasks.map((task) => renderTodoTaskLine(task, theme)),
|
|
68
|
+
}).map((line) => truncateToWidth(line, width, "..."));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function callArguments(params: TodoActionInput): string | undefined {
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
if (params.id !== undefined) parts.push(`#${params.id}`);
|
|
74
|
+
if (params.action === "add" && params.title) parts.push(`"${params.title}"`);
|
|
75
|
+
if (params.action === "add" && params.tasks) parts.push(formatTaskCount(params.tasks.length));
|
|
76
|
+
if (params.action === "update" && params.updates) {
|
|
77
|
+
parts.push(formatTaskCount(params.updates.length));
|
|
78
|
+
}
|
|
79
|
+
return parts.length === 0 ? undefined : parts.join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `todo` call row: bold `todo`, the action in `accent`, arguments in `muted`, then `Elapsed`. */
|
|
83
|
+
export function renderTodoCall(
|
|
84
|
+
params: TodoActionInput,
|
|
85
|
+
theme: TodoRenderTheme,
|
|
86
|
+
context: DurationContext,
|
|
87
|
+
): Component {
|
|
88
|
+
const container = new Container();
|
|
89
|
+
container.addChild(
|
|
90
|
+
new Text(toolHeader(theme, "todo", params.action, callArguments(params)), 0, 0),
|
|
91
|
+
);
|
|
92
|
+
container.addChild(callDurationFooter(theme, context));
|
|
93
|
+
return container;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function taskListLines(
|
|
97
|
+
action: "list" | "add" | "update",
|
|
98
|
+
tasks: readonly TodoTask[],
|
|
99
|
+
theme: TodoRenderTheme,
|
|
100
|
+
expanded: boolean,
|
|
101
|
+
): string[] {
|
|
102
|
+
const verb = action === "add" ? "Added " : action === "update" ? "Updated " : "";
|
|
103
|
+
const lines = [theme.fg("muted", `${verb}${formatTaskCount(tasks.length)}:`)];
|
|
104
|
+
if (expanded) {
|
|
105
|
+
for (const task of tasks) {
|
|
106
|
+
lines.push(renderTodoTaskLine(task, theme));
|
|
107
|
+
if (task.description) {
|
|
108
|
+
lines.push(...task.description.split("\n").map((line) => theme.fg("dim", ` ${line}`)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
lines.push(
|
|
114
|
+
...tasks.slice(0, COLLAPSED_LINES.list).map((task) => renderTodoTaskLine(task, theme)),
|
|
115
|
+
);
|
|
116
|
+
const hidden = tasks.length - COLLAPSED_LINES.list;
|
|
117
|
+
if (hidden > 0) lines.push(expandHint(theme, hidden));
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `todo` result row. A Task list is the Nearest Built-in `ls` (20 rows collapsed, Pi's Expand
|
|
123
|
+
* Hint beyond that); a failure shows its text in `error`. Every result ends with `Took`.
|
|
124
|
+
*/
|
|
125
|
+
export function renderTodoResult(
|
|
126
|
+
result: AgentToolResult<TodoToolDetails | undefined>,
|
|
127
|
+
options: ToolRenderResultOptions,
|
|
128
|
+
theme: TodoRenderTheme,
|
|
129
|
+
context: DurationContext,
|
|
130
|
+
): Component {
|
|
131
|
+
const text = result.content.find((item) => item.type === "text")?.text;
|
|
132
|
+
let body: string[];
|
|
133
|
+
if (!result.details) {
|
|
134
|
+
body = previewBody(theme, (text ?? "Todo operation failed").split("\n"), {
|
|
135
|
+
limit: COLLAPSED_LINES.fallback,
|
|
136
|
+
expanded: options.expanded,
|
|
137
|
+
color: "error",
|
|
138
|
+
});
|
|
139
|
+
} else if ("tasks" in result.details && result.details.tasks.length > 0) {
|
|
140
|
+
body = taskListLines(result.details.action, result.details.tasks, theme, options.expanded);
|
|
141
|
+
} else {
|
|
142
|
+
const message = "tasks" in result.details ? "Todo List is empty" : (text ?? "Done");
|
|
143
|
+
body = previewBody(theme, message.split("\n"), {
|
|
144
|
+
limit: COLLAPSED_LINES.fallback,
|
|
145
|
+
expanded: options.expanded,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const container = new Container();
|
|
149
|
+
container.addChild(new Spacer(1));
|
|
150
|
+
container.addChild(new Text(body.join("\n"), 0, 0));
|
|
151
|
+
appendDurationFooter(container, theme, context, { isPartial: options.isPartial });
|
|
152
|
+
return container;
|
|
153
|
+
}
|