@xynogen/pix-todo 0.1.10 → 0.1.12
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.test.ts +179 -191
- package/src/todo.ts +57 -25
package/package.json
CHANGED
package/src/todo.test.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import registerTodo, {
|
|
3
|
-
renderTodoLines,
|
|
4
|
-
renderTodoSummaryLine,
|
|
5
|
-
type TodoItem,
|
|
6
|
-
} from "./todo.ts";
|
|
2
|
+
import registerTodo, { renderTodoLines, renderTodoSummaryLine, type TodoItem } from "./todo.ts";
|
|
7
3
|
|
|
8
4
|
// registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
|
|
9
5
|
// WeakMap guard that dedupes activation across pix-core + a standalone install.
|
|
@@ -40,10 +36,7 @@ function makeHost(
|
|
|
40
36
|
}>)
|
|
41
37
|
| null = null;
|
|
42
38
|
const appendCalls: Array<{ type: string; data: unknown }> = [];
|
|
43
|
-
const handlers: Record<
|
|
44
|
-
string,
|
|
45
|
-
Array<(event: unknown, ctx?: unknown) => unknown>
|
|
46
|
-
> = {};
|
|
39
|
+
const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
|
|
47
40
|
|
|
48
41
|
let capturedRender:
|
|
49
42
|
| ((
|
|
@@ -70,8 +63,13 @@ function makeHost(
|
|
|
70
63
|
if (!handlers[ev]) handlers[ev] = [];
|
|
71
64
|
handlers[ev].push(fn);
|
|
72
65
|
},
|
|
73
|
-
async emit(ev: string, event?: unknown, ctx?: unknown) {
|
|
74
|
-
|
|
66
|
+
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
67
|
+
let last: unknown;
|
|
68
|
+
for (const fn of handlers[ev] ?? []) {
|
|
69
|
+
const result = await fn(event, ctx);
|
|
70
|
+
if (result !== undefined) last = result;
|
|
71
|
+
}
|
|
72
|
+
return last;
|
|
75
73
|
},
|
|
76
74
|
} as never;
|
|
77
75
|
|
|
@@ -93,8 +91,13 @@ function makeHost(
|
|
|
93
91
|
return capturedRender;
|
|
94
92
|
},
|
|
95
93
|
appendCalls,
|
|
96
|
-
async emit(ev: string, event?: unknown, ctx?: unknown) {
|
|
97
|
-
|
|
94
|
+
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
95
|
+
let last: unknown;
|
|
96
|
+
for (const fn of handlers[ev] ?? []) {
|
|
97
|
+
const result = await fn(event, ctx);
|
|
98
|
+
if (result !== undefined) last = result;
|
|
99
|
+
}
|
|
100
|
+
return last;
|
|
98
101
|
},
|
|
99
102
|
};
|
|
100
103
|
}
|
|
@@ -122,11 +125,7 @@ describe("todo actions", () => {
|
|
|
122
125
|
test("list on empty returns (no todos)", async () => {
|
|
123
126
|
const host = makeHost();
|
|
124
127
|
registerTodo(host.pi);
|
|
125
|
-
await host.emit(
|
|
126
|
-
"session_start",
|
|
127
|
-
{},
|
|
128
|
-
{ sessionManager: host.sessionManager },
|
|
129
|
-
);
|
|
128
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
130
129
|
const result = await run(host.execute, { action: "list" });
|
|
131
130
|
expect(text(result)).toBe("(no todos)");
|
|
132
131
|
});
|
|
@@ -134,11 +133,7 @@ describe("todo actions", () => {
|
|
|
134
133
|
test("set creates items from newline text", async () => {
|
|
135
134
|
const host = makeHost();
|
|
136
135
|
registerTodo(host.pi);
|
|
137
|
-
await host.emit(
|
|
138
|
-
"session_start",
|
|
139
|
-
{},
|
|
140
|
-
{ sessionManager: host.sessionManager },
|
|
141
|
-
);
|
|
136
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
142
137
|
const result = await run(host.execute, {
|
|
143
138
|
action: "set",
|
|
144
139
|
items: "alpha\nbravo\ncharlie",
|
|
@@ -153,11 +148,7 @@ describe("todo actions", () => {
|
|
|
153
148
|
test("set creates items from numbered list", async () => {
|
|
154
149
|
const host = makeHost();
|
|
155
150
|
registerTodo(host.pi);
|
|
156
|
-
await host.emit(
|
|
157
|
-
"session_start",
|
|
158
|
-
{},
|
|
159
|
-
{ sessionManager: host.sessionManager },
|
|
160
|
-
);
|
|
151
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
161
152
|
const result = await run(host.execute, {
|
|
162
153
|
action: "set",
|
|
163
154
|
items: "1. alpha\n2. bravo",
|
|
@@ -168,11 +159,7 @@ describe("todo actions", () => {
|
|
|
168
159
|
test("set creates items from bullet list", async () => {
|
|
169
160
|
const host = makeHost();
|
|
170
161
|
registerTodo(host.pi);
|
|
171
|
-
await host.emit(
|
|
172
|
-
"session_start",
|
|
173
|
-
{},
|
|
174
|
-
{ sessionManager: host.sessionManager },
|
|
175
|
-
);
|
|
162
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
176
163
|
const result = await run(host.execute, {
|
|
177
164
|
action: "set",
|
|
178
165
|
items: "- alpha\n* bravo",
|
|
@@ -184,11 +171,7 @@ describe("todo actions", () => {
|
|
|
184
171
|
test("set ignores empty lines", async () => {
|
|
185
172
|
const host = makeHost();
|
|
186
173
|
registerTodo(host.pi);
|
|
187
|
-
await host.emit(
|
|
188
|
-
"session_start",
|
|
189
|
-
{},
|
|
190
|
-
{ sessionManager: host.sessionManager },
|
|
191
|
-
);
|
|
174
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
192
175
|
const result = await run(host.execute, {
|
|
193
176
|
action: "set",
|
|
194
177
|
items: "alpha\n\nbravo\n \ncharlie",
|
|
@@ -199,11 +182,7 @@ describe("todo actions", () => {
|
|
|
199
182
|
test("set with empty items returns error", async () => {
|
|
200
183
|
const host = makeHost();
|
|
201
184
|
registerTodo(host.pi);
|
|
202
|
-
await host.emit(
|
|
203
|
-
"session_start",
|
|
204
|
-
{},
|
|
205
|
-
{ sessionManager: host.sessionManager },
|
|
206
|
-
);
|
|
185
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
207
186
|
const result = await run(host.execute, { action: "set", items: "" });
|
|
208
187
|
expect(result.isError).toBe(true);
|
|
209
188
|
expect(text(result)).toContain("non-empty");
|
|
@@ -212,11 +191,7 @@ describe("todo actions", () => {
|
|
|
212
191
|
test("set with only whitespace returns error", async () => {
|
|
213
192
|
const host = makeHost();
|
|
214
193
|
registerTodo(host.pi);
|
|
215
|
-
await host.emit(
|
|
216
|
-
"session_start",
|
|
217
|
-
{},
|
|
218
|
-
{ sessionManager: host.sessionManager },
|
|
219
|
-
);
|
|
194
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
220
195
|
const result = await run(host.execute, { action: "set", items: " \n " });
|
|
221
196
|
expect(result.isError).toBe(true);
|
|
222
197
|
});
|
|
@@ -224,11 +199,7 @@ describe("todo actions", () => {
|
|
|
224
199
|
test("set resets ids on re-set", async () => {
|
|
225
200
|
const host = makeHost();
|
|
226
201
|
registerTodo(host.pi);
|
|
227
|
-
await host.emit(
|
|
228
|
-
"session_start",
|
|
229
|
-
{},
|
|
230
|
-
{ sessionManager: host.sessionManager },
|
|
231
|
-
);
|
|
202
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
232
203
|
await run(host.execute, { action: "set", items: "first\nsecond" });
|
|
233
204
|
const result = await run(host.execute, { action: "set", items: "new" });
|
|
234
205
|
expect(text(result)).toContain("○ 1. new");
|
|
@@ -238,11 +209,7 @@ describe("todo actions", () => {
|
|
|
238
209
|
test("add appends items", async () => {
|
|
239
210
|
const host = makeHost();
|
|
240
211
|
registerTodo(host.pi);
|
|
241
|
-
await host.emit(
|
|
242
|
-
"session_start",
|
|
243
|
-
{},
|
|
244
|
-
{ sessionManager: host.sessionManager },
|
|
245
|
-
);
|
|
212
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
246
213
|
await run(host.execute, { action: "set", items: "alpha" });
|
|
247
214
|
const result = await run(host.execute, {
|
|
248
215
|
action: "add",
|
|
@@ -258,11 +225,7 @@ describe("todo actions", () => {
|
|
|
258
225
|
test("add with ids continuing sequence", async () => {
|
|
259
226
|
const host = makeHost();
|
|
260
227
|
registerTodo(host.pi);
|
|
261
|
-
await host.emit(
|
|
262
|
-
"session_start",
|
|
263
|
-
{},
|
|
264
|
-
{ sessionManager: host.sessionManager },
|
|
265
|
-
);
|
|
228
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
266
229
|
await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
|
|
267
230
|
const result = await run(host.execute, { action: "add", items: "fourth" });
|
|
268
231
|
expect(text(result)).toContain("○ 4. fourth");
|
|
@@ -271,11 +234,7 @@ describe("todo actions", () => {
|
|
|
271
234
|
test("add with empty items returns error", async () => {
|
|
272
235
|
const host = makeHost();
|
|
273
236
|
registerTodo(host.pi);
|
|
274
|
-
await host.emit(
|
|
275
|
-
"session_start",
|
|
276
|
-
{},
|
|
277
|
-
{ sessionManager: host.sessionManager },
|
|
278
|
-
);
|
|
237
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
279
238
|
const result = await run(host.execute, { action: "add", items: "" });
|
|
280
239
|
expect(result.isError).toBe(true);
|
|
281
240
|
expect(text(result)).toContain("non-empty");
|
|
@@ -284,11 +243,7 @@ describe("todo actions", () => {
|
|
|
284
243
|
test("update changes status", async () => {
|
|
285
244
|
const host = makeHost();
|
|
286
245
|
registerTodo(host.pi);
|
|
287
|
-
await host.emit(
|
|
288
|
-
"session_start",
|
|
289
|
-
{},
|
|
290
|
-
{ sessionManager: host.sessionManager },
|
|
291
|
-
);
|
|
246
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
292
247
|
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
293
248
|
const result = await run(host.execute, {
|
|
294
249
|
action: "update",
|
|
@@ -304,11 +259,7 @@ describe("todo actions", () => {
|
|
|
304
259
|
test("update changes text", async () => {
|
|
305
260
|
const host = makeHost();
|
|
306
261
|
registerTodo(host.pi);
|
|
307
|
-
await host.emit(
|
|
308
|
-
"session_start",
|
|
309
|
-
{},
|
|
310
|
-
{ sessionManager: host.sessionManager },
|
|
311
|
-
);
|
|
262
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
312
263
|
await run(host.execute, { action: "set", items: "old name" });
|
|
313
264
|
const result = await run(host.execute, {
|
|
314
265
|
action: "update",
|
|
@@ -321,11 +272,7 @@ describe("todo actions", () => {
|
|
|
321
272
|
test("update changes status and text together", async () => {
|
|
322
273
|
const host = makeHost();
|
|
323
274
|
registerTodo(host.pi);
|
|
324
|
-
await host.emit(
|
|
325
|
-
"session_start",
|
|
326
|
-
{},
|
|
327
|
-
{ sessionManager: host.sessionManager },
|
|
328
|
-
);
|
|
275
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
329
276
|
await run(host.execute, { action: "set", items: "alpha" });
|
|
330
277
|
const result = await run(host.execute, {
|
|
331
278
|
action: "update",
|
|
@@ -341,11 +288,7 @@ describe("todo actions", () => {
|
|
|
341
288
|
test("opening a new in_progress closes the previous one", async () => {
|
|
342
289
|
const host = makeHost();
|
|
343
290
|
registerTodo(host.pi);
|
|
344
|
-
await host.emit(
|
|
345
|
-
"session_start",
|
|
346
|
-
{},
|
|
347
|
-
{ sessionManager: host.sessionManager },
|
|
348
|
-
);
|
|
291
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
349
292
|
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
350
293
|
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
351
294
|
const result = await run(host.execute, {
|
|
@@ -363,11 +306,7 @@ describe("todo actions", () => {
|
|
|
363
306
|
test("opening a later item cascade-closes skipped pending items", async () => {
|
|
364
307
|
const host = makeHost();
|
|
365
308
|
registerTodo(host.pi);
|
|
366
|
-
await host.emit(
|
|
367
|
-
"session_start",
|
|
368
|
-
{},
|
|
369
|
-
{ sessionManager: host.sessionManager },
|
|
370
|
-
);
|
|
309
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
371
310
|
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
372
311
|
// Jump straight to id 4 without opening 1-3; they should all auto-close.
|
|
373
312
|
const result = await run(host.execute, {
|
|
@@ -386,11 +325,7 @@ describe("todo actions", () => {
|
|
|
386
325
|
test("cascade-close leaves a blocked earlier item untouched", async () => {
|
|
387
326
|
const host = makeHost();
|
|
388
327
|
registerTodo(host.pi);
|
|
389
|
-
await host.emit(
|
|
390
|
-
"session_start",
|
|
391
|
-
{},
|
|
392
|
-
{ sessionManager: host.sessionManager },
|
|
393
|
-
);
|
|
328
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
394
329
|
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
395
330
|
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
396
331
|
const result = await run(host.execute, {
|
|
@@ -407,11 +342,7 @@ describe("todo actions", () => {
|
|
|
407
342
|
test("update unknown id returns error", async () => {
|
|
408
343
|
const host = makeHost();
|
|
409
344
|
registerTodo(host.pi);
|
|
410
|
-
await host.emit(
|
|
411
|
-
"session_start",
|
|
412
|
-
{},
|
|
413
|
-
{ sessionManager: host.sessionManager },
|
|
414
|
-
);
|
|
345
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
415
346
|
const result = await run(host.execute, {
|
|
416
347
|
action: "update",
|
|
417
348
|
id: 999,
|
|
@@ -424,11 +355,7 @@ describe("todo actions", () => {
|
|
|
424
355
|
test("update without status or text does nothing", async () => {
|
|
425
356
|
const host = makeHost();
|
|
426
357
|
registerTodo(host.pi);
|
|
427
|
-
await host.emit(
|
|
428
|
-
"session_start",
|
|
429
|
-
{},
|
|
430
|
-
{ sessionManager: host.sessionManager },
|
|
431
|
-
);
|
|
358
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
432
359
|
await run(host.execute, { action: "set", items: "unchanged" });
|
|
433
360
|
const result = await run(host.execute, { action: "update", id: 1 });
|
|
434
361
|
expect(text(result)).toContain("○ 1. unchanged");
|
|
@@ -437,11 +364,7 @@ describe("todo actions", () => {
|
|
|
437
364
|
test("clear empties list", async () => {
|
|
438
365
|
const host = makeHost();
|
|
439
366
|
registerTodo(host.pi);
|
|
440
|
-
await host.emit(
|
|
441
|
-
"session_start",
|
|
442
|
-
{},
|
|
443
|
-
{ sessionManager: host.sessionManager },
|
|
444
|
-
);
|
|
367
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
445
368
|
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
446
369
|
const result = await run(host.execute, { action: "clear" });
|
|
447
370
|
expect(text(result)).toContain("Todos cleared");
|
|
@@ -453,11 +376,7 @@ describe("todo actions", () => {
|
|
|
453
376
|
test("clear resets id counter", async () => {
|
|
454
377
|
const host = makeHost();
|
|
455
378
|
registerTodo(host.pi);
|
|
456
|
-
await host.emit(
|
|
457
|
-
"session_start",
|
|
458
|
-
{},
|
|
459
|
-
{ sessionManager: host.sessionManager },
|
|
460
|
-
);
|
|
379
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
461
380
|
await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
|
|
462
381
|
await run(host.execute, { action: "clear" });
|
|
463
382
|
const result = await run(host.execute, { action: "set", items: "new" });
|
|
@@ -467,11 +386,7 @@ describe("todo actions", () => {
|
|
|
467
386
|
test("unknown action returns error", async () => {
|
|
468
387
|
const host = makeHost();
|
|
469
388
|
registerTodo(host.pi);
|
|
470
|
-
await host.emit(
|
|
471
|
-
"session_start",
|
|
472
|
-
{},
|
|
473
|
-
{ sessionManager: host.sessionManager },
|
|
474
|
-
);
|
|
389
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
475
390
|
const result = await run(host.execute, { action: "bogus" });
|
|
476
391
|
expect(result.isError).toBe(true);
|
|
477
392
|
expect(text(result)).toContain("Unknown action");
|
|
@@ -480,11 +395,7 @@ describe("todo actions", () => {
|
|
|
480
395
|
test("all status glyphs render correctly", async () => {
|
|
481
396
|
const host = makeHost();
|
|
482
397
|
registerTodo(host.pi);
|
|
483
|
-
await host.emit(
|
|
484
|
-
"session_start",
|
|
485
|
-
{},
|
|
486
|
-
{ sessionManager: host.sessionManager },
|
|
487
|
-
);
|
|
398
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
488
399
|
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
489
400
|
// Open id 2 first (cascade-closes nothing earlier we assert on), then set
|
|
490
401
|
// the others directly so each glyph is exercised without cascade interfering.
|
|
@@ -508,11 +419,7 @@ describe("persistence", () => {
|
|
|
508
419
|
test("set persists todos", async () => {
|
|
509
420
|
const host = makeHost();
|
|
510
421
|
registerTodo(host.pi);
|
|
511
|
-
await host.emit(
|
|
512
|
-
"session_start",
|
|
513
|
-
{},
|
|
514
|
-
{ sessionManager: host.sessionManager },
|
|
515
|
-
);
|
|
422
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
516
423
|
host.appendCalls.length = 0;
|
|
517
424
|
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
518
425
|
expect(host.appendCalls.length).toBe(1);
|
|
@@ -529,11 +436,7 @@ describe("persistence", () => {
|
|
|
529
436
|
test("add persists todos", async () => {
|
|
530
437
|
const host = makeHost();
|
|
531
438
|
registerTodo(host.pi);
|
|
532
|
-
await host.emit(
|
|
533
|
-
"session_start",
|
|
534
|
-
{},
|
|
535
|
-
{ sessionManager: host.sessionManager },
|
|
536
|
-
);
|
|
439
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
537
440
|
await run(host.execute, { action: "set", items: "alpha" });
|
|
538
441
|
host.appendCalls.length = 0;
|
|
539
442
|
await run(host.execute, { action: "add", items: "bravo" });
|
|
@@ -543,11 +446,7 @@ describe("persistence", () => {
|
|
|
543
446
|
test("update persists todos", async () => {
|
|
544
447
|
const host = makeHost();
|
|
545
448
|
registerTodo(host.pi);
|
|
546
|
-
await host.emit(
|
|
547
|
-
"session_start",
|
|
548
|
-
{},
|
|
549
|
-
{ sessionManager: host.sessionManager },
|
|
550
|
-
);
|
|
449
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
551
450
|
await run(host.execute, { action: "set", items: "alpha" });
|
|
552
451
|
host.appendCalls.length = 0;
|
|
553
452
|
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
@@ -557,11 +456,7 @@ describe("persistence", () => {
|
|
|
557
456
|
test("clear persists", async () => {
|
|
558
457
|
const host = makeHost();
|
|
559
458
|
registerTodo(host.pi);
|
|
560
|
-
await host.emit(
|
|
561
|
-
"session_start",
|
|
562
|
-
{},
|
|
563
|
-
{ sessionManager: host.sessionManager },
|
|
564
|
-
);
|
|
459
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
565
460
|
await run(host.execute, { action: "set", items: "alpha" });
|
|
566
461
|
host.appendCalls.length = 0;
|
|
567
462
|
await run(host.execute, { action: "clear" });
|
|
@@ -590,11 +485,7 @@ describe("restore", () => {
|
|
|
590
485
|
},
|
|
591
486
|
]);
|
|
592
487
|
registerTodo(host.pi);
|
|
593
|
-
await host.emit(
|
|
594
|
-
"session_start",
|
|
595
|
-
{},
|
|
596
|
-
{ sessionManager: host.sessionManager },
|
|
597
|
-
);
|
|
488
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
598
489
|
const result = await run(host.execute, { action: "list" });
|
|
599
490
|
expect(text(result)).toContain("● 1. restored");
|
|
600
491
|
expect(text(result)).toContain("Todos 1/1 done");
|
|
@@ -612,11 +503,7 @@ describe("restore", () => {
|
|
|
612
503
|
},
|
|
613
504
|
]);
|
|
614
505
|
registerTodo(host.pi);
|
|
615
|
-
await host.emit(
|
|
616
|
-
"session_start",
|
|
617
|
-
{},
|
|
618
|
-
{ sessionManager: host.sessionManager },
|
|
619
|
-
);
|
|
506
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
620
507
|
const result = await run(host.execute, { action: "add", items: "new" });
|
|
621
508
|
expect(text(result)).toContain("○ 6. new");
|
|
622
509
|
});
|
|
@@ -635,11 +522,7 @@ describe("restore", () => {
|
|
|
635
522
|
},
|
|
636
523
|
]);
|
|
637
524
|
registerTodo(host.pi);
|
|
638
|
-
await host.emit(
|
|
639
|
-
"session_start",
|
|
640
|
-
{},
|
|
641
|
-
{ sessionManager: host.sessionManager },
|
|
642
|
-
);
|
|
525
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
643
526
|
const result = await run(host.execute, { action: "add", items: "next" });
|
|
644
527
|
expect(text(result)).toContain("○ 8. next");
|
|
645
528
|
});
|
|
@@ -658,11 +541,7 @@ describe("restore", () => {
|
|
|
658
541
|
},
|
|
659
542
|
]);
|
|
660
543
|
registerTodo(host.pi);
|
|
661
|
-
await host.emit(
|
|
662
|
-
"session_start",
|
|
663
|
-
{},
|
|
664
|
-
{ sessionManager: host.sessionManager },
|
|
665
|
-
);
|
|
544
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
666
545
|
const result = await run(host.execute, { action: "list" });
|
|
667
546
|
expect(text(result)).toContain("○ 1. real");
|
|
668
547
|
});
|
|
@@ -670,11 +549,7 @@ describe("restore", () => {
|
|
|
670
549
|
test("no todo-state entries starts empty", async () => {
|
|
671
550
|
const host = makeHost([{ type: "message", data: "hello" }]);
|
|
672
551
|
registerTodo(host.pi);
|
|
673
|
-
await host.emit(
|
|
674
|
-
"session_start",
|
|
675
|
-
{},
|
|
676
|
-
{ sessionManager: host.sessionManager },
|
|
677
|
-
);
|
|
552
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
678
553
|
const result = await run(host.execute, { action: "list" });
|
|
679
554
|
expect(text(result)).toBe("(no todos)");
|
|
680
555
|
});
|
|
@@ -682,11 +557,7 @@ describe("restore", () => {
|
|
|
682
557
|
test("empty entries list starts empty", async () => {
|
|
683
558
|
const host = makeHost([]);
|
|
684
559
|
registerTodo(host.pi);
|
|
685
|
-
await host.emit(
|
|
686
|
-
"session_start",
|
|
687
|
-
{},
|
|
688
|
-
{ sessionManager: host.sessionManager },
|
|
689
|
-
);
|
|
560
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
690
561
|
const result = await run(host.execute, { action: "list" });
|
|
691
562
|
expect(text(result)).toBe("(no todos)");
|
|
692
563
|
});
|
|
@@ -700,16 +571,139 @@ describe("restore", () => {
|
|
|
700
571
|
},
|
|
701
572
|
]);
|
|
702
573
|
registerTodo(host.pi);
|
|
703
|
-
await host.emit(
|
|
704
|
-
"session_start",
|
|
705
|
-
{},
|
|
706
|
-
{ sessionManager: host.sessionManager },
|
|
707
|
-
);
|
|
574
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
708
575
|
const result = await run(host.execute, { action: "list" });
|
|
709
576
|
expect(text(result)).toBe("(no todos)");
|
|
710
577
|
});
|
|
711
578
|
});
|
|
712
579
|
|
|
580
|
+
// ─── Skip-guard ─────────────────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
describe("skip-guard on marking done", () => {
|
|
583
|
+
test("warns when marking a later item done with earlier pending items", async () => {
|
|
584
|
+
const host = makeHost();
|
|
585
|
+
registerTodo(host.pi);
|
|
586
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
587
|
+
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
588
|
+
// Mark items 1 and 4 done, leaving 2 and 3 pending
|
|
589
|
+
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
590
|
+
const result = await run(host.execute, { action: "update", id: 4, status: "done" });
|
|
591
|
+
const out = text(result);
|
|
592
|
+
expect(out).toContain("\u26a0 Earlier items still incomplete");
|
|
593
|
+
expect(out).toContain("#2 (b)");
|
|
594
|
+
expect(out).toContain("#3 (c)");
|
|
595
|
+
expect(out).toContain("Mark each done or blocked before proceeding");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("no warning when all earlier items are done", async () => {
|
|
599
|
+
const host = makeHost();
|
|
600
|
+
registerTodo(host.pi);
|
|
601
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
602
|
+
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
603
|
+
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
604
|
+
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
605
|
+
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
606
|
+
expect(text(result)).not.toContain("\u26a0");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("no warning when marking the first item done", async () => {
|
|
610
|
+
const host = makeHost();
|
|
611
|
+
registerTodo(host.pi);
|
|
612
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
613
|
+
await run(host.execute, { action: "set", items: "a\nb" });
|
|
614
|
+
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
615
|
+
expect(text(result)).not.toContain("\u26a0");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
|
|
619
|
+
const host = makeHost();
|
|
620
|
+
registerTodo(host.pi);
|
|
621
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
622
|
+
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
623
|
+
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
624
|
+
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
625
|
+
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
626
|
+
// blocked is an explicit decision, not incomplete — no warning
|
|
627
|
+
expect(text(result)).not.toContain("\u26a0");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("warns about in_progress items too (not just pending)", async () => {
|
|
631
|
+
const host = makeHost();
|
|
632
|
+
registerTodo(host.pi);
|
|
633
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
634
|
+
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
635
|
+
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
636
|
+
// Mark item 3 done while item 1 is still in_progress
|
|
637
|
+
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
638
|
+
const out = text(result);
|
|
639
|
+
expect(out).toContain("\u26a0");
|
|
640
|
+
expect(out).toContain("#1 (a)");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("no skip-guard on in_progress (only on done)", async () => {
|
|
644
|
+
// in_progress uses cascade-close instead, which is different behavior
|
|
645
|
+
const host = makeHost();
|
|
646
|
+
registerTodo(host.pi);
|
|
647
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
648
|
+
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
649
|
+
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
650
|
+
// Should cascade-close, not warn
|
|
651
|
+
expect(text(result)).not.toContain("\u26a0");
|
|
652
|
+
expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
|
|
653
|
+
expect(text(result)).toContain("\u25cf 2. b");
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ─── Turn-based reminder ────────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
describe("turn-based todo reminder", () => {
|
|
660
|
+
test("injects reminder every 10 turns when incomplete items exist", async () => {
|
|
661
|
+
const host = makeHost();
|
|
662
|
+
registerTodo(host.pi);
|
|
663
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
664
|
+
await run(host.execute, { action: "set", items: "a\nb" });
|
|
665
|
+
|
|
666
|
+
// Simulate 10 turns — only the 10th should inject
|
|
667
|
+
for (let i = 1; i <= 9; i++) {
|
|
668
|
+
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
669
|
+
// before_agent_start returns undefined when no injection
|
|
670
|
+
expect(result).toBeUndefined();
|
|
671
|
+
}
|
|
672
|
+
// 10th turn should inject
|
|
673
|
+
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
674
|
+
expect(result).toBeDefined();
|
|
675
|
+
const prompt = (result as { systemPrompt: string }).systemPrompt;
|
|
676
|
+
expect(prompt).toContain("base");
|
|
677
|
+
expect(prompt).toContain("Todo reminder");
|
|
678
|
+
expect(prompt).toContain("1. a");
|
|
679
|
+
expect(prompt).toContain("2. b");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("does not inject when no todos exist", async () => {
|
|
683
|
+
const host = makeHost();
|
|
684
|
+
registerTodo(host.pi);
|
|
685
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
686
|
+
|
|
687
|
+
for (let i = 1; i <= 10; i++) {
|
|
688
|
+
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
689
|
+
expect(result).toBeUndefined();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("does not inject when all items are done", async () => {
|
|
694
|
+
const host = makeHost();
|
|
695
|
+
registerTodo(host.pi);
|
|
696
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
697
|
+
await run(host.execute, { action: "set", items: "a" });
|
|
698
|
+
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
699
|
+
|
|
700
|
+
for (let i = 1; i <= 10; i++) {
|
|
701
|
+
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
702
|
+
expect(result).toBeUndefined();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
713
707
|
describe("renderTodoLines (colored TUI render)", () => {
|
|
714
708
|
const items: TodoItem[] = [
|
|
715
709
|
{ id: 1, text: "alpha", status: "done" },
|
|
@@ -754,11 +748,7 @@ describe("renderResult snapshot isolation", () => {
|
|
|
754
748
|
test("a rendered card keeps its state after todos mutate", async () => {
|
|
755
749
|
const host = makeHost();
|
|
756
750
|
registerTodo(host.pi);
|
|
757
|
-
await host.emit(
|
|
758
|
-
"session_start",
|
|
759
|
-
{},
|
|
760
|
-
{ sessionManager: host.sessionManager },
|
|
761
|
-
);
|
|
751
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
762
752
|
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
763
753
|
|
|
764
754
|
// Render once with a per-row state bag; snapshot is taken here.
|
|
@@ -789,8 +779,6 @@ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
|
|
|
789
779
|
{ id: 1, text: "a", status: "done" },
|
|
790
780
|
{ id: 2, text: "b", status: "pending" },
|
|
791
781
|
];
|
|
792
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
793
|
-
"[muted]Todos 1/2 done ✓[/]",
|
|
794
|
-
);
|
|
782
|
+
expect(renderTodoSummaryLine(items, tagTheme)).toBe("[muted]Todos 1/2 done ✓[/]");
|
|
795
783
|
});
|
|
796
784
|
});
|
package/src/todo.ts
CHANGED
|
@@ -42,8 +42,7 @@ function readCollapseConfig(): CollapseConf {
|
|
|
42
42
|
if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
|
|
43
43
|
return {
|
|
44
44
|
enabled: typeof c.enabled === "boolean" ? c.enabled : true,
|
|
45
|
-
delaySec:
|
|
46
|
-
typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
|
|
45
|
+
delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
|
|
47
46
|
tools:
|
|
48
47
|
c.tools && typeof c.tools === "object"
|
|
49
48
|
? (c.tools as Record<string, boolean | undefined>)
|
|
@@ -99,10 +98,7 @@ export type TodoTheme = {
|
|
|
99
98
|
};
|
|
100
99
|
|
|
101
100
|
/** One-line dim summary used once a card has collapsed. */
|
|
102
|
-
export function renderTodoSummaryLine(
|
|
103
|
-
items: TodoItem[],
|
|
104
|
-
theme: TodoTheme,
|
|
105
|
-
): string {
|
|
101
|
+
export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
|
|
106
102
|
if (!items.length) return theme.fg("muted", "(no todos)");
|
|
107
103
|
const done = items.filter((t) => t.status === "done").length;
|
|
108
104
|
return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
|
|
@@ -127,6 +123,22 @@ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
|
|
|
127
123
|
return `${head}\n${lines.join("\n")}`;
|
|
128
124
|
}
|
|
129
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Skip-guard: when marking an item done, check for earlier items still
|
|
128
|
+
* pending or in_progress. Returns a warning string or "" if none skipped.
|
|
129
|
+
*/
|
|
130
|
+
function buildSkipWarning(items: TodoItem[], targetId: number): string {
|
|
131
|
+
const skipped = items.filter(
|
|
132
|
+
(o) => o.id < targetId && (o.status === "pending" || o.status === "in_progress"),
|
|
133
|
+
);
|
|
134
|
+
if (skipped.length === 0) return "";
|
|
135
|
+
const ids = skipped.map((s) => `#${s.id} (${s.text})`).join(", ");
|
|
136
|
+
return (
|
|
137
|
+
`\n\n\u26a0 Earlier items still incomplete: ${ids}. ` +
|
|
138
|
+
"Mark each done or blocked before proceeding."
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
130
142
|
const parseItems = (raw: string): string[] =>
|
|
131
143
|
raw
|
|
132
144
|
.split("\n")
|
|
@@ -145,9 +157,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
145
157
|
function todoSummary(): string {
|
|
146
158
|
if (!todos.length) return "(no todos)";
|
|
147
159
|
const done = todos.filter((t) => t.status === "done").length;
|
|
148
|
-
const lines = todos.map(
|
|
149
|
-
(t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
|
|
150
|
-
);
|
|
160
|
+
const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
|
|
151
161
|
return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
|
|
152
162
|
}
|
|
153
163
|
|
|
@@ -164,6 +174,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
164
174
|
promptGuidelines: [
|
|
165
175
|
"When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
|
|
166
176
|
"Mark each item in_progress before working it via `todo(action:'update', id, status)`; opening one auto-closes every earlier item, so just open the next and skipped steps mark done themselves.",
|
|
177
|
+
"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.",
|
|
167
178
|
"Call `todo(action:'list')` to recover your place after long runs or context compaction.",
|
|
168
179
|
],
|
|
169
180
|
parameters: Type.Object({
|
|
@@ -179,13 +190,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
179
190
|
),
|
|
180
191
|
items: Type.Optional(
|
|
181
192
|
Type.String({
|
|
182
|
-
description:
|
|
183
|
-
"For set/add: newline-separated or numbered list of todo texts.",
|
|
193
|
+
description: "For set/add: newline-separated or numbered list of todo texts.",
|
|
184
194
|
}),
|
|
185
195
|
),
|
|
186
|
-
id: Type.Optional(
|
|
187
|
-
Type.Number({ description: "For update: target todo id." }),
|
|
188
|
-
),
|
|
196
|
+
id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
|
|
189
197
|
status: Type.Optional(
|
|
190
198
|
Type.Union(
|
|
191
199
|
[
|
|
@@ -220,9 +228,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
220
228
|
context.invalidate();
|
|
221
229
|
}, collapseDelayMs());
|
|
222
230
|
}
|
|
223
|
-
const render = state.collapsed
|
|
224
|
-
? renderTodoSummaryLine
|
|
225
|
-
: renderTodoLines;
|
|
231
|
+
const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
|
|
226
232
|
return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
|
|
227
233
|
},
|
|
228
234
|
|
|
@@ -258,8 +264,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
258
264
|
case "add": {
|
|
259
265
|
const texts = parseItems(params.items ?? "");
|
|
260
266
|
if (!texts.length) return fail("add requires non-empty `items`.");
|
|
261
|
-
for (const text of texts)
|
|
262
|
-
todos.push({ id: nextTodoId++, text, status: "pending" });
|
|
267
|
+
for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" });
|
|
263
268
|
persistTodos();
|
|
264
269
|
return ok(todoSummary());
|
|
265
270
|
}
|
|
@@ -267,6 +272,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
267
272
|
case "update": {
|
|
268
273
|
const t = todos.find((x) => x.id === params.id);
|
|
269
274
|
if (!t) return fail(`No todo with id ${params.id}.`);
|
|
275
|
+
let skipWarning = "";
|
|
270
276
|
if (params.status) {
|
|
271
277
|
// Sequential-progress invariant: opening a task means everything
|
|
272
278
|
// before it is finished. Cascade-close every earlier pending or
|
|
@@ -276,15 +282,17 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
276
282
|
for (const other of todos)
|
|
277
283
|
if (
|
|
278
284
|
other.id < t.id &&
|
|
279
|
-
(other.status === "pending" ||
|
|
280
|
-
other.status === "in_progress")
|
|
285
|
+
(other.status === "pending" || other.status === "in_progress")
|
|
281
286
|
)
|
|
282
287
|
other.status = "done";
|
|
288
|
+
|
|
289
|
+
if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
|
|
290
|
+
|
|
283
291
|
t.status = params.status;
|
|
284
292
|
}
|
|
285
293
|
if (params.text) t.text = params.text;
|
|
286
294
|
persistTodos();
|
|
287
|
-
return ok(todoSummary());
|
|
295
|
+
return ok(todoSummary() + skipWarning);
|
|
288
296
|
}
|
|
289
297
|
|
|
290
298
|
case "clear":
|
|
@@ -299,6 +307,32 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
299
307
|
},
|
|
300
308
|
});
|
|
301
309
|
|
|
310
|
+
// ── Turn-based reminder ─────────────────────────────────────────────
|
|
311
|
+
// Every TODO_REMINDER_INTERVAL turns, inject the current todo summary
|
|
312
|
+
// into the system prompt so the model stays aware of pending work and
|
|
313
|
+
// can't hand-wave or ignore incomplete items.
|
|
314
|
+
const TODO_REMINDER_INTERVAL = 10;
|
|
315
|
+
let todoTurnCount = 0;
|
|
316
|
+
|
|
317
|
+
pi.on("before_agent_start", async (event) => {
|
|
318
|
+
todoTurnCount++;
|
|
319
|
+
// Only inject when there are active (non-empty) todos
|
|
320
|
+
if (todos.length === 0) return;
|
|
321
|
+
// Check if any items are still incomplete
|
|
322
|
+
const hasIncomplete = todos.some((t) => t.status === "pending" || t.status === "in_progress");
|
|
323
|
+
if (!hasIncomplete) return;
|
|
324
|
+
// Fire on every Nth turn
|
|
325
|
+
if (todoTurnCount % TODO_REMINDER_INTERVAL !== 0) return;
|
|
326
|
+
|
|
327
|
+
const reminder =
|
|
328
|
+
"Todo reminder — incomplete items remain:\n" +
|
|
329
|
+
todoSummary() +
|
|
330
|
+
"\nCall `todo(action:'list')` to review, then continue working through pending items.";
|
|
331
|
+
|
|
332
|
+
const existing = event.systemPrompt ?? "";
|
|
333
|
+
return { systemPrompt: existing ? `${existing}\n\n${reminder}` : reminder };
|
|
334
|
+
});
|
|
335
|
+
|
|
302
336
|
// Restore the checklist from session entries so it survives restart.
|
|
303
337
|
pi.on("session_start", async (_event, ctx) => {
|
|
304
338
|
const entries = ctx.sessionManager.getEntries() as Array<{
|
|
@@ -311,9 +345,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
311
345
|
.pop();
|
|
312
346
|
if (Array.isArray(lastTodo?.data?.todos)) {
|
|
313
347
|
todos = lastTodo.data.todos;
|
|
314
|
-
nextTodoId =
|
|
315
|
-
lastTodo.data.nextTodoId ??
|
|
316
|
-
todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
|
|
348
|
+
nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
|
|
317
349
|
}
|
|
318
350
|
});
|
|
319
351
|
});
|