@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.
Files changed (3) hide show
  1. package/package.json +2 -1
  2. package/src/todo.ts +103 -15
  3. 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.2",
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 item's status), clear.",
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
- "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.",
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 item by id; "clear" removes all.',
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
- id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
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
- const t = todos.find((x) => x.id === params.id);
346
- if (!t) return fail(`No todo with id ${params.id}.`);
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
- if (params.status) {
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 && params.status === "in_progress")
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
- if (ordered && params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
363
-
364
- t.status = params.status;
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
- return ok(todoSummary() + skipWarning);
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
- });