@xynogen/pix-todo 0.3.2 → 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 +2 -1
- package/src/todo.ts +103 -15
- package/src/todo.test.ts +0 -1012
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi tool — durable execution checklist (todo)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.*",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
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":
|
package/src/todo.test.ts
DELETED
|
@@ -1,1012 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import registerTodo, { renderTodoLines, renderTodoSummaryLine, type TodoItem } from "./todo.ts";
|
|
3
|
-
|
|
4
|
-
// registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
|
|
5
|
-
// WeakMap guard that dedupes activation across pix-core + a standalone install.
|
|
6
|
-
// Tests re-register a fresh host per case. Clear the registry between tests so
|
|
7
|
-
// that the same pi object can be re-used without cross-test interference.
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
// Stub theme tags each fragment with its color/bold so assertions can verify
|
|
13
|
-
// which status got which tint, without depending on real ANSI codes.
|
|
14
|
-
const tagTheme = {
|
|
15
|
-
fg: (color: string, text: string) => `[${color}]${text}[/]`,
|
|
16
|
-
bold: (text: string) => `<b>${text}</b>`,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
/** Create a mock ExtensionAPI that captures the registered tool's execute fn. */
|
|
22
|
-
function makeHost(
|
|
23
|
-
initialEntries: Array<{
|
|
24
|
-
type: string;
|
|
25
|
-
customType?: string;
|
|
26
|
-
data?: unknown;
|
|
27
|
-
}> = [],
|
|
28
|
-
) {
|
|
29
|
-
let capturedParameters: unknown;
|
|
30
|
-
let capturedRenderShell: unknown;
|
|
31
|
-
let capturedExecute:
|
|
32
|
-
| ((
|
|
33
|
-
id: string,
|
|
34
|
-
params: Record<string, unknown>,
|
|
35
|
-
) => Promise<{
|
|
36
|
-
content: Array<{ type: string; text: string }>;
|
|
37
|
-
details?: unknown;
|
|
38
|
-
isError?: boolean;
|
|
39
|
-
}>)
|
|
40
|
-
| null = null;
|
|
41
|
-
const appendCalls: Array<{ type: string; data: unknown }> = [];
|
|
42
|
-
const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
|
|
43
|
-
|
|
44
|
-
let capturedRender:
|
|
45
|
-
| ((
|
|
46
|
-
result: unknown,
|
|
47
|
-
options: unknown,
|
|
48
|
-
theme: unknown,
|
|
49
|
-
context: unknown,
|
|
50
|
-
) => { render(width: number): string[] })
|
|
51
|
-
| null = null;
|
|
52
|
-
let capturedRenderCall:
|
|
53
|
-
| ((args: unknown, theme: unknown, context: unknown) => { render(width: number): string[] })
|
|
54
|
-
| null = null;
|
|
55
|
-
|
|
56
|
-
const pi = {
|
|
57
|
-
registerTool(def: {
|
|
58
|
-
name: string;
|
|
59
|
-
parameters: unknown;
|
|
60
|
-
execute: typeof capturedExecute;
|
|
61
|
-
renderShell?: unknown;
|
|
62
|
-
renderCall?: typeof capturedRenderCall;
|
|
63
|
-
renderResult?: typeof capturedRender;
|
|
64
|
-
}) {
|
|
65
|
-
capturedParameters = def.parameters;
|
|
66
|
-
capturedRenderShell = def.renderShell;
|
|
67
|
-
capturedExecute = def.execute;
|
|
68
|
-
if (def.renderCall) capturedRenderCall = def.renderCall;
|
|
69
|
-
if (def.renderResult) capturedRender = def.renderResult;
|
|
70
|
-
},
|
|
71
|
-
appendEntry(type: string, data: unknown) {
|
|
72
|
-
appendCalls.push({ type, data });
|
|
73
|
-
},
|
|
74
|
-
on(ev: string, fn: (event: unknown, ctx?: unknown) => unknown) {
|
|
75
|
-
if (!handlers[ev]) handlers[ev] = [];
|
|
76
|
-
handlers[ev].push(fn);
|
|
77
|
-
},
|
|
78
|
-
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
79
|
-
let last: unknown;
|
|
80
|
-
for (const fn of handlers[ev] ?? []) {
|
|
81
|
-
const result = await fn(event, ctx);
|
|
82
|
-
if (result !== undefined) last = result;
|
|
83
|
-
}
|
|
84
|
-
return last;
|
|
85
|
-
},
|
|
86
|
-
} as never;
|
|
87
|
-
|
|
88
|
-
const sessionManager = {
|
|
89
|
-
getEntries() {
|
|
90
|
-
return initialEntries;
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
pi,
|
|
96
|
-
sessionManager,
|
|
97
|
-
get parameters() {
|
|
98
|
-
if (!capturedParameters) throw new Error("parameters not captured");
|
|
99
|
-
return capturedParameters;
|
|
100
|
-
},
|
|
101
|
-
get execute() {
|
|
102
|
-
if (!capturedExecute) throw new Error("execute not captured");
|
|
103
|
-
return capturedExecute;
|
|
104
|
-
},
|
|
105
|
-
get renderShell() {
|
|
106
|
-
return capturedRenderShell;
|
|
107
|
-
},
|
|
108
|
-
get renderCall() {
|
|
109
|
-
if (!capturedRenderCall) throw new Error("renderCall not captured");
|
|
110
|
-
return capturedRenderCall;
|
|
111
|
-
},
|
|
112
|
-
get render() {
|
|
113
|
-
if (!capturedRender) throw new Error("render not captured");
|
|
114
|
-
return capturedRender;
|
|
115
|
-
},
|
|
116
|
-
appendCalls,
|
|
117
|
-
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
118
|
-
let last: unknown;
|
|
119
|
-
for (const fn of handlers[ev] ?? []) {
|
|
120
|
-
const result = await fn(event, ctx);
|
|
121
|
-
if (result !== undefined) last = result;
|
|
122
|
-
}
|
|
123
|
-
return last;
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function run(
|
|
129
|
-
execute: (
|
|
130
|
-
id: string,
|
|
131
|
-
params: Record<string, unknown>,
|
|
132
|
-
) => Promise<{
|
|
133
|
-
content: Array<{ type: string; text: string }>;
|
|
134
|
-
details?: unknown;
|
|
135
|
-
isError?: boolean;
|
|
136
|
-
}>,
|
|
137
|
-
params: Record<string, unknown>,
|
|
138
|
-
) {
|
|
139
|
-
return execute("call-1", params);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function text(result: { content: Array<{ type: string; text: string }> }) {
|
|
143
|
-
return result.content.map((c) => c.text).join("\n");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ─── Tool schema ────────────────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
test("todo exposes action and status as guided string enums", () => {
|
|
149
|
-
const host = makeHost();
|
|
150
|
-
registerTodo(host.pi);
|
|
151
|
-
const schema = host.parameters as {
|
|
152
|
-
properties: {
|
|
153
|
-
action: { type?: string; enum?: string[]; description?: string };
|
|
154
|
-
status: { type?: string; enum?: string[]; description?: string };
|
|
155
|
-
};
|
|
156
|
-
};
|
|
157
|
-
const action = schema.properties.action;
|
|
158
|
-
const status = schema.properties.status;
|
|
159
|
-
|
|
160
|
-
expect(action.type).toBe("string");
|
|
161
|
-
expect(action.enum).toEqual(["list", "set", "add", "update", "clear"]);
|
|
162
|
-
expect(action.description).toContain('"list" shows items');
|
|
163
|
-
expect(action.description).toContain('"update" changes one item by id');
|
|
164
|
-
expect(status?.type).toBe("string");
|
|
165
|
-
expect(status?.enum).toEqual(["pending", "in_progress", "done", "blocked"]);
|
|
166
|
-
expect(status?.description).toContain('"pending" = not started');
|
|
167
|
-
expect(status?.description).toContain('"blocked" = cannot proceed');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ─── parseItems (via set/add) ───────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
describe("todo actions", () => {
|
|
173
|
-
test("list on empty returns (no todos)", async () => {
|
|
174
|
-
const host = makeHost();
|
|
175
|
-
registerTodo(host.pi);
|
|
176
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
177
|
-
const result = await run(host.execute, { action: "list" });
|
|
178
|
-
expect(text(result)).toBe("(no todos)");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("set creates items from newline text", async () => {
|
|
182
|
-
const host = makeHost();
|
|
183
|
-
registerTodo(host.pi);
|
|
184
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
185
|
-
const result = await run(host.execute, {
|
|
186
|
-
action: "set",
|
|
187
|
-
items: "alpha\nbravo\ncharlie",
|
|
188
|
-
});
|
|
189
|
-
const out = text(result);
|
|
190
|
-
expect(out).toContain("Todos 0/3 done");
|
|
191
|
-
expect(out).toContain("○ 1. alpha");
|
|
192
|
-
expect(out).toContain("○ 2. bravo");
|
|
193
|
-
expect(out).toContain("○ 3. charlie");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("set creates items from numbered list", async () => {
|
|
197
|
-
const host = makeHost();
|
|
198
|
-
registerTodo(host.pi);
|
|
199
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
200
|
-
const result = await run(host.execute, {
|
|
201
|
-
action: "set",
|
|
202
|
-
items: "1. alpha\n2. bravo",
|
|
203
|
-
});
|
|
204
|
-
expect(text(result)).toContain("○ 1. alpha");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("set creates items from bullet list", async () => {
|
|
208
|
-
const host = makeHost();
|
|
209
|
-
registerTodo(host.pi);
|
|
210
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
211
|
-
const result = await run(host.execute, {
|
|
212
|
-
action: "set",
|
|
213
|
-
items: "- alpha\n* bravo",
|
|
214
|
-
});
|
|
215
|
-
expect(text(result)).toContain("○ 1. alpha");
|
|
216
|
-
expect(text(result)).toContain("○ 2. bravo");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("set ignores empty lines", async () => {
|
|
220
|
-
const host = makeHost();
|
|
221
|
-
registerTodo(host.pi);
|
|
222
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
223
|
-
const result = await run(host.execute, {
|
|
224
|
-
action: "set",
|
|
225
|
-
items: "alpha\n\nbravo\n \ncharlie",
|
|
226
|
-
});
|
|
227
|
-
expect(text(result)).toContain("Todos 0/3 done");
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("set with empty items returns error", async () => {
|
|
231
|
-
const host = makeHost();
|
|
232
|
-
registerTodo(host.pi);
|
|
233
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
234
|
-
const result = await run(host.execute, { action: "set", items: "" });
|
|
235
|
-
expect(result.isError).toBe(true);
|
|
236
|
-
expect(text(result)).toContain("non-empty");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test("set with only whitespace returns error", async () => {
|
|
240
|
-
const host = makeHost();
|
|
241
|
-
registerTodo(host.pi);
|
|
242
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
243
|
-
const result = await run(host.execute, { action: "set", items: " \n " });
|
|
244
|
-
expect(result.isError).toBe(true);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("set resets ids on re-set", async () => {
|
|
248
|
-
const host = makeHost();
|
|
249
|
-
registerTodo(host.pi);
|
|
250
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
251
|
-
await run(host.execute, { action: "set", items: "first\nsecond" });
|
|
252
|
-
const result = await run(host.execute, { action: "set", items: "new" });
|
|
253
|
-
expect(text(result)).toContain("○ 1. new");
|
|
254
|
-
expect(text(result)).toContain("Todos 0/1 done");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("add appends items", async () => {
|
|
258
|
-
const host = makeHost();
|
|
259
|
-
registerTodo(host.pi);
|
|
260
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
261
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
262
|
-
const result = await run(host.execute, {
|
|
263
|
-
action: "add",
|
|
264
|
-
items: "bravo\ncharlie",
|
|
265
|
-
});
|
|
266
|
-
const out = text(result);
|
|
267
|
-
expect(out).toContain("○ 1. alpha");
|
|
268
|
-
expect(out).toContain("○ 2. bravo");
|
|
269
|
-
expect(out).toContain("○ 3. charlie");
|
|
270
|
-
expect(out).toContain("Todos 0/3 done");
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("add with ids continuing sequence", async () => {
|
|
274
|
-
const host = makeHost();
|
|
275
|
-
registerTodo(host.pi);
|
|
276
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
277
|
-
await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
|
|
278
|
-
const result = await run(host.execute, { action: "add", items: "fourth" });
|
|
279
|
-
expect(text(result)).toContain("○ 4. fourth");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("add with empty items returns error", async () => {
|
|
283
|
-
const host = makeHost();
|
|
284
|
-
registerTodo(host.pi);
|
|
285
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
286
|
-
const result = await run(host.execute, { action: "add", items: "" });
|
|
287
|
-
expect(result.isError).toBe(true);
|
|
288
|
-
expect(text(result)).toContain("non-empty");
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("update changes status", async () => {
|
|
292
|
-
const host = makeHost();
|
|
293
|
-
registerTodo(host.pi);
|
|
294
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
295
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
296
|
-
const result = await run(host.execute, {
|
|
297
|
-
action: "update",
|
|
298
|
-
id: 1,
|
|
299
|
-
status: "done",
|
|
300
|
-
});
|
|
301
|
-
const out = text(result);
|
|
302
|
-
expect(out).toContain("● 1. alpha");
|
|
303
|
-
expect(out).toContain("○ 2. bravo");
|
|
304
|
-
expect(out).toContain("Todos 1/2 done");
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("update changes text", async () => {
|
|
308
|
-
const host = makeHost();
|
|
309
|
-
registerTodo(host.pi);
|
|
310
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
311
|
-
await run(host.execute, { action: "set", items: "old name" });
|
|
312
|
-
const result = await run(host.execute, {
|
|
313
|
-
action: "update",
|
|
314
|
-
id: 1,
|
|
315
|
-
text: "new name",
|
|
316
|
-
});
|
|
317
|
-
expect(text(result)).toContain("○ 1. new name");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("update changes status and text together", async () => {
|
|
321
|
-
const host = makeHost();
|
|
322
|
-
registerTodo(host.pi);
|
|
323
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
324
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
325
|
-
const result = await run(host.execute, {
|
|
326
|
-
action: "update",
|
|
327
|
-
id: 1,
|
|
328
|
-
status: "blocked",
|
|
329
|
-
text: "alpha (waiting)",
|
|
330
|
-
});
|
|
331
|
-
const out = text(result);
|
|
332
|
-
expect(out).toContain("⊘ 1. alpha (waiting)");
|
|
333
|
-
expect(out).toContain("Todos 0/1 done");
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("opening a new in_progress closes the previous one", async () => {
|
|
337
|
-
const host = makeHost();
|
|
338
|
-
registerTodo(host.pi);
|
|
339
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
340
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
341
|
-
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
342
|
-
const result = await run(host.execute, {
|
|
343
|
-
action: "update",
|
|
344
|
-
id: 2,
|
|
345
|
-
status: "in_progress",
|
|
346
|
-
});
|
|
347
|
-
const out = text(result);
|
|
348
|
-
expect(out).toContain("● 1. a"); // auto-closed to done
|
|
349
|
-
expect(out).toContain("◐ 2. b"); // now active
|
|
350
|
-
expect(out).toContain("○ 3. c");
|
|
351
|
-
expect(out).toContain("Todos 1/3 done");
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("opening a later item cascade-closes skipped pending items", async () => {
|
|
355
|
-
const host = makeHost();
|
|
356
|
-
registerTodo(host.pi);
|
|
357
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
358
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
359
|
-
// Jump straight to id 4 without opening 1-3; they should all auto-close.
|
|
360
|
-
const result = await run(host.execute, {
|
|
361
|
-
action: "update",
|
|
362
|
-
id: 4,
|
|
363
|
-
status: "in_progress",
|
|
364
|
-
});
|
|
365
|
-
const out = text(result);
|
|
366
|
-
expect(out).toContain("● 1. a");
|
|
367
|
-
expect(out).toContain("● 2. b");
|
|
368
|
-
expect(out).toContain("● 3. c");
|
|
369
|
-
expect(out).toContain("◐ 4. d");
|
|
370
|
-
expect(out).toContain("Todos 3/4 done");
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test("cascade-close leaves a blocked earlier item untouched", async () => {
|
|
374
|
-
const host = makeHost();
|
|
375
|
-
registerTodo(host.pi);
|
|
376
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
377
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
378
|
-
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
379
|
-
const result = await run(host.execute, {
|
|
380
|
-
action: "update",
|
|
381
|
-
id: 3,
|
|
382
|
-
status: "in_progress",
|
|
383
|
-
});
|
|
384
|
-
const out = text(result);
|
|
385
|
-
expect(out).toContain("⊘ 1. a"); // still blocked, not force-closed
|
|
386
|
-
expect(out).toContain("● 2. b"); // pending -> done
|
|
387
|
-
expect(out).toContain("◐ 3. c");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("update unknown id returns error", async () => {
|
|
391
|
-
const host = makeHost();
|
|
392
|
-
registerTodo(host.pi);
|
|
393
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
394
|
-
const result = await run(host.execute, {
|
|
395
|
-
action: "update",
|
|
396
|
-
id: 999,
|
|
397
|
-
status: "done",
|
|
398
|
-
});
|
|
399
|
-
expect(result.isError).toBe(true);
|
|
400
|
-
expect(text(result)).toContain("999");
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("update without status or text does nothing", async () => {
|
|
404
|
-
const host = makeHost();
|
|
405
|
-
registerTodo(host.pi);
|
|
406
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
407
|
-
await run(host.execute, { action: "set", items: "unchanged" });
|
|
408
|
-
const result = await run(host.execute, { action: "update", id: 1 });
|
|
409
|
-
expect(text(result)).toContain("○ 1. unchanged");
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
test("clear empties list", async () => {
|
|
413
|
-
const host = makeHost();
|
|
414
|
-
registerTodo(host.pi);
|
|
415
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
416
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
417
|
-
const result = await run(host.execute, { action: "clear" });
|
|
418
|
-
expect(text(result)).toContain("Todos cleared");
|
|
419
|
-
// next list should show empty
|
|
420
|
-
const list = await run(host.execute, { action: "list" });
|
|
421
|
-
expect(text(list)).toBe("(no todos)");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("clear resets id counter", async () => {
|
|
425
|
-
const host = makeHost();
|
|
426
|
-
registerTodo(host.pi);
|
|
427
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
428
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
|
|
429
|
-
await run(host.execute, { action: "clear" });
|
|
430
|
-
const result = await run(host.execute, { action: "set", items: "new" });
|
|
431
|
-
expect(text(result)).toContain("○ 1. new");
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test("unknown action returns error", async () => {
|
|
435
|
-
const host = makeHost();
|
|
436
|
-
registerTodo(host.pi);
|
|
437
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
438
|
-
const result = await run(host.execute, { action: "bogus" });
|
|
439
|
-
expect(result.isError).toBe(true);
|
|
440
|
-
expect(text(result)).toContain("Unknown action");
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test("all status glyphs render correctly", async () => {
|
|
444
|
-
const host = makeHost();
|
|
445
|
-
registerTodo(host.pi);
|
|
446
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
447
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
448
|
-
// Open id 2 first (cascade-closes nothing earlier we assert on), then set
|
|
449
|
-
// the others directly so each glyph is exercised without cascade interfering.
|
|
450
|
-
await run(host.execute, { action: "update", id: 2, status: "in_progress" });
|
|
451
|
-
await run(host.execute, { action: "update", id: 1, status: "pending" });
|
|
452
|
-
await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
453
|
-
await run(host.execute, { action: "update", id: 4, status: "blocked" });
|
|
454
|
-
const out = text(await run(host.execute, { action: "list" }));
|
|
455
|
-
expect(out).toContain("○ 1. a");
|
|
456
|
-
expect(out).toContain("◐ 2. b");
|
|
457
|
-
expect(out).toContain("● 3. c");
|
|
458
|
-
expect(out).toContain("⊘ 4. d");
|
|
459
|
-
expect(out).toContain("Todos 1/4 done");
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
// ─── Persistence ────────────────────────────────────────────────────────────
|
|
464
|
-
|
|
465
|
-
describe("persistence", () => {
|
|
466
|
-
type AppendCall = { type: string; data: unknown };
|
|
467
|
-
test("set persists todos", async () => {
|
|
468
|
-
const host = makeHost();
|
|
469
|
-
registerTodo(host.pi);
|
|
470
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
471
|
-
host.appendCalls.length = 0;
|
|
472
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
473
|
-
expect(host.appendCalls.length).toBe(1);
|
|
474
|
-
const ac0 = host.appendCalls[0] as AppendCall;
|
|
475
|
-
expect(ac0.type).toBe("todo-state");
|
|
476
|
-
const data = ac0.data as {
|
|
477
|
-
todos: Array<{ id: number; text: string; status: string }>;
|
|
478
|
-
nextTodoId: number;
|
|
479
|
-
};
|
|
480
|
-
expect(data.todos).toHaveLength(2);
|
|
481
|
-
expect(data.nextTodoId).toBe(3);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
test("add persists todos", async () => {
|
|
485
|
-
const host = makeHost();
|
|
486
|
-
registerTodo(host.pi);
|
|
487
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
488
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
489
|
-
host.appendCalls.length = 0;
|
|
490
|
-
await run(host.execute, { action: "add", items: "bravo" });
|
|
491
|
-
expect(host.appendCalls.length).toBe(1);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("update persists todos", async () => {
|
|
495
|
-
const host = makeHost();
|
|
496
|
-
registerTodo(host.pi);
|
|
497
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
498
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
499
|
-
host.appendCalls.length = 0;
|
|
500
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
501
|
-
expect(host.appendCalls.length).toBe(1);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("clear persists", async () => {
|
|
505
|
-
const host = makeHost();
|
|
506
|
-
registerTodo(host.pi);
|
|
507
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
508
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
509
|
-
host.appendCalls.length = 0;
|
|
510
|
-
await run(host.execute, { action: "clear" });
|
|
511
|
-
expect(host.appendCalls.length).toBe(1);
|
|
512
|
-
const data = (host.appendCalls[0] as AppendCall).data as {
|
|
513
|
-
todos: Array<unknown>;
|
|
514
|
-
nextTodoId: number;
|
|
515
|
-
};
|
|
516
|
-
expect(data.todos).toEqual([]);
|
|
517
|
-
expect(data.nextTodoId).toBe(1);
|
|
518
|
-
});
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
// ─── Session restore ────────────────────────────────────────────────────────
|
|
522
|
-
|
|
523
|
-
describe("restore", () => {
|
|
524
|
-
test("restores todos from last todo-state entry", async () => {
|
|
525
|
-
const host = makeHost([
|
|
526
|
-
{
|
|
527
|
-
type: "custom",
|
|
528
|
-
customType: "todo-state",
|
|
529
|
-
data: {
|
|
530
|
-
todos: [{ id: 1, text: "restored", status: "done" }],
|
|
531
|
-
nextTodoId: 2,
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
]);
|
|
535
|
-
registerTodo(host.pi);
|
|
536
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
537
|
-
const result = await run(host.execute, { action: "list" });
|
|
538
|
-
expect(text(result)).toContain("● 1. restored");
|
|
539
|
-
expect(text(result)).toContain("Todos 1/1 done");
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test("restores nextTodoId so new items continue sequence", async () => {
|
|
543
|
-
const host = makeHost([
|
|
544
|
-
{
|
|
545
|
-
type: "custom",
|
|
546
|
-
customType: "todo-state",
|
|
547
|
-
data: {
|
|
548
|
-
todos: [{ id: 5, text: "existing", status: "pending" }],
|
|
549
|
-
nextTodoId: 6,
|
|
550
|
-
},
|
|
551
|
-
},
|
|
552
|
-
]);
|
|
553
|
-
registerTodo(host.pi);
|
|
554
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
555
|
-
const result = await run(host.execute, { action: "add", items: "new" });
|
|
556
|
-
expect(text(result)).toContain("○ 6. new");
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
test("restores nextTodoId from max id when nextTodoId missing", async () => {
|
|
560
|
-
const host = makeHost([
|
|
561
|
-
{
|
|
562
|
-
type: "custom",
|
|
563
|
-
customType: "todo-state",
|
|
564
|
-
data: {
|
|
565
|
-
todos: [
|
|
566
|
-
{ id: 3, text: "old", status: "done" },
|
|
567
|
-
{ id: 7, text: "newer", status: "pending" },
|
|
568
|
-
],
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
]);
|
|
572
|
-
registerTodo(host.pi);
|
|
573
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
574
|
-
const result = await run(host.execute, { action: "add", items: "next" });
|
|
575
|
-
expect(text(result)).toContain("○ 8. next");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("ignores non-todo-state entries", async () => {
|
|
579
|
-
const host = makeHost([
|
|
580
|
-
{ type: "message", data: "hello" },
|
|
581
|
-
{ type: "custom", customType: "other-thing", data: {} },
|
|
582
|
-
{
|
|
583
|
-
type: "custom",
|
|
584
|
-
customType: "todo-state",
|
|
585
|
-
data: {
|
|
586
|
-
todos: [{ id: 1, text: "real", status: "pending" }],
|
|
587
|
-
nextTodoId: 2,
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
]);
|
|
591
|
-
registerTodo(host.pi);
|
|
592
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
593
|
-
const result = await run(host.execute, { action: "list" });
|
|
594
|
-
expect(text(result)).toContain("○ 1. real");
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
test("no todo-state entries starts empty", async () => {
|
|
598
|
-
const host = makeHost([{ type: "message", data: "hello" }]);
|
|
599
|
-
registerTodo(host.pi);
|
|
600
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
601
|
-
const result = await run(host.execute, { action: "list" });
|
|
602
|
-
expect(text(result)).toBe("(no todos)");
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
test("empty entries list starts empty", async () => {
|
|
606
|
-
const host = makeHost([]);
|
|
607
|
-
registerTodo(host.pi);
|
|
608
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
609
|
-
const result = await run(host.execute, { action: "list" });
|
|
610
|
-
expect(text(result)).toBe("(no todos)");
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
test("restore with empty todos array works", async () => {
|
|
614
|
-
const host = makeHost([
|
|
615
|
-
{
|
|
616
|
-
type: "custom",
|
|
617
|
-
customType: "todo-state",
|
|
618
|
-
data: { todos: [], nextTodoId: 1 },
|
|
619
|
-
},
|
|
620
|
-
]);
|
|
621
|
-
registerTodo(host.pi);
|
|
622
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
623
|
-
const result = await run(host.execute, { action: "list" });
|
|
624
|
-
expect(text(result)).toBe("(no todos)");
|
|
625
|
-
});
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
// ─── Skip-guard ─────────────────────────────────────────────────────────────────────
|
|
629
|
-
|
|
630
|
-
describe("skip-guard on marking done", () => {
|
|
631
|
-
test("warns when marking a later item done with earlier pending items", async () => {
|
|
632
|
-
const host = makeHost();
|
|
633
|
-
registerTodo(host.pi);
|
|
634
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
635
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
636
|
-
// Mark items 1 and 4 done, leaving 2 and 3 pending
|
|
637
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
638
|
-
const result = await run(host.execute, { action: "update", id: 4, status: "done" });
|
|
639
|
-
const out = text(result);
|
|
640
|
-
expect(out).toContain("\u26a0 Earlier items still incomplete");
|
|
641
|
-
expect(out).toContain("#2 (b)");
|
|
642
|
-
expect(out).toContain("#3 (c)");
|
|
643
|
-
expect(out).toContain("Mark each done or blocked before proceeding");
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
test("no warning when all earlier items are done", async () => {
|
|
647
|
-
const host = makeHost();
|
|
648
|
-
registerTodo(host.pi);
|
|
649
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
650
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
651
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
652
|
-
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
653
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
654
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
test("no warning when marking the first item done", async () => {
|
|
658
|
-
const host = makeHost();
|
|
659
|
-
registerTodo(host.pi);
|
|
660
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
661
|
-
await run(host.execute, { action: "set", items: "a\nb" });
|
|
662
|
-
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
663
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
|
|
667
|
-
const host = makeHost();
|
|
668
|
-
registerTodo(host.pi);
|
|
669
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
670
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
671
|
-
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
672
|
-
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
673
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
674
|
-
// blocked is an explicit decision, not incomplete — no warning
|
|
675
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
test("warns about in_progress items too (not just pending)", async () => {
|
|
679
|
-
const host = makeHost();
|
|
680
|
-
registerTodo(host.pi);
|
|
681
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
682
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
683
|
-
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
684
|
-
// Mark item 3 done while item 1 is still in_progress
|
|
685
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
686
|
-
const out = text(result);
|
|
687
|
-
expect(out).toContain("\u26a0");
|
|
688
|
-
expect(out).toContain("#1 (a)");
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
test("no skip-guard on in_progress (only on done)", async () => {
|
|
692
|
-
// in_progress uses cascade-close instead, which is different behavior
|
|
693
|
-
const host = makeHost();
|
|
694
|
-
registerTodo(host.pi);
|
|
695
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
696
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
697
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
698
|
-
// Should cascade-close, not warn
|
|
699
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
700
|
-
expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
|
|
701
|
-
expect(text(result)).toContain("\u25cf 2. b");
|
|
702
|
-
});
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
// ─── Unordered lists (ordered:false) ────────────────────────────────────
|
|
706
|
-
|
|
707
|
-
describe("unordered lists", () => {
|
|
708
|
-
test("ordered:false opening a later item does NOT cascade-close earlier ones", async () => {
|
|
709
|
-
const host = makeHost();
|
|
710
|
-
registerTodo(host.pi);
|
|
711
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
712
|
-
await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
|
|
713
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
714
|
-
// Earlier items stay pending — no silent completion.
|
|
715
|
-
expect(text(result)).toContain("\u25cb 1. a");
|
|
716
|
-
expect(text(result)).toContain("\u25cb 2. b");
|
|
717
|
-
expect(text(result)).toContain("\u25d0 3. c");
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
test("ordered:false marking a later item done does NOT warn about earlier ones", async () => {
|
|
721
|
-
const host = makeHost();
|
|
722
|
-
registerTodo(host.pi);
|
|
723
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
724
|
-
await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
|
|
725
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
726
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
test("defaults to ordered (cascade-close) when ordered omitted", async () => {
|
|
730
|
-
const host = makeHost();
|
|
731
|
-
registerTodo(host.pi);
|
|
732
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
733
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
734
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
735
|
-
expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("ordered flag persists and restores", async () => {
|
|
739
|
-
const host = makeHost();
|
|
740
|
-
registerTodo(host.pi);
|
|
741
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
742
|
-
host.appendCalls.length = 0;
|
|
743
|
-
await run(host.execute, { action: "set", items: "a\nb", ordered: false });
|
|
744
|
-
const data = host.appendCalls[0]?.data as { ordered?: boolean };
|
|
745
|
-
expect(data.ordered).toBe(false);
|
|
746
|
-
|
|
747
|
-
// Restore into a fresh host and confirm cascade stays disabled.
|
|
748
|
-
delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
749
|
-
const host2 = makeHost([
|
|
750
|
-
{
|
|
751
|
-
type: "custom",
|
|
752
|
-
customType: "todo-state",
|
|
753
|
-
data: {
|
|
754
|
-
todos: [
|
|
755
|
-
{ id: 1, text: "a", status: "pending" },
|
|
756
|
-
{ id: 2, text: "b", status: "pending" },
|
|
757
|
-
],
|
|
758
|
-
nextTodoId: 3,
|
|
759
|
-
ordered: false,
|
|
760
|
-
},
|
|
761
|
-
},
|
|
762
|
-
]);
|
|
763
|
-
registerTodo(host2.pi);
|
|
764
|
-
await host2.emit("session_start", {}, { sessionManager: host2.sessionManager });
|
|
765
|
-
const result = await run(host2.execute, { action: "update", id: 2, status: "in_progress" });
|
|
766
|
-
expect(text(result)).toContain("\u25cb 1. a"); // NOT cascade-closed
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
// ─── Turn-based reminder ────────────────────────────────────────────────────────────
|
|
771
|
-
|
|
772
|
-
describe("turn-based todo reminder", () => {
|
|
773
|
-
test("injects reminder every 10 turns when incomplete items exist", async () => {
|
|
774
|
-
const host = makeHost();
|
|
775
|
-
registerTodo(host.pi);
|
|
776
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
777
|
-
await run(host.execute, { action: "set", items: "a\nb" });
|
|
778
|
-
|
|
779
|
-
// Simulate 10 turns — only the 10th should inject
|
|
780
|
-
for (let i = 1; i <= 9; i++) {
|
|
781
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
782
|
-
// before_agent_start returns undefined when no injection
|
|
783
|
-
expect(result).toBeUndefined();
|
|
784
|
-
}
|
|
785
|
-
// 10th turn should inject
|
|
786
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
787
|
-
expect(result).toBeDefined();
|
|
788
|
-
const prompt = (result as { systemPrompt: string }).systemPrompt;
|
|
789
|
-
expect(prompt).toContain("base");
|
|
790
|
-
expect(prompt).toContain("Todo reminder");
|
|
791
|
-
expect(prompt).toContain("1. a");
|
|
792
|
-
expect(prompt).toContain("2. b");
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
test("does not inject when no todos exist", async () => {
|
|
796
|
-
const host = makeHost();
|
|
797
|
-
registerTodo(host.pi);
|
|
798
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
799
|
-
|
|
800
|
-
for (let i = 1; i <= 10; i++) {
|
|
801
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
802
|
-
expect(result).toBeUndefined();
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
test("does not inject when all items are done", async () => {
|
|
807
|
-
const host = makeHost();
|
|
808
|
-
registerTodo(host.pi);
|
|
809
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
810
|
-
await run(host.execute, { action: "set", items: "a" });
|
|
811
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
812
|
-
|
|
813
|
-
for (let i = 1; i <= 10; i++) {
|
|
814
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
815
|
-
expect(result).toBeUndefined();
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
describe("renderTodoLines (colored TUI render)", () => {
|
|
821
|
-
const items: TodoItem[] = [
|
|
822
|
-
{ id: 1, text: "alpha", status: "done" },
|
|
823
|
-
{ id: 2, text: "bravo", status: "in_progress" },
|
|
824
|
-
{ id: 3, text: "charlie", status: "pending" },
|
|
825
|
-
{ id: 4, text: "delta", status: "blocked" },
|
|
826
|
-
];
|
|
827
|
-
|
|
828
|
-
test("empty list renders muted placeholder", () => {
|
|
829
|
-
expect(renderTodoLines([], tagTheme)).toBe("[muted](no todos)[/]");
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
test("colors each card cell by status (glyph + body one unit)", () => {
|
|
833
|
-
const out = renderTodoLines(items, tagTheme);
|
|
834
|
-
// Cards are padded cells colored as a whole; assert the tint wraps each card
|
|
835
|
-
// body (glyph-agnostic — glyph codepoint comes from the icon catalog/mode).
|
|
836
|
-
expect(out).toMatch(/\[success\][^\n]*alpha/); // done card success
|
|
837
|
-
expect(out).toMatch(/<b>\[accent\][^\n]*bravo[^\n]*<\/b>/); // in_progress bold+accent
|
|
838
|
-
expect(out).toMatch(/\[text\][^\n]*charlie/); // pending card white (text)
|
|
839
|
-
expect(out).toMatch(/\[error\][^\n]*delta/); // blocked card error
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
test("renders a header row with one column per status", () => {
|
|
843
|
-
const out = renderTodoLines(items, tagTheme);
|
|
844
|
-
expect(out).toContain("To Do (1)");
|
|
845
|
-
expect(out).toContain("In Progress (1)");
|
|
846
|
-
expect(out).toContain("Blocked (1)");
|
|
847
|
-
expect(out).toContain("Done (1)");
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
describe("todo card layout", () => {
|
|
852
|
-
test("uses the self-rendered shell so the compact checkmark has no leading space", () => {
|
|
853
|
-
const host = makeHost();
|
|
854
|
-
registerTodo(host.pi);
|
|
855
|
-
expect(host.renderShell).toBe("self");
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
test("shows the todo <action> title while open, empty once collapsed", () => {
|
|
859
|
-
const host = makeHost();
|
|
860
|
-
registerTodo(host.pi);
|
|
861
|
-
// Open card: title + action visible.
|
|
862
|
-
const open = host.renderCall({ action: "set" }, tagTheme, { state: {}, expanded: false });
|
|
863
|
-
expect(open.render(80).join("\n")).toContain("todo");
|
|
864
|
-
expect(open.render(80).join("\n")).toContain("set");
|
|
865
|
-
// Collapsed card: call row hides so the summary row is the only line.
|
|
866
|
-
const closed = host.renderCall({ action: "set" }, tagTheme, {
|
|
867
|
-
state: { collapsed: true },
|
|
868
|
-
expanded: false,
|
|
869
|
-
});
|
|
870
|
-
expect(closed.render(80).join("\n")).toBe("");
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("expanded mode restores a collapsed checklist", async () => {
|
|
874
|
-
const host = makeHost();
|
|
875
|
-
registerTodo(host.pi);
|
|
876
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
877
|
-
const result = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
878
|
-
const rendered = host
|
|
879
|
-
.render(result, { expanded: true }, tagTheme, {
|
|
880
|
-
state: { collapsed: true },
|
|
881
|
-
invalidate: () => {},
|
|
882
|
-
})
|
|
883
|
-
.render(80)
|
|
884
|
-
.join("\n");
|
|
885
|
-
|
|
886
|
-
expect(rendered).toContain("alpha");
|
|
887
|
-
expect(rendered).toContain("[text]○ alpha"); // pending card, no id number
|
|
888
|
-
expect(rendered).not.toContain("[success]✓ [/] [toolTitle]<b>todo</b>[/]");
|
|
889
|
-
expect(rendered.split("\n")[0]).toBe(`[success]${"─".repeat(80)}[/]`);
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("opening a new todo card collapses the previously open one", async () => {
|
|
893
|
-
const host = makeHost();
|
|
894
|
-
registerTodo(host.pi);
|
|
895
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
896
|
-
const r1 = await run(host.execute, { action: "set", items: "a" });
|
|
897
|
-
const r2 = await run(host.execute, { action: "add", items: "b" });
|
|
898
|
-
|
|
899
|
-
// Card 1 renders open (not yet collapsed).
|
|
900
|
-
let invalidated1 = false;
|
|
901
|
-
const state1 = { collapsed: false } as { collapsed?: boolean };
|
|
902
|
-
host.render(r1, { expanded: false }, tagTheme, {
|
|
903
|
-
state: state1,
|
|
904
|
-
invalidate: () => {
|
|
905
|
-
invalidated1 = true;
|
|
906
|
-
},
|
|
907
|
-
});
|
|
908
|
-
expect(state1.collapsed).toBe(false);
|
|
909
|
-
|
|
910
|
-
// Card 2 renders open → it claims focus and collapses card 1.
|
|
911
|
-
const state2 = { collapsed: false } as { collapsed?: boolean };
|
|
912
|
-
host.render(r2, { expanded: false }, tagTheme, {
|
|
913
|
-
state: state2,
|
|
914
|
-
invalidate: () => {},
|
|
915
|
-
});
|
|
916
|
-
expect(state1.collapsed).toBe(true); // previous card force-collapsed
|
|
917
|
-
expect(invalidated1).toBe(true); // and re-rendered
|
|
918
|
-
expect(state2.collapsed).toBe(false); // newest stays open
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
test("failed todo actions render their exact error", async () => {
|
|
922
|
-
const host = makeHost();
|
|
923
|
-
registerTodo(host.pi);
|
|
924
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
925
|
-
const result = await run(host.execute, { action: "update", id: 99, status: "done" });
|
|
926
|
-
const rendered = host
|
|
927
|
-
.render(result, { expanded: false }, tagTheme, {
|
|
928
|
-
state: { collapsed: true },
|
|
929
|
-
invalidate: () => {},
|
|
930
|
-
})
|
|
931
|
-
.render(80)
|
|
932
|
-
.join("\n")
|
|
933
|
-
.trimEnd();
|
|
934
|
-
|
|
935
|
-
expect(rendered.split("\n")[0]).toBe(`[error]${"─".repeat(80)}[/]`);
|
|
936
|
-
expect(rendered).toContain("No todo with id 99.");
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
test("a collapsed result is exactly one shared-style line", async () => {
|
|
940
|
-
const host = makeHost();
|
|
941
|
-
registerTodo(host.pi);
|
|
942
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
943
|
-
await run(host.execute, { action: "set", items: "foundation\ntodo renderer" });
|
|
944
|
-
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
945
|
-
const activeResult = await run(host.execute, {
|
|
946
|
-
action: "update",
|
|
947
|
-
id: 2,
|
|
948
|
-
status: "in_progress",
|
|
949
|
-
});
|
|
950
|
-
const lines = host
|
|
951
|
-
.render(activeResult, { expanded: false }, tagTheme, {
|
|
952
|
-
state: { collapsed: true },
|
|
953
|
-
invalidate: () => {},
|
|
954
|
-
})
|
|
955
|
-
.render(120);
|
|
956
|
-
|
|
957
|
-
expect(result.details).toBeDefined();
|
|
958
|
-
expect(lines).toHaveLength(1);
|
|
959
|
-
expect(lines[0]).not.toContain("────");
|
|
960
|
-
expect(lines[0]?.trimEnd()).toBe(
|
|
961
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]#2 todo renderer[/] [muted]·[/] [muted]1/2 done[/]",
|
|
962
|
-
);
|
|
963
|
-
});
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
describe("renderResult snapshot isolation", () => {
|
|
967
|
-
test("successful results carry immutable snapshots", async () => {
|
|
968
|
-
const host = makeHost();
|
|
969
|
-
registerTodo(host.pi);
|
|
970
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
971
|
-
const original = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
972
|
-
await run(host.execute, { action: "set", items: "changed" });
|
|
973
|
-
|
|
974
|
-
const details = original.details as { snapshot: TodoItem[] };
|
|
975
|
-
expect(details.snapshot.map((item) => item.text)).toEqual(["alpha", "bravo"]);
|
|
976
|
-
const rendered = host
|
|
977
|
-
.render(original, { expanded: true }, tagTheme, {
|
|
978
|
-
state: {},
|
|
979
|
-
invalidate: () => {},
|
|
980
|
-
})
|
|
981
|
-
.render(80)
|
|
982
|
-
.join("\n");
|
|
983
|
-
expect(rendered).toContain("alpha");
|
|
984
|
-
expect(rendered).toContain("bravo");
|
|
985
|
-
expect(rendered).not.toContain("changed");
|
|
986
|
-
});
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
describe("renderTodoSummaryLine (collapsed one-liner)", () => {
|
|
990
|
-
test("empty list renders a compact tool row", () => {
|
|
991
|
-
expect(renderTodoSummaryLine([], tagTheme)).toBe(
|
|
992
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]empty[/]",
|
|
993
|
-
);
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("uses warning status when work is blocked", () => {
|
|
997
|
-
const items: TodoItem[] = [{ id: 1, text: "Need approval", status: "blocked" }];
|
|
998
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
999
|
-
"[warning]⚠ [/] [toolTitle]<b>todo</b>[/] [dim]checklist[/] [muted]·[/] [muted]0/1 done · 1 blocked[/]",
|
|
1000
|
-
);
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
test("renders active work and progress in one row", () => {
|
|
1004
|
-
const items: TodoItem[] = [
|
|
1005
|
-
{ id: 1, text: "a", status: "done" },
|
|
1006
|
-
{ id: 2, text: "b", status: "in_progress" },
|
|
1007
|
-
];
|
|
1008
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
1009
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]#2 b[/] [muted]·[/] [muted]1/2 done[/]",
|
|
1010
|
-
);
|
|
1011
|
-
});
|
|
1012
|
-
});
|