@xynogen/pix-todo 0.1.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 ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@xynogen/pix-todo",
3
+ "version": "0.1.0",
4
+ "description": "Pi tool — durable execution checklist (todo)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "src/index.ts"
18
+ ]
19
+ },
20
+ "keywords": [
21
+ "pi",
22
+ "pi-package",
23
+ "pi-extension",
24
+ "todo"
25
+ ],
26
+ "author": "xynogen",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/xynogen/pix-mono.git",
31
+ "directory": "packages/pix-todo"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "typebox": "^1.1.38"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { TodoItem, TodoStatus, TodoTheme } from "./todo.js";
2
+ export { default, renderTodoLines } from "./todo.js";
@@ -0,0 +1,646 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import registerTodo, { renderTodoLines, type TodoItem } from "./todo.ts";
3
+
4
+ // Stub theme tags each fragment with its color/bold so assertions can verify
5
+ // which status got which tint, without depending on real ANSI codes.
6
+ const tagTheme = {
7
+ fg: (color: string, text: string) => `[${color}]${text}[/]`,
8
+ bold: (text: string) => `<b>${text}</b>`,
9
+ };
10
+
11
+ // ─── Helpers ────────────────────────────────────────────────────────────────
12
+
13
+ /** Create a mock ExtensionAPI that captures the registered tool's execute fn. */
14
+ function makeHost(
15
+ initialEntries: Array<{
16
+ type: string;
17
+ customType?: string;
18
+ data?: unknown;
19
+ }> = [],
20
+ ) {
21
+ let capturedExecute:
22
+ | ((
23
+ id: string,
24
+ params: Record<string, unknown>,
25
+ ) => Promise<{
26
+ content: Array<{ type: string; text: string }>;
27
+ isError?: boolean;
28
+ }>)
29
+ | null = null;
30
+ const appendCalls: Array<{ type: string; data: unknown }> = [];
31
+ const handlers: Record<
32
+ string,
33
+ Array<(event: unknown, ctx?: unknown) => unknown>
34
+ > = {};
35
+
36
+ const pi = {
37
+ registerTool(def: { name: string; execute: typeof capturedExecute }) {
38
+ capturedExecute = def.execute;
39
+ },
40
+ appendEntry(type: string, data: unknown) {
41
+ appendCalls.push({ type, data });
42
+ },
43
+ on(ev: string, fn: (event: unknown, ctx?: unknown) => unknown) {
44
+ (handlers[ev] ??= []).push(fn);
45
+ },
46
+ async emit(ev: string, event?: unknown, ctx?: unknown) {
47
+ for (const fn of handlers[ev] ?? []) await fn(event, ctx);
48
+ },
49
+ } as never;
50
+
51
+ const sessionManager = {
52
+ getEntries() {
53
+ return initialEntries;
54
+ },
55
+ };
56
+
57
+ return {
58
+ pi,
59
+ sessionManager,
60
+ get execute() {
61
+ return capturedExecute!;
62
+ },
63
+ appendCalls,
64
+ async emit(ev: string, event?: unknown, ctx?: unknown) {
65
+ for (const fn of handlers[ev] ?? []) await fn(event, ctx);
66
+ },
67
+ };
68
+ }
69
+
70
+ async function run(
71
+ execute: (
72
+ id: string,
73
+ params: Record<string, unknown>,
74
+ ) => Promise<{
75
+ content: Array<{ type: string; text: string }>;
76
+ isError?: boolean;
77
+ }>,
78
+ params: Record<string, unknown>,
79
+ ) {
80
+ return execute("call-1", params);
81
+ }
82
+
83
+ function text(result: { content: Array<{ type: string; text: string }> }) {
84
+ return result.content.map((c) => c.text).join("\n");
85
+ }
86
+
87
+ // ─── parseItems (via set/add) ───────────────────────────────────────────────
88
+
89
+ describe("todo actions", () => {
90
+ test("list on empty returns (no todos)", async () => {
91
+ const host = makeHost();
92
+ registerTodo(host.pi);
93
+ await host.emit(
94
+ "session_start",
95
+ {},
96
+ { sessionManager: host.sessionManager },
97
+ );
98
+ const result = await run(host.execute, { action: "list" });
99
+ expect(text(result)).toBe("(no todos)");
100
+ });
101
+
102
+ test("set creates items from newline text", async () => {
103
+ const host = makeHost();
104
+ registerTodo(host.pi);
105
+ await host.emit(
106
+ "session_start",
107
+ {},
108
+ { sessionManager: host.sessionManager },
109
+ );
110
+ const result = await run(host.execute, {
111
+ action: "set",
112
+ items: "alpha\nbravo\ncharlie",
113
+ });
114
+ const out = text(result);
115
+ expect(out).toContain("Todos 0/3 done");
116
+ expect(out).toContain("○ 1. alpha");
117
+ expect(out).toContain("○ 2. bravo");
118
+ expect(out).toContain("○ 3. charlie");
119
+ });
120
+
121
+ test("set creates items from numbered list", async () => {
122
+ const host = makeHost();
123
+ registerTodo(host.pi);
124
+ await host.emit(
125
+ "session_start",
126
+ {},
127
+ { sessionManager: host.sessionManager },
128
+ );
129
+ const result = await run(host.execute, {
130
+ action: "set",
131
+ items: "1. alpha\n2. bravo",
132
+ });
133
+ expect(text(result)).toContain("○ 1. alpha");
134
+ });
135
+
136
+ test("set creates items from bullet list", async () => {
137
+ const host = makeHost();
138
+ registerTodo(host.pi);
139
+ await host.emit(
140
+ "session_start",
141
+ {},
142
+ { sessionManager: host.sessionManager },
143
+ );
144
+ const result = await run(host.execute, {
145
+ action: "set",
146
+ items: "- alpha\n* bravo",
147
+ });
148
+ expect(text(result)).toContain("○ 1. alpha");
149
+ expect(text(result)).toContain("○ 2. bravo");
150
+ });
151
+
152
+ test("set ignores empty lines", async () => {
153
+ const host = makeHost();
154
+ registerTodo(host.pi);
155
+ await host.emit(
156
+ "session_start",
157
+ {},
158
+ { sessionManager: host.sessionManager },
159
+ );
160
+ const result = await run(host.execute, {
161
+ action: "set",
162
+ items: "alpha\n\nbravo\n \ncharlie",
163
+ });
164
+ expect(text(result)).toContain("Todos 0/3 done");
165
+ });
166
+
167
+ test("set with empty items returns error", async () => {
168
+ const host = makeHost();
169
+ registerTodo(host.pi);
170
+ await host.emit(
171
+ "session_start",
172
+ {},
173
+ { sessionManager: host.sessionManager },
174
+ );
175
+ const result = await run(host.execute, { action: "set", items: "" });
176
+ expect(result.isError).toBe(true);
177
+ expect(text(result)).toContain("non-empty");
178
+ });
179
+
180
+ test("set with only whitespace returns error", async () => {
181
+ const host = makeHost();
182
+ registerTodo(host.pi);
183
+ await host.emit(
184
+ "session_start",
185
+ {},
186
+ { sessionManager: host.sessionManager },
187
+ );
188
+ const result = await run(host.execute, { action: "set", items: " \n " });
189
+ expect(result.isError).toBe(true);
190
+ });
191
+
192
+ test("set resets ids on re-set", async () => {
193
+ const host = makeHost();
194
+ registerTodo(host.pi);
195
+ await host.emit(
196
+ "session_start",
197
+ {},
198
+ { sessionManager: host.sessionManager },
199
+ );
200
+ await run(host.execute, { action: "set", items: "first\nsecond" });
201
+ const result = await run(host.execute, { action: "set", items: "new" });
202
+ expect(text(result)).toContain("○ 1. new");
203
+ expect(text(result)).toContain("Todos 0/1 done");
204
+ });
205
+
206
+ test("add appends items", async () => {
207
+ const host = makeHost();
208
+ registerTodo(host.pi);
209
+ await host.emit(
210
+ "session_start",
211
+ {},
212
+ { sessionManager: host.sessionManager },
213
+ );
214
+ await run(host.execute, { action: "set", items: "alpha" });
215
+ const result = await run(host.execute, {
216
+ action: "add",
217
+ items: "bravo\ncharlie",
218
+ });
219
+ const out = text(result);
220
+ expect(out).toContain("○ 1. alpha");
221
+ expect(out).toContain("○ 2. bravo");
222
+ expect(out).toContain("○ 3. charlie");
223
+ expect(out).toContain("Todos 0/3 done");
224
+ });
225
+
226
+ test("add with ids continuing sequence", async () => {
227
+ const host = makeHost();
228
+ registerTodo(host.pi);
229
+ await host.emit(
230
+ "session_start",
231
+ {},
232
+ { sessionManager: host.sessionManager },
233
+ );
234
+ await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
235
+ const result = await run(host.execute, { action: "add", items: "fourth" });
236
+ expect(text(result)).toContain("○ 4. fourth");
237
+ });
238
+
239
+ test("add with empty items returns error", async () => {
240
+ const host = makeHost();
241
+ registerTodo(host.pi);
242
+ await host.emit(
243
+ "session_start",
244
+ {},
245
+ { sessionManager: host.sessionManager },
246
+ );
247
+ const result = await run(host.execute, { action: "add", items: "" });
248
+ expect(result.isError).toBe(true);
249
+ expect(text(result)).toContain("non-empty");
250
+ });
251
+
252
+ test("update changes status", async () => {
253
+ const host = makeHost();
254
+ registerTodo(host.pi);
255
+ await host.emit(
256
+ "session_start",
257
+ {},
258
+ { sessionManager: host.sessionManager },
259
+ );
260
+ await run(host.execute, { action: "set", items: "alpha\nbravo" });
261
+ const result = await run(host.execute, {
262
+ action: "update",
263
+ id: 1,
264
+ status: "done",
265
+ });
266
+ const out = text(result);
267
+ expect(out).toContain("● 1. alpha");
268
+ expect(out).toContain("○ 2. bravo");
269
+ expect(out).toContain("Todos 1/2 done");
270
+ });
271
+
272
+ test("update changes text", async () => {
273
+ const host = makeHost();
274
+ registerTodo(host.pi);
275
+ await host.emit(
276
+ "session_start",
277
+ {},
278
+ { sessionManager: host.sessionManager },
279
+ );
280
+ await run(host.execute, { action: "set", items: "old name" });
281
+ const result = await run(host.execute, {
282
+ action: "update",
283
+ id: 1,
284
+ text: "new name",
285
+ });
286
+ expect(text(result)).toContain("○ 1. new name");
287
+ });
288
+
289
+ test("update changes status and text together", async () => {
290
+ const host = makeHost();
291
+ registerTodo(host.pi);
292
+ await host.emit(
293
+ "session_start",
294
+ {},
295
+ { sessionManager: host.sessionManager },
296
+ );
297
+ await run(host.execute, { action: "set", items: "alpha" });
298
+ const result = await run(host.execute, {
299
+ action: "update",
300
+ id: 1,
301
+ status: "blocked",
302
+ text: "alpha (waiting)",
303
+ });
304
+ const out = text(result);
305
+ expect(out).toContain("⊘ 1. alpha (waiting)");
306
+ expect(out).toContain("Todos 0/1 done");
307
+ });
308
+
309
+ test("update unknown id returns error", async () => {
310
+ const host = makeHost();
311
+ registerTodo(host.pi);
312
+ await host.emit(
313
+ "session_start",
314
+ {},
315
+ { sessionManager: host.sessionManager },
316
+ );
317
+ const result = await run(host.execute, {
318
+ action: "update",
319
+ id: 999,
320
+ status: "done",
321
+ });
322
+ expect(result.isError).toBe(true);
323
+ expect(text(result)).toContain("999");
324
+ });
325
+
326
+ test("update without status or text does nothing", async () => {
327
+ const host = makeHost();
328
+ registerTodo(host.pi);
329
+ await host.emit(
330
+ "session_start",
331
+ {},
332
+ { sessionManager: host.sessionManager },
333
+ );
334
+ await run(host.execute, { action: "set", items: "unchanged" });
335
+ const result = await run(host.execute, { action: "update", id: 1 });
336
+ expect(text(result)).toContain("○ 1. unchanged");
337
+ });
338
+
339
+ test("clear empties list", async () => {
340
+ const host = makeHost();
341
+ registerTodo(host.pi);
342
+ await host.emit(
343
+ "session_start",
344
+ {},
345
+ { sessionManager: host.sessionManager },
346
+ );
347
+ await run(host.execute, { action: "set", items: "alpha\nbravo" });
348
+ const result = await run(host.execute, { action: "clear" });
349
+ expect(text(result)).toContain("Todos cleared");
350
+ // next list should show empty
351
+ const list = await run(host.execute, { action: "list" });
352
+ expect(text(list)).toBe("(no todos)");
353
+ });
354
+
355
+ test("clear resets id counter", async () => {
356
+ const host = makeHost();
357
+ registerTodo(host.pi);
358
+ await host.emit(
359
+ "session_start",
360
+ {},
361
+ { sessionManager: host.sessionManager },
362
+ );
363
+ await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
364
+ await run(host.execute, { action: "clear" });
365
+ const result = await run(host.execute, { action: "set", items: "new" });
366
+ expect(text(result)).toContain("○ 1. new");
367
+ });
368
+
369
+ test("unknown action returns error", async () => {
370
+ const host = makeHost();
371
+ registerTodo(host.pi);
372
+ await host.emit(
373
+ "session_start",
374
+ {},
375
+ { sessionManager: host.sessionManager },
376
+ );
377
+ const result = await run(host.execute, { action: "bogus" });
378
+ expect(result.isError).toBe(true);
379
+ expect(text(result)).toContain("Unknown action");
380
+ });
381
+
382
+ test("all status glyphs render correctly", async () => {
383
+ const host = makeHost();
384
+ registerTodo(host.pi);
385
+ await host.emit(
386
+ "session_start",
387
+ {},
388
+ { sessionManager: host.sessionManager },
389
+ );
390
+ await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
391
+ await run(host.execute, { action: "update", id: 1, status: "pending" });
392
+ await run(host.execute, { action: "update", id: 2, status: "in_progress" });
393
+ await run(host.execute, { action: "update", id: 3, status: "done" });
394
+ await run(host.execute, { action: "update", id: 4, status: "blocked" });
395
+ const out = text(await run(host.execute, { action: "list" }));
396
+ expect(out).toContain("○ 1. a");
397
+ expect(out).toContain("◐ 2. b");
398
+ expect(out).toContain("● 3. c");
399
+ expect(out).toContain("⊘ 4. d");
400
+ expect(out).toContain("Todos 1/4 done");
401
+ });
402
+ });
403
+
404
+ // ─── Persistence ────────────────────────────────────────────────────────────
405
+
406
+ describe("persistence", () => {
407
+ test("set persists todos", async () => {
408
+ const host = makeHost();
409
+ registerTodo(host.pi);
410
+ await host.emit(
411
+ "session_start",
412
+ {},
413
+ { sessionManager: host.sessionManager },
414
+ );
415
+ host.appendCalls.length = 0;
416
+ await run(host.execute, { action: "set", items: "alpha\nbravo" });
417
+ expect(host.appendCalls.length).toBe(1);
418
+ expect(host.appendCalls[0].type).toBe("todo-state");
419
+ const data = host.appendCalls[0].data as {
420
+ todos: Array<{ id: number; text: string; status: string }>;
421
+ nextTodoId: number;
422
+ };
423
+ expect(data.todos).toHaveLength(2);
424
+ expect(data.nextTodoId).toBe(3);
425
+ });
426
+
427
+ test("add persists todos", async () => {
428
+ const host = makeHost();
429
+ registerTodo(host.pi);
430
+ await host.emit(
431
+ "session_start",
432
+ {},
433
+ { sessionManager: host.sessionManager },
434
+ );
435
+ await run(host.execute, { action: "set", items: "alpha" });
436
+ host.appendCalls.length = 0;
437
+ await run(host.execute, { action: "add", items: "bravo" });
438
+ expect(host.appendCalls.length).toBe(1);
439
+ });
440
+
441
+ test("update persists todos", async () => {
442
+ const host = makeHost();
443
+ registerTodo(host.pi);
444
+ await host.emit(
445
+ "session_start",
446
+ {},
447
+ { sessionManager: host.sessionManager },
448
+ );
449
+ await run(host.execute, { action: "set", items: "alpha" });
450
+ host.appendCalls.length = 0;
451
+ await run(host.execute, { action: "update", id: 1, status: "done" });
452
+ expect(host.appendCalls.length).toBe(1);
453
+ });
454
+
455
+ test("clear persists", async () => {
456
+ const host = makeHost();
457
+ registerTodo(host.pi);
458
+ await host.emit(
459
+ "session_start",
460
+ {},
461
+ { sessionManager: host.sessionManager },
462
+ );
463
+ await run(host.execute, { action: "set", items: "alpha" });
464
+ host.appendCalls.length = 0;
465
+ await run(host.execute, { action: "clear" });
466
+ expect(host.appendCalls.length).toBe(1);
467
+ const data = host.appendCalls[0].data as {
468
+ todos: Array<unknown>;
469
+ nextTodoId: number;
470
+ };
471
+ expect(data.todos).toEqual([]);
472
+ expect(data.nextTodoId).toBe(1);
473
+ });
474
+ });
475
+
476
+ // ─── Session restore ────────────────────────────────────────────────────────
477
+
478
+ describe("restore", () => {
479
+ test("restores todos from last todo-state entry", async () => {
480
+ const host = makeHost([
481
+ {
482
+ type: "custom",
483
+ customType: "todo-state",
484
+ data: {
485
+ todos: [{ id: 1, text: "restored", status: "done" }],
486
+ nextTodoId: 2,
487
+ },
488
+ },
489
+ ]);
490
+ registerTodo(host.pi);
491
+ await host.emit(
492
+ "session_start",
493
+ {},
494
+ { sessionManager: host.sessionManager },
495
+ );
496
+ const result = await run(host.execute, { action: "list" });
497
+ expect(text(result)).toContain("● 1. restored");
498
+ expect(text(result)).toContain("Todos 1/1 done");
499
+ });
500
+
501
+ test("restores nextTodoId so new items continue sequence", async () => {
502
+ const host = makeHost([
503
+ {
504
+ type: "custom",
505
+ customType: "todo-state",
506
+ data: {
507
+ todos: [{ id: 5, text: "existing", status: "pending" }],
508
+ nextTodoId: 6,
509
+ },
510
+ },
511
+ ]);
512
+ registerTodo(host.pi);
513
+ await host.emit(
514
+ "session_start",
515
+ {},
516
+ { sessionManager: host.sessionManager },
517
+ );
518
+ const result = await run(host.execute, { action: "add", items: "new" });
519
+ expect(text(result)).toContain("○ 6. new");
520
+ });
521
+
522
+ test("restores nextTodoId from max id when nextTodoId missing", async () => {
523
+ const host = makeHost([
524
+ {
525
+ type: "custom",
526
+ customType: "todo-state",
527
+ data: {
528
+ todos: [
529
+ { id: 3, text: "old", status: "done" },
530
+ { id: 7, text: "newer", status: "pending" },
531
+ ],
532
+ },
533
+ },
534
+ ]);
535
+ registerTodo(host.pi);
536
+ await host.emit(
537
+ "session_start",
538
+ {},
539
+ { sessionManager: host.sessionManager },
540
+ );
541
+ const result = await run(host.execute, { action: "add", items: "next" });
542
+ expect(text(result)).toContain("○ 8. next");
543
+ });
544
+
545
+ test("ignores non-todo-state entries", async () => {
546
+ const host = makeHost([
547
+ { type: "message", data: "hello" },
548
+ { type: "custom", customType: "other-thing", data: {} },
549
+ {
550
+ type: "custom",
551
+ customType: "todo-state",
552
+ data: {
553
+ todos: [{ id: 1, text: "real", status: "pending" }],
554
+ nextTodoId: 2,
555
+ },
556
+ },
557
+ ]);
558
+ registerTodo(host.pi);
559
+ await host.emit(
560
+ "session_start",
561
+ {},
562
+ { sessionManager: host.sessionManager },
563
+ );
564
+ const result = await run(host.execute, { action: "list" });
565
+ expect(text(result)).toContain("○ 1. real");
566
+ });
567
+
568
+ test("no todo-state entries starts empty", async () => {
569
+ const host = makeHost([{ type: "message", data: "hello" }]);
570
+ registerTodo(host.pi);
571
+ await host.emit(
572
+ "session_start",
573
+ {},
574
+ { sessionManager: host.sessionManager },
575
+ );
576
+ const result = await run(host.execute, { action: "list" });
577
+ expect(text(result)).toBe("(no todos)");
578
+ });
579
+
580
+ test("empty entries list starts empty", async () => {
581
+ const host = makeHost([]);
582
+ registerTodo(host.pi);
583
+ await host.emit(
584
+ "session_start",
585
+ {},
586
+ { sessionManager: host.sessionManager },
587
+ );
588
+ const result = await run(host.execute, { action: "list" });
589
+ expect(text(result)).toBe("(no todos)");
590
+ });
591
+
592
+ test("restore with empty todos array works", async () => {
593
+ const host = makeHost([
594
+ {
595
+ type: "custom",
596
+ customType: "todo-state",
597
+ data: { todos: [], nextTodoId: 1 },
598
+ },
599
+ ]);
600
+ registerTodo(host.pi);
601
+ await host.emit(
602
+ "session_start",
603
+ {},
604
+ { sessionManager: host.sessionManager },
605
+ );
606
+ const result = await run(host.execute, { action: "list" });
607
+ expect(text(result)).toBe("(no todos)");
608
+ });
609
+ });
610
+
611
+ describe("renderTodoLines (colored TUI render)", () => {
612
+ const items: TodoItem[] = [
613
+ { id: 1, text: "alpha", status: "done" },
614
+ { id: 2, text: "bravo", status: "in_progress" },
615
+ { id: 3, text: "charlie", status: "pending" },
616
+ { id: 4, text: "delta", status: "blocked" },
617
+ ];
618
+
619
+ test("empty list renders muted placeholder", () => {
620
+ expect(renderTodoLines([], tagTheme)).toBe("[muted](no todos)[/]");
621
+ });
622
+
623
+ test("tints each glyph by status", () => {
624
+ const out = renderTodoLines(items, tagTheme);
625
+ expect(out).toContain("[success]●[/]"); // done
626
+ expect(out).toContain("[accent]◐[/]"); // in_progress
627
+ expect(out).toContain("[muted]○[/]"); // pending
628
+ expect(out).toContain("[error]⊘[/]"); // blocked
629
+ });
630
+
631
+ test("highlights the in-progress row bold + accent", () => {
632
+ const out = renderTodoLines(items, tagTheme);
633
+ expect(out).toContain("<b>[accent]2. bravo[/]</b>");
634
+ });
635
+
636
+ test("dims completed rows and uses text color for active-but-not-running", () => {
637
+ const out = renderTodoLines(items, tagTheme);
638
+ expect(out).toContain("[muted]1. alpha[/]"); // done body muted
639
+ expect(out).toContain("[text]3. charlie[/]"); // pending body text
640
+ });
641
+
642
+ test("shows the done/total count header", () => {
643
+ const out = renderTodoLines(items, tagTheme);
644
+ expect(out).toContain("[muted]Todos 1/4 done:[/]");
645
+ });
646
+ });
package/src/todo.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * todo.ts — durable execution checklist tool
3
+ *
4
+ * Extracted from the plan extension: the checklist is BUILD-phase execution
5
+ * state that survives context compaction and session restore (persisted via
6
+ * appendEntry("todo-state")). It is universal — other tools and workflow
7
+ * extensions (like plan) drive it — so it lives in pix-core and registers the
8
+ * `todo` tool. State, persistence, and restore are owned end to end here; the
9
+ * checklist is seeded by the model via the tool's `set` action.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+
16
+ export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
17
+
18
+ export interface TodoItem {
19
+ id: number;
20
+ text: string;
21
+ status: TodoStatus;
22
+ }
23
+
24
+ const TODO_GLYPH: Record<TodoStatus, string> = {
25
+ pending: "○",
26
+ in_progress: "◐",
27
+ done: "●",
28
+ blocked: "⊘",
29
+ };
30
+
31
+ /** Theme color key per status — drives both glyph and (for active) row tint. */
32
+ const TODO_COLOR: Record<TodoStatus, string> = {
33
+ pending: "muted",
34
+ in_progress: "accent",
35
+ done: "success",
36
+ blocked: "error",
37
+ };
38
+
39
+ export type TodoTheme = {
40
+ fg: (color: string, text: string) => string;
41
+ bold: (text: string) => string;
42
+ };
43
+
44
+ /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
45
+ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
46
+ if (!items.length) return theme.fg("muted", "(no todos)");
47
+ const done = items.filter((t) => t.status === "done").length;
48
+ const head = theme.fg("muted", `Todos ${done}/${items.length} done:`);
49
+ const lines = items.map((t) => {
50
+ const color = TODO_COLOR[t.status];
51
+ const glyph = theme.fg(color, TODO_GLYPH[t.status]);
52
+ const body = `${t.id}. ${t.text}`;
53
+ // Highlight the in-flight task so the eye lands on it first.
54
+ const label =
55
+ t.status === "in_progress"
56
+ ? theme.bold(theme.fg("accent", body))
57
+ : theme.fg(t.status === "done" ? "muted" : "text", body);
58
+ return `${glyph} ${label}`;
59
+ });
60
+ return `${head}\n${lines.join("\n")}`;
61
+ }
62
+
63
+ const parseItems = (raw: string): string[] =>
64
+ raw
65
+ .split("\n")
66
+ .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim())
67
+ .filter(Boolean);
68
+
69
+ export default function registerTodo(pi: ExtensionAPI): void {
70
+ let todos: TodoItem[] = [];
71
+ let nextTodoId = 1;
72
+
73
+ function persistTodos() {
74
+ pi.appendEntry("todo-state", { todos, nextTodoId });
75
+ }
76
+
77
+ function todoSummary(): string {
78
+ if (!todos.length) return "(no todos)";
79
+ const done = todos.filter((t) => t.status === "done").length;
80
+ const lines = todos.map(
81
+ (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
82
+ );
83
+ return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
84
+ }
85
+
86
+ // Durable execution checklist for BUILD mode. Survives context compaction
87
+ // and session restore. Workflows like plan instruct the model to seed it
88
+ // from a plan's "Implementation Phases" so it stays anchored to plan.md.
89
+ pi.registerTool({
90
+ name: "todo",
91
+ label: "Todo",
92
+ description:
93
+ "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
94
+ promptSnippet:
95
+ "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
96
+ promptGuidelines: [
97
+ "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
98
+ "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
99
+ "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
100
+ ],
101
+ parameters: Type.Object({
102
+ action: Type.Union(
103
+ [
104
+ Type.Literal("list"),
105
+ Type.Literal("set"),
106
+ Type.Literal("add"),
107
+ Type.Literal("update"),
108
+ Type.Literal("clear"),
109
+ ],
110
+ { description: "Operation to perform" },
111
+ ),
112
+ items: Type.Optional(
113
+ Type.String({
114
+ description:
115
+ "For set/add: newline-separated or numbered list of todo texts.",
116
+ }),
117
+ ),
118
+ id: Type.Optional(
119
+ Type.Number({ description: "For update: target todo id." }),
120
+ ),
121
+ status: Type.Optional(
122
+ Type.Union(
123
+ [
124
+ Type.Literal("pending"),
125
+ Type.Literal("in_progress"),
126
+ Type.Literal("done"),
127
+ Type.Literal("blocked"),
128
+ ],
129
+ { description: "For update: new status." },
130
+ ),
131
+ ),
132
+ text: Type.Optional(
133
+ Type.String({
134
+ description: "For update: replacement text (optional).",
135
+ }),
136
+ ),
137
+ }),
138
+ renderResult(_result, _options, theme) {
139
+ return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
140
+ },
141
+
142
+ async execute(_id, params) {
143
+ // AgentToolResult now requires a `details` field. These todo results have
144
+ // no structured details, so emit `undefined` via small local helpers.
145
+ const ok = (text: string) => ({
146
+ content: [{ type: "text" as const, text }],
147
+ details: undefined,
148
+ });
149
+ const fail = (text: string) => ({
150
+ content: [{ type: "text" as const, text }],
151
+ details: undefined,
152
+ isError: true,
153
+ });
154
+ switch (params.action) {
155
+ case "list":
156
+ return ok(todoSummary());
157
+
158
+ case "set": {
159
+ const texts = parseItems(params.items ?? "");
160
+ if (!texts.length) return fail("set requires non-empty `items`.");
161
+ nextTodoId = 1;
162
+ todos = texts.map((text) => ({
163
+ id: nextTodoId++,
164
+ text,
165
+ status: "pending" as TodoStatus,
166
+ }));
167
+ persistTodos();
168
+ return ok(todoSummary());
169
+ }
170
+
171
+ case "add": {
172
+ const texts = parseItems(params.items ?? "");
173
+ if (!texts.length) return fail("add requires non-empty `items`.");
174
+ for (const text of texts)
175
+ todos.push({ id: nextTodoId++, text, status: "pending" });
176
+ persistTodos();
177
+ return ok(todoSummary());
178
+ }
179
+
180
+ case "update": {
181
+ const t = todos.find((x) => x.id === params.id);
182
+ if (!t) return fail(`No todo with id ${params.id}.`);
183
+ if (params.status) t.status = params.status;
184
+ if (params.text) t.text = params.text;
185
+ persistTodos();
186
+ return ok(todoSummary());
187
+ }
188
+
189
+ case "clear":
190
+ todos = [];
191
+ nextTodoId = 1;
192
+ persistTodos();
193
+ return ok("Todos cleared.");
194
+
195
+ default:
196
+ return fail(`Unknown action: ${String(params.action)}`);
197
+ }
198
+ },
199
+ });
200
+
201
+ // Restore the checklist from session entries so it survives restart.
202
+ pi.on("session_start", async (_event, ctx) => {
203
+ const entries = ctx.sessionManager.getEntries() as Array<{
204
+ type: string;
205
+ customType?: string;
206
+ data?: { todos?: TodoItem[]; nextTodoId?: number };
207
+ }>;
208
+ const lastTodo = entries
209
+ .filter((e) => e.type === "custom" && e.customType === "todo-state")
210
+ .pop();
211
+ if (Array.isArray(lastTodo?.data?.todos)) {
212
+ todos = lastTodo.data.todos;
213
+ nextTodoId =
214
+ lastTodo.data.nextTodoId ??
215
+ todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
216
+ }
217
+ });
218
+ }