@ian-pascoe/pi-todo 0.3.0 → 0.5.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 +15 -7
- package/package.json +1 -1
- package/src/pi-todo-extension.ts +69 -10
- package/src/todo-list.ts +233 -57
package/README.md
CHANGED
|
@@ -16,13 +16,19 @@ pi -e ./src/index.ts
|
|
|
16
16
|
|
|
17
17
|
The `todo` tool supports five actions:
|
|
18
18
|
|
|
19
|
-
| Action | Input
|
|
20
|
-
| -------- |
|
|
21
|
-
| `list` | —
|
|
22
|
-
| `add` | `title`, optional `description` and `status`
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
19
|
+
| Action | Input | Result |
|
|
20
|
+
| -------- | ----------------------------------------------------- | --------------------------------- |
|
|
21
|
+
| `list` | — | Lists every Task |
|
|
22
|
+
| `add` | `title`, optional `description` and `status` | Adds a Task |
|
|
23
|
+
| `add` | `tasks: [{ title, description? }]`, optional `status` | Adds several Tasks at once |
|
|
24
|
+
| `update` | `id`, plus fields to change | Updates a Task |
|
|
25
|
+
| `update` | `updates: [{ id, status?, title?, description? }]` | Updates several Tasks at once |
|
|
26
|
+
| `remove` | `id` | Removes one Task |
|
|
27
|
+
| `clear` | — | Removes every Task and resets IDs |
|
|
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.
|
|
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.
|
|
26
32
|
|
|
27
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:
|
|
28
34
|
|
|
@@ -30,6 +36,8 @@ A Pi `codemode` script receives the result as an object (the tool declares an `o
|
|
|
30
36
|
| ---------------- | ---------------------------------------------------------------------------- |
|
|
31
37
|
| `list` | `{ action: "list", tasks }`, each Task `{ id, title, description?, status }` |
|
|
32
38
|
| `add` / `update` | `{ action, task }` with the resulting Task |
|
|
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 |
|
|
33
41
|
| `remove` | `{ action: "remove", id }` |
|
|
34
42
|
| `clear` | `{ action: "clear", cleared }` with the number of Tasks removed |
|
|
35
43
|
|
package/package.json
CHANGED
package/src/pi-todo-extension.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { projectTodoContext, todoStateFromEntry } from "./todo-context.js";
|
|
|
6
6
|
import {
|
|
7
7
|
applyTodoAction,
|
|
8
8
|
createEmptyTodoState,
|
|
9
|
+
formatTaskCount,
|
|
9
10
|
TODO_ACTIONS,
|
|
10
11
|
TODO_STATUSES,
|
|
11
12
|
TodoToolOutputSchema,
|
|
@@ -41,16 +42,67 @@ const TodoParameters = Type.Object({
|
|
|
41
42
|
Type.Integer({
|
|
42
43
|
minimum: 1,
|
|
43
44
|
maximum: Number.MAX_SAFE_INTEGER,
|
|
44
|
-
description: "Task ID for update or remove",
|
|
45
|
+
description: "Task ID for update or remove; not allowed with updates",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
title: Type.Optional(
|
|
49
|
+
Type.String({
|
|
50
|
+
description: "Task title for add or update; not allowed with tasks or updates",
|
|
45
51
|
}),
|
|
46
52
|
),
|
|
47
|
-
title: Type.Optional(Type.String({ description: "Task title for add or update" })),
|
|
48
53
|
description: Type.Optional(
|
|
49
54
|
Type.Union([Type.String(), Type.Null()], {
|
|
50
|
-
description:
|
|
55
|
+
description:
|
|
56
|
+
"Optional Task description; null removes it during update; not allowed with tasks or updates",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
tasks: Type.Optional(
|
|
60
|
+
Type.Array(
|
|
61
|
+
Type.Object(
|
|
62
|
+
{
|
|
63
|
+
title: Type.String({ description: "Task title" }),
|
|
64
|
+
description: Type.Optional(Type.String({ description: "Optional Task description" })),
|
|
65
|
+
},
|
|
66
|
+
{ additionalProperties: false },
|
|
67
|
+
),
|
|
68
|
+
{
|
|
69
|
+
minItems: 1,
|
|
70
|
+
description:
|
|
71
|
+
"Tasks to add in one call, all or none, with sequential IDs; use instead of title and description",
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
updates: Type.Optional(
|
|
76
|
+
Type.Array(
|
|
77
|
+
Type.Object(
|
|
78
|
+
{
|
|
79
|
+
id: Type.Integer({
|
|
80
|
+
minimum: 1,
|
|
81
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
82
|
+
description: "Task ID",
|
|
83
|
+
}),
|
|
84
|
+
status: Type.Optional(StringEnum(TODO_STATUSES, { description: "New Task status" })),
|
|
85
|
+
title: Type.Optional(Type.String({ description: "New Task title" })),
|
|
86
|
+
description: Type.Optional(
|
|
87
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
88
|
+
description: "New Task description; null removes it",
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{ additionalProperties: false },
|
|
93
|
+
),
|
|
94
|
+
{
|
|
95
|
+
minItems: 1,
|
|
96
|
+
description:
|
|
97
|
+
"Task changes to apply in one call, all or none, one per Task ID; use instead of id, title, description, and status",
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
status: Type.Optional(
|
|
102
|
+
StringEnum(TODO_STATUSES, {
|
|
103
|
+
description: "Task status; for add, applies to every new Task; not allowed with updates",
|
|
51
104
|
}),
|
|
52
105
|
),
|
|
53
|
-
status: Type.Optional(StringEnum(TODO_STATUSES)),
|
|
54
106
|
});
|
|
55
107
|
|
|
56
108
|
function restoreTodoState(context: ExtensionContext): TodoStateSnapshot {
|
|
@@ -192,6 +244,13 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
192
244
|
let text = theme.fg("toolTitle", theme.bold("todo")) + theme.fg("muted", ` ${params.action}`);
|
|
193
245
|
if (params.id !== undefined) text += theme.fg("accent", ` #${params.id}`);
|
|
194
246
|
if (params.action === "add" && params.title) text += theme.fg("dim", ` "${params.title}"`);
|
|
247
|
+
if (params.action === "add" && params.tasks) {
|
|
248
|
+
const count = params.tasks.length;
|
|
249
|
+
text += theme.fg("dim", ` ${formatTaskCount(count)}`);
|
|
250
|
+
}
|
|
251
|
+
if (params.action === "update" && params.updates) {
|
|
252
|
+
text += theme.fg("dim", ` ${formatTaskCount(params.updates.length)}`);
|
|
253
|
+
}
|
|
195
254
|
return new Text(text, 0, 0);
|
|
196
255
|
},
|
|
197
256
|
renderResult: (result, { expanded }, theme) => {
|
|
@@ -199,13 +258,13 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
199
258
|
if (!result.details) {
|
|
200
259
|
return new Text(theme.fg("error", text?.text ?? "Todo operation failed"), 0, 0);
|
|
201
260
|
}
|
|
202
|
-
if (result.details
|
|
203
|
-
const tasks = result.details
|
|
261
|
+
if ("tasks" in result.details) {
|
|
262
|
+
const { action, tasks } = result.details;
|
|
204
263
|
if (tasks.length === 0) return new Text(theme.fg("dim", "Todo List is empty"), 0, 0);
|
|
205
264
|
const visibleTasks = expanded ? tasks : tasks.slice(0, TODO_COLLAPSED_TASK_LIMIT);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
];
|
|
265
|
+
const verb = action === "add" ? "Added " : action === "update" ? "Updated " : "";
|
|
266
|
+
const heading = `${verb}${formatTaskCount(tasks.length)}:`;
|
|
267
|
+
const lines = [theme.fg("muted", heading)];
|
|
209
268
|
for (const task of visibleTasks) {
|
|
210
269
|
lines.push(renderTodoTaskLine(task, theme));
|
|
211
270
|
if (expanded && task.description) {
|
|
@@ -244,7 +303,7 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
244
303
|
}
|
|
245
304
|
const confirmed = await context.ui.confirm(
|
|
246
305
|
"Clear Todo List",
|
|
247
|
-
`Remove all ${state.tasks.length}
|
|
306
|
+
`Remove all ${formatTaskCount(state.tasks.length)}?`,
|
|
248
307
|
);
|
|
249
308
|
if (!confirmed) return;
|
|
250
309
|
const result = runTodoAction({ action: "clear" }, context);
|
package/src/todo-list.ts
CHANGED
|
@@ -32,21 +32,39 @@ export type TodoStateSnapshot = {
|
|
|
32
32
|
readonly tasks: readonly TodoTask[];
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/** One Task to create within a batch `add`. */
|
|
36
|
+
export type TodoTaskDraft = {
|
|
37
|
+
readonly title: string;
|
|
38
|
+
readonly description?: string;
|
|
39
|
+
};
|
|
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
|
+
|
|
35
49
|
/** Boundary input shared by the Todo tool's five operations. */
|
|
36
50
|
export type TodoActionInput = {
|
|
37
51
|
readonly action: TodoAction;
|
|
38
52
|
readonly id?: number;
|
|
39
53
|
readonly title?: string;
|
|
40
54
|
readonly description?: string | null;
|
|
55
|
+
readonly tasks?: readonly TodoTaskDraft[];
|
|
56
|
+
readonly updates?: readonly TodoTaskChange[];
|
|
41
57
|
readonly status?: TodoStatus;
|
|
42
58
|
};
|
|
43
59
|
|
|
44
60
|
/**
|
|
45
|
-
* Render details and the tool's structured result. The action
|
|
46
|
-
* Task a mutation added or updated, the ID
|
|
61
|
+
* Render details and the tool's structured result. The action, plus `tasks` versus `task` for
|
|
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`.
|
|
47
65
|
*/
|
|
48
66
|
export type TodoToolDetails =
|
|
49
|
-
| { readonly action: "list"; readonly tasks: readonly TodoTask[] }
|
|
67
|
+
| { readonly action: "list" | "add" | "update"; readonly tasks: readonly TodoTask[] }
|
|
50
68
|
| { readonly action: "add" | "update"; readonly task: TodoTask }
|
|
51
69
|
| { readonly action: "remove"; readonly id: TodoTaskId }
|
|
52
70
|
| { readonly action: "clear"; readonly cleared: number };
|
|
@@ -64,14 +82,18 @@ const TodoTaskRecord = Type.Object({
|
|
|
64
82
|
|
|
65
83
|
/**
|
|
66
84
|
* JSON Schema of the `todo` tool's `structuredContent`, which codemode scripts receive instead of
|
|
67
|
-
* the model-facing text. Flat so Pi's one-line script declaration stays compact; `action`
|
|
68
|
-
* which other field is
|
|
85
|
+
* the model-facing text. Flat so Pi's one-line script declaration stays compact; `action` (and, for
|
|
86
|
+
* `add` and `update`, whether the request carried `tasks` or `updates`) says which other field is
|
|
87
|
+
* present.
|
|
69
88
|
*/
|
|
70
89
|
export const TodoToolOutputSchema = Type.Object(
|
|
71
90
|
{
|
|
72
91
|
action: StringEnum(TODO_ACTIONS, { description: "The operation that ran" }),
|
|
73
92
|
tasks: Type.Optional(
|
|
74
|
-
Type.Array(TodoTaskRecord, {
|
|
93
|
+
Type.Array(TodoTaskRecord, {
|
|
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",
|
|
96
|
+
}),
|
|
75
97
|
),
|
|
76
98
|
task: Type.Optional(TodoTaskRecord),
|
|
77
99
|
id: Type.Optional(
|
|
@@ -157,6 +179,199 @@ function todoOperationFailure(action: TodoAction, message: string): TodoActionRe
|
|
|
157
179
|
return { ok: false, error: new TodoOperationError(action, message) };
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
function newTodoTask(
|
|
183
|
+
id: TodoTaskId,
|
|
184
|
+
title: string,
|
|
185
|
+
description: string | undefined,
|
|
186
|
+
status: TodoStatus,
|
|
187
|
+
): TodoTask {
|
|
188
|
+
return description ? { id, title, description, status } : { id, title, status };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Counts Tasks in model-facing text: "1 Task", "3 Tasks". */
|
|
192
|
+
export function formatTaskCount(count: number): string {
|
|
193
|
+
return `${count} ${count === 1 ? "Task" : "Tasks"}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type ParsedTodoDraft = { readonly title: string; readonly description: string | undefined };
|
|
197
|
+
|
|
198
|
+
/** Trims and validates one Task to add; `field` prefixes the field name in the failure message. */
|
|
199
|
+
function parseTodoDraft(
|
|
200
|
+
title: string | undefined,
|
|
201
|
+
description: string | null | undefined,
|
|
202
|
+
field: string,
|
|
203
|
+
): ParsedTodoDraft | TodoActionResult {
|
|
204
|
+
const trimmedTitle = title?.trim();
|
|
205
|
+
if (!trimmedTitle) {
|
|
206
|
+
return todoOperationFailure("add", `Todo add failed: ${field}title must not be empty`);
|
|
207
|
+
}
|
|
208
|
+
const trimmedDescription = description?.trim();
|
|
209
|
+
if (description !== undefined && description !== null && !trimmedDescription) {
|
|
210
|
+
return todoOperationFailure("add", `Todo add failed: ${field}description must not be empty`);
|
|
211
|
+
}
|
|
212
|
+
return { title: trimmedTitle, description: trimmedDescription };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isTodoActionResult<T extends object>(
|
|
216
|
+
value: T | TodoActionResult,
|
|
217
|
+
): value is TodoActionResult {
|
|
218
|
+
return "ok" in value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addTask(state: TodoStateSnapshot, input: TodoActionInput): TodoActionResult {
|
|
222
|
+
const draft = parseTodoDraft(input.title, input.description, "");
|
|
223
|
+
if (isTodoActionResult(draft)) return draft;
|
|
224
|
+
const id = parseTodoTaskId(state.nextId);
|
|
225
|
+
if (id === undefined || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
226
|
+
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
227
|
+
}
|
|
228
|
+
const task = newTodoTask(id, draft.title, draft.description, input.status ?? "pending");
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
|
|
232
|
+
message: `Added Task #${task.id}`,
|
|
233
|
+
details: { action: "add", task },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Validates every draft before creating any Task, so a batch is all-or-nothing. */
|
|
238
|
+
function addTasks(
|
|
239
|
+
state: TodoStateSnapshot,
|
|
240
|
+
input: TodoActionInput & { readonly tasks: readonly TodoTaskDraft[] },
|
|
241
|
+
): TodoActionResult {
|
|
242
|
+
// A null description is "absent" for add, as it is for a single Task.
|
|
243
|
+
if (input.title !== undefined || (input.description ?? undefined) !== undefined) {
|
|
244
|
+
return todoOperationFailure("add", "Todo add failed: provide either title or tasks, not both");
|
|
245
|
+
}
|
|
246
|
+
if (input.tasks.length === 0) {
|
|
247
|
+
return todoOperationFailure("add", "Todo add failed: tasks must not be empty");
|
|
248
|
+
}
|
|
249
|
+
const drafts: ParsedTodoDraft[] = [];
|
|
250
|
+
for (const [index, task] of input.tasks.entries()) {
|
|
251
|
+
const draft = parseTodoDraft(task.title, task.description, `tasks[${index}].`);
|
|
252
|
+
if (isTodoActionResult(draft)) return draft;
|
|
253
|
+
drafts.push(draft);
|
|
254
|
+
}
|
|
255
|
+
// The last Task's successor ID must stay a safe integer so the saved state restores.
|
|
256
|
+
const exceedsIdLimit = state.nextId + drafts.length > Number.MAX_SAFE_INTEGER;
|
|
257
|
+
const status = input.status ?? "pending";
|
|
258
|
+
const added: TodoTask[] = [];
|
|
259
|
+
for (const [index, { title, description }] of drafts.entries()) {
|
|
260
|
+
const id = parseTodoTaskId(state.nextId + index);
|
|
261
|
+
if (id === undefined || exceedsIdLimit) {
|
|
262
|
+
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
263
|
+
}
|
|
264
|
+
added.push(newTodoTask(id, title, description, status));
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
state: { nextId: state.nextId + added.length, tasks: [...state.tasks, ...added] },
|
|
269
|
+
message: `Added ${formatTaskCount(added.length)}\n${formatTodoList(added)}`,
|
|
270
|
+
details: { action: "add", tasks: added },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
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
|
+
|
|
160
375
|
/** Applies one validated-by-schema tool request without performing session or UI effects. */
|
|
161
376
|
export function applyTodoAction(
|
|
162
377
|
state: TodoStateSnapshot,
|
|
@@ -171,31 +386,17 @@ export function applyTodoAction(
|
|
|
171
386
|
details: { action: "list", tasks: state.tasks },
|
|
172
387
|
};
|
|
173
388
|
|
|
174
|
-
case "add":
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (input.description !== undefined && input.description !== null && !description) {
|
|
179
|
-
return todoOperationFailure("add", "Todo add failed: description must not be empty");
|
|
180
|
-
}
|
|
181
|
-
const id = parseTodoTaskId(state.nextId);
|
|
182
|
-
if (id === undefined || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
183
|
-
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
184
|
-
}
|
|
185
|
-
const task: TodoTask = description
|
|
186
|
-
? { id, title, description, status: input.status ?? "pending" }
|
|
187
|
-
: { id, title, status: input.status ?? "pending" };
|
|
188
|
-
return {
|
|
189
|
-
ok: true,
|
|
190
|
-
state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
|
|
191
|
-
message: `Added Task #${task.id}`,
|
|
192
|
-
details: { action: "add", task },
|
|
193
|
-
};
|
|
194
|
-
}
|
|
389
|
+
case "add":
|
|
390
|
+
return input.tasks === undefined
|
|
391
|
+
? addTask(state, input)
|
|
392
|
+
: addTasks(state, { ...input, tasks: input.tasks });
|
|
195
393
|
|
|
196
394
|
case "update":
|
|
197
395
|
case "remove": {
|
|
198
396
|
const action = input.action;
|
|
397
|
+
if (action === "update" && input.updates !== undefined) {
|
|
398
|
+
return updateTasks(state, { ...input, updates: input.updates });
|
|
399
|
+
}
|
|
199
400
|
if (input.id === undefined) {
|
|
200
401
|
return todoOperationFailure(action, `Todo ${action} failed: id is required`);
|
|
201
402
|
}
|
|
@@ -224,38 +425,13 @@ export function applyTodoAction(
|
|
|
224
425
|
details: { action: "remove", id: task.id },
|
|
225
426
|
};
|
|
226
427
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
input.description === undefined &&
|
|
230
|
-
input.status === undefined
|
|
231
|
-
) {
|
|
232
|
-
return todoOperationFailure(
|
|
233
|
-
"update",
|
|
234
|
-
"Todo update failed: provide a title, description, or status",
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
const title = input.title === undefined ? task.title : input.title.trim();
|
|
238
|
-
if (!title) {
|
|
239
|
-
return todoOperationFailure("update", "Todo update failed: title must not be empty");
|
|
240
|
-
}
|
|
241
|
-
const description =
|
|
242
|
-
input.description === undefined ? task.description : input.description?.trim();
|
|
243
|
-
if (input.description !== undefined && input.description !== null && !description) {
|
|
244
|
-
return todoOperationFailure("update", "Todo update failed: description must not be empty");
|
|
245
|
-
}
|
|
246
|
-
const updatedTask: TodoTask = description
|
|
247
|
-
? { id: task.id, title, description, status: input.status ?? task.status }
|
|
248
|
-
: { id: task.id, title, status: input.status ?? task.status };
|
|
428
|
+
const changed = changeTask(task, input, undefined);
|
|
429
|
+
if (isTodoActionResult(changed)) return changed;
|
|
249
430
|
return {
|
|
250
431
|
ok: true,
|
|
251
|
-
state:
|
|
252
|
-
nextId: state.nextId,
|
|
253
|
-
tasks: state.tasks.map((candidate) =>
|
|
254
|
-
candidate.id === updatedTask.id ? updatedTask : candidate,
|
|
255
|
-
),
|
|
256
|
-
},
|
|
432
|
+
state: replaceTasks(state, [changed]),
|
|
257
433
|
message: `Updated Task #${task.id}`,
|
|
258
|
-
details: { action: "update", task:
|
|
434
|
+
details: { action: "update", task: changed },
|
|
259
435
|
};
|
|
260
436
|
}
|
|
261
437
|
|
|
@@ -264,7 +440,7 @@ export function applyTodoAction(
|
|
|
264
440
|
return {
|
|
265
441
|
ok: true,
|
|
266
442
|
state: count > 0 || state.nextId !== 1 ? createEmptyTodoState() : state,
|
|
267
|
-
message: `Cleared ${count}
|
|
443
|
+
message: `Cleared ${formatTaskCount(count)}`,
|
|
268
444
|
details: { action: "clear", cleared: count },
|
|
269
445
|
};
|
|
270
446
|
}
|