@xynogen/pix-todo 0.3.3 → 0.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/package.json +1 -1
- package/src/todo.ts +103 -15
package/package.json
CHANGED
package/src/todo.ts
CHANGED
|
@@ -172,6 +172,35 @@ const parseItems = (raw: string): string[] =>
|
|
|
172
172
|
.map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim())
|
|
173
173
|
.filter(Boolean);
|
|
174
174
|
|
|
175
|
+
const STATUSES: readonly TodoStatus[] = ["pending", "in_progress", "done", "blocked"];
|
|
176
|
+
|
|
177
|
+
export interface TodoUpdateOp {
|
|
178
|
+
id: number;
|
|
179
|
+
status: TodoStatus;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse the batch `updates` string: comma/newline separated `id:status` pairs
|
|
184
|
+
* (e.g. "3:done, 4:blocked"). Returns an error string on the first bad token so
|
|
185
|
+
* the model gets one precise correction instead of a silent partial apply.
|
|
186
|
+
*/
|
|
187
|
+
export function parseUpdates(raw: string): { ops: TodoUpdateOp[] } | { error: string } {
|
|
188
|
+
const ops: TodoUpdateOp[] = [];
|
|
189
|
+
for (const token of raw.split(/[,\n]/)) {
|
|
190
|
+
const tok = token.trim();
|
|
191
|
+
if (!tok) continue;
|
|
192
|
+
const m = /^#?(\d+)\s*[:=]\s*(\w+)$/.exec(tok);
|
|
193
|
+
if (!m) return { error: `Bad update token "${tok}" — expected "id:status".` };
|
|
194
|
+
const status = m[2] as TodoStatus;
|
|
195
|
+
if (!STATUSES.includes(status))
|
|
196
|
+
return { error: `Bad status "${m[2]}" — expected one of ${STATUSES.join(", ")}.` };
|
|
197
|
+
ops.push({ id: Number(m[1]), status });
|
|
198
|
+
}
|
|
199
|
+
return ops.length
|
|
200
|
+
? { ops }
|
|
201
|
+
: { error: 'update requires `updates` ("id:status") or `id`+`status`.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
175
204
|
export default function registerTodo(pi: ExtensionAPI): void {
|
|
176
205
|
once(pi, "pix-todo", () => {
|
|
177
206
|
let todos: TodoItem[] = [];
|
|
@@ -212,6 +241,16 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
212
241
|
return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
|
|
213
242
|
}
|
|
214
243
|
|
|
244
|
+
/** Compact next-step hint for the delta echo — the full list is already in
|
|
245
|
+
* the transcript and the tool card, so re-sending it is token waste. */
|
|
246
|
+
function todoHint(): string {
|
|
247
|
+
const done = todos.filter((t) => t.status === "done").length;
|
|
248
|
+
const next =
|
|
249
|
+
todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
|
|
250
|
+
if (!next) return `${done}/${todos.length} done — all items closed.`;
|
|
251
|
+
return `${done}/${todos.length} done — next #${next.id} ${next.text}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
215
254
|
// Durable execution checklist for BUILD mode. Survives context compaction
|
|
216
255
|
// and session restore. Workflows like plan instruct the model to seed it
|
|
217
256
|
// from a plan's "Implementation Phases" so it stays anchored to plan.md.
|
|
@@ -222,12 +261,12 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
222
261
|
// result row and should align its status glyph with other compact tools.
|
|
223
262
|
renderShell: "self",
|
|
224
263
|
description:
|
|
225
|
-
"Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one
|
|
264
|
+
"Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one or more items' status — batch via `updates`), clear.",
|
|
226
265
|
promptSnippet:
|
|
227
|
-
"todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
|
|
266
|
+
"todo(action, items?, updates?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
|
|
228
267
|
promptGuidelines: [
|
|
229
268
|
"When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
|
|
230
|
-
"
|
|
269
|
+
"Batch status changes in ONE call: `todo(action:'update', updates:'3:done,4:in_progress')`. Opening an item auto-closes earlier ones (ordered lists), so do not send per-item update calls.",
|
|
231
270
|
"When marking an item done, the tool checks for earlier incomplete items and warns you — resolve each skipped item (mark done or blocked) before moving on.",
|
|
232
271
|
"If the list is NOT a sequential run (items independent, done in any order), pass `ordered:false` on `set` — that disables the cascade-close and skip warning.",
|
|
233
272
|
"Call `todo(action:'list')` to recover your place after long runs or context compaction.",
|
|
@@ -236,14 +275,20 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
236
275
|
action: Type.Enum(["list", "set", "add", "update", "clear"] as const, {
|
|
237
276
|
type: "string",
|
|
238
277
|
description:
|
|
239
|
-
'Required operation: "list" shows items; "set" replaces all from items; "add" appends items; "update" changes one
|
|
278
|
+
'Required operation: "list" shows items; "set" replaces all from items; "add" appends items; "update" changes one or more items by id; "clear" removes all.',
|
|
240
279
|
}),
|
|
241
280
|
items: Type.Optional(
|
|
242
281
|
Type.String({
|
|
243
282
|
description: "For set/add: newline-separated or numbered list of todo texts.",
|
|
244
283
|
}),
|
|
245
284
|
),
|
|
246
|
-
|
|
285
|
+
updates: Type.Optional(
|
|
286
|
+
Type.String({
|
|
287
|
+
description:
|
|
288
|
+
'For update (preferred for status changes): comma-separated "id:status" pairs, e.g. "3:done,4:in_progress". Applied in order.',
|
|
289
|
+
}),
|
|
290
|
+
),
|
|
291
|
+
id: Type.Optional(Type.Number({ description: "For update: single target todo id." })),
|
|
247
292
|
status: Type.Optional(
|
|
248
293
|
Type.Enum(["pending", "in_progress", "done", "blocked"] as const, {
|
|
249
294
|
type: "string",
|
|
@@ -342,30 +387,73 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
342
387
|
}
|
|
343
388
|
|
|
344
389
|
case "update": {
|
|
345
|
-
|
|
346
|
-
|
|
390
|
+
// Batch form (`updates`) is preferred — one call closes many items.
|
|
391
|
+
// Single form (`id`+`status`/`text`) stays supported for renames.
|
|
392
|
+
let ops: TodoUpdateOp[];
|
|
393
|
+
if (params.updates) {
|
|
394
|
+
const parsed = parseUpdates(params.updates as string);
|
|
395
|
+
if ("error" in parsed) return fail(parsed.error);
|
|
396
|
+
ops = parsed.ops;
|
|
397
|
+
} else if (params.id !== undefined && params.status) {
|
|
398
|
+
ops = [{ id: params.id as number, status: params.status as TodoStatus }];
|
|
399
|
+
} else {
|
|
400
|
+
ops = [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const missing = ops.filter((o) => !todos.some((t) => t.id === o.id)).map((o) => o.id);
|
|
404
|
+
if (missing.length) return fail(`No todo with id ${missing.join(", ")}.`);
|
|
405
|
+
|
|
406
|
+
// Text-only / no-op single update: keep the legacy tolerant path.
|
|
407
|
+
if (!ops.length) {
|
|
408
|
+
const t = todos.find((x) => x.id === params.id);
|
|
409
|
+
if (!t) return fail(`No todo with id ${params.id}.`);
|
|
410
|
+
if (params.text) {
|
|
411
|
+
t.text = params.text;
|
|
412
|
+
persistTodos();
|
|
413
|
+
}
|
|
414
|
+
return ok(`#${t.id} ${t.status} · ${todoHint()}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const autoClosed = new Set<number>();
|
|
347
418
|
let skipWarning = "";
|
|
348
|
-
|
|
419
|
+
for (const op of ops) {
|
|
420
|
+
const t = todos.find((x) => x.id === op.id) as TodoItem;
|
|
349
421
|
// Sequential-progress invariant (ordered lists only): opening a task
|
|
350
422
|
// means everything before it is finished. Cascade-close every earlier
|
|
351
423
|
// pending or in_progress item so the model never has to mark skipped
|
|
352
424
|
// steps done by hand. `blocked` is left untouched. Unordered lists
|
|
353
425
|
// treat each item independently — no cascade, no skip warning.
|
|
354
|
-
if (ordered &&
|
|
426
|
+
if (ordered && op.status === "in_progress")
|
|
355
427
|
for (const other of todos)
|
|
356
428
|
if (
|
|
357
429
|
other.id < t.id &&
|
|
358
430
|
(other.status === "pending" || other.status === "in_progress")
|
|
359
|
-
)
|
|
431
|
+
) {
|
|
360
432
|
other.status = "done";
|
|
433
|
+
autoClosed.add(other.id);
|
|
434
|
+
}
|
|
361
435
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
436
|
+
// Only the last op's skip state matters — earlier ops in the same
|
|
437
|
+
// batch may legitimately still be settling.
|
|
438
|
+
skipWarning = ordered && op.status === "done" ? buildSkipWarning(todos, t.id) : "";
|
|
439
|
+
t.status = op.status;
|
|
440
|
+
}
|
|
441
|
+
// Text rename only applies to a single-target update.
|
|
442
|
+
const only = ops.length === 1 ? ops[0] : undefined;
|
|
443
|
+
if (params.text && only) {
|
|
444
|
+
const t = todos.find((x) => x.id === only.id) as TodoItem;
|
|
445
|
+
t.text = params.text;
|
|
365
446
|
}
|
|
366
|
-
if (params.text) t.text = params.text;
|
|
367
447
|
persistTodos();
|
|
368
|
-
|
|
448
|
+
|
|
449
|
+
// Delta-only echo: the model already holds the list; re-sending it on
|
|
450
|
+
// every update is pure token waste. The TUI card still renders the
|
|
451
|
+
// full checklist from `details.snapshot`.
|
|
452
|
+
const applied = ops.map((o) => `#${o.id} ${o.status}`).join(", ");
|
|
453
|
+
const auto = autoClosed.size
|
|
454
|
+
? ` (auto-done ${[...autoClosed].map((i) => `#${i}`).join(",")})`
|
|
455
|
+
: "";
|
|
456
|
+
return ok(`${applied}${auto} · ${todoHint()}${skipWarning}`);
|
|
369
457
|
}
|
|
370
458
|
|
|
371
459
|
case "clear":
|