@xynogen/pix-todo 0.1.5 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/todo.test.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { beforeEach, describe, expect, test } from "bun:test";
2
- import registerTodo, { renderTodoLines, type TodoItem } from "./todo.ts";
2
+ import registerTodo, {
3
+ renderTodoLines,
4
+ renderTodoSummaryLine,
5
+ type TodoItem,
6
+ } from "./todo.ts";
3
7
 
4
8
  // registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
5
9
  // WeakMap guard that dedupes activation across pix-core + a standalone install.
@@ -41,9 +45,23 @@ function makeHost(
41
45
  Array<(event: unknown, ctx?: unknown) => unknown>
42
46
  > = {};
43
47
 
48
+ let capturedRender:
49
+ | ((
50
+ result: unknown,
51
+ options: unknown,
52
+ theme: unknown,
53
+ context: unknown,
54
+ ) => { render(width: number): string[] })
55
+ | null = null;
56
+
44
57
  const pi = {
45
- registerTool(def: { name: string; execute: typeof capturedExecute }) {
58
+ registerTool(def: {
59
+ name: string;
60
+ execute: typeof capturedExecute;
61
+ renderResult?: typeof capturedRender;
62
+ }) {
46
63
  capturedExecute = def.execute;
64
+ if (def.renderResult) capturedRender = def.renderResult;
47
65
  },
48
66
  appendEntry(type: string, data: unknown) {
49
67
  appendCalls.push({ type, data });
@@ -68,6 +86,9 @@ function makeHost(
68
86
  get execute() {
69
87
  return capturedExecute!;
70
88
  },
89
+ get render() {
90
+ return capturedRender!;
91
+ },
71
92
  appendCalls,
72
93
  async emit(ev: string, event?: unknown, ctx?: unknown) {
73
94
  for (const fn of handlers[ev] ?? []) await fn(event, ctx);
@@ -314,6 +335,72 @@ describe("todo actions", () => {
314
335
  expect(out).toContain("Todos 0/1 done");
315
336
  });
316
337
 
338
+ test("opening a new in_progress closes the previous one", async () => {
339
+ const host = makeHost();
340
+ registerTodo(host.pi);
341
+ await host.emit(
342
+ "session_start",
343
+ {},
344
+ { sessionManager: host.sessionManager },
345
+ );
346
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
347
+ await run(host.execute, { action: "update", id: 1, status: "in_progress" });
348
+ const result = await run(host.execute, {
349
+ action: "update",
350
+ id: 2,
351
+ status: "in_progress",
352
+ });
353
+ const out = text(result);
354
+ expect(out).toContain("● 1. a"); // auto-closed to done
355
+ expect(out).toContain("◐ 2. b"); // now active
356
+ expect(out).toContain("○ 3. c");
357
+ expect(out).toContain("Todos 1/3 done");
358
+ });
359
+
360
+ test("opening a later item cascade-closes skipped pending items", async () => {
361
+ const host = makeHost();
362
+ registerTodo(host.pi);
363
+ await host.emit(
364
+ "session_start",
365
+ {},
366
+ { sessionManager: host.sessionManager },
367
+ );
368
+ await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
369
+ // Jump straight to id 4 without opening 1-3; they should all auto-close.
370
+ const result = await run(host.execute, {
371
+ action: "update",
372
+ id: 4,
373
+ status: "in_progress",
374
+ });
375
+ const out = text(result);
376
+ expect(out).toContain("● 1. a");
377
+ expect(out).toContain("● 2. b");
378
+ expect(out).toContain("● 3. c");
379
+ expect(out).toContain("◐ 4. d");
380
+ expect(out).toContain("Todos 3/4 done");
381
+ });
382
+
383
+ test("cascade-close leaves a blocked earlier item untouched", async () => {
384
+ const host = makeHost();
385
+ registerTodo(host.pi);
386
+ await host.emit(
387
+ "session_start",
388
+ {},
389
+ { sessionManager: host.sessionManager },
390
+ );
391
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
392
+ await run(host.execute, { action: "update", id: 1, status: "blocked" });
393
+ const result = await run(host.execute, {
394
+ action: "update",
395
+ id: 3,
396
+ status: "in_progress",
397
+ });
398
+ const out = text(result);
399
+ expect(out).toContain("⊘ 1. a"); // still blocked, not force-closed
400
+ expect(out).toContain("● 2. b"); // pending -> done
401
+ expect(out).toContain("◐ 3. c");
402
+ });
403
+
317
404
  test("update unknown id returns error", async () => {
318
405
  const host = makeHost();
319
406
  registerTodo(host.pi);
@@ -396,8 +483,10 @@ describe("todo actions", () => {
396
483
  { sessionManager: host.sessionManager },
397
484
  );
398
485
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
399
- await run(host.execute, { action: "update", id: 1, status: "pending" });
486
+ // Open id 2 first (cascade-closes nothing earlier we assert on), then set
487
+ // the others directly so each glyph is exercised without cascade interfering.
400
488
  await run(host.execute, { action: "update", id: 2, status: "in_progress" });
489
+ await run(host.execute, { action: "update", id: 1, status: "pending" });
401
490
  await run(host.execute, { action: "update", id: 3, status: "done" });
402
491
  await run(host.execute, { action: "update", id: 4, status: "blocked" });
403
492
  const out = text(await run(host.execute, { action: "list" }));
@@ -652,3 +741,51 @@ describe("renderTodoLines (colored TUI render)", () => {
652
741
  expect(out).toContain("[muted]Todos 1/4 done:[/]");
653
742
  });
654
743
  });
744
+
745
+ describe("renderResult snapshot isolation", () => {
746
+ // The card snapshots `todos` on first render; later execute() mutations must
747
+ // NOT bleed into an already-rendered card. Guards the invariant the inline
748
+ // comment defends.
749
+ test("a rendered card keeps its state after todos mutate", async () => {
750
+ const host = makeHost();
751
+ registerTodo(host.pi);
752
+ await host.emit(
753
+ "session_start",
754
+ {},
755
+ { sessionManager: host.sessionManager },
756
+ );
757
+ await run(host.execute, { action: "set", items: "alpha\nbravo" });
758
+
759
+ // Render once with a per-row state bag; snapshot is taken here.
760
+ const state: Record<string, unknown> = {};
761
+ const ctx = { state, invalidate: () => {} };
762
+ const first = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
763
+ expect(first).toContain("1. alpha");
764
+ expect(first).toContain("2. bravo");
765
+
766
+ // Mutate underlying todos via a new execute call.
767
+ await run(host.execute, { action: "set", items: "changed" });
768
+
769
+ // Re-render the SAME row (same state bag) — must still show the snapshot.
770
+ const second = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
771
+ expect(second).toContain("1. alpha");
772
+ expect(second).toContain("2. bravo");
773
+ expect(second).not.toContain("changed");
774
+ });
775
+ });
776
+
777
+ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
778
+ test("empty list renders muted placeholder", () => {
779
+ expect(renderTodoSummaryLine([], tagTheme)).toBe("[muted](no todos)[/]");
780
+ });
781
+
782
+ test("renders single dim done/total line with check", () => {
783
+ const items: TodoItem[] = [
784
+ { id: 1, text: "a", status: "done" },
785
+ { id: 2, text: "b", status: "pending" },
786
+ ];
787
+ expect(renderTodoSummaryLine(items, tagTheme)).toBe(
788
+ "[muted]Todos 1/2 done ✓[/]",
789
+ );
790
+ });
791
+ });
package/src/todo.ts CHANGED
@@ -15,6 +15,9 @@ import { Type } from "typebox";
15
15
 
16
16
  import { once } from "./once.ts";
17
17
 
18
+ /** Seconds before a todo card collapses to a one-line dim summary. */
19
+ const COLLAPSE_AFTER_SEC = 10;
20
+
18
21
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
19
22
 
20
23
  export interface TodoItem {
@@ -43,6 +46,16 @@ export type TodoTheme = {
43
46
  bold: (text: string) => string;
44
47
  };
45
48
 
49
+ /** One-line dim summary used once a card has collapsed. */
50
+ export function renderTodoSummaryLine(
51
+ items: TodoItem[],
52
+ theme: TodoTheme,
53
+ ): string {
54
+ if (!items.length) return theme.fg("muted", "(no todos)");
55
+ const done = items.filter((t) => t.status === "done").length;
56
+ return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
57
+ }
58
+
46
59
  /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
47
60
  export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
48
61
  if (!items.length) return theme.fg("muted", "(no todos)");
@@ -98,7 +111,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
98
111
  "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
99
112
  promptGuidelines: [
100
113
  "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
101
- "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
114
+ "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.",
102
115
  "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
103
116
  ],
104
117
  parameters: Type.Object({
@@ -138,8 +151,26 @@ export default function registerTodo(pi: ExtensionAPI): void {
138
151
  }),
139
152
  ),
140
153
  }),
141
- renderResult(_result, _options, theme) {
142
- return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
154
+ renderResult(_result, _options, theme, context) {
155
+ // Snapshot this row's todos once (live `todos` mutate across calls;
156
+ // a card should keep the state it was created with).
157
+ const state = context.state as {
158
+ snapshot?: TodoItem[];
159
+ collapsed?: boolean;
160
+ timer?: ReturnType<typeof setTimeout>;
161
+ };
162
+ if (!state.snapshot) state.snapshot = todos.map((t) => ({ ...t }));
163
+ // Start the collapse timer once per row; invalidate() triggers rerender.
164
+ if (!state.collapsed && !state.timer) {
165
+ state.timer = setTimeout(() => {
166
+ state.collapsed = true;
167
+ context.invalidate();
168
+ }, COLLAPSE_AFTER_SEC * 1000);
169
+ }
170
+ const render = state.collapsed
171
+ ? renderTodoSummaryLine
172
+ : renderTodoLines;
173
+ return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
143
174
  },
144
175
 
145
176
  async execute(_id, params) {
@@ -183,7 +214,21 @@ export default function registerTodo(pi: ExtensionAPI): void {
183
214
  case "update": {
184
215
  const t = todos.find((x) => x.id === params.id);
185
216
  if (!t) return fail(`No todo with id ${params.id}.`);
186
- if (params.status) t.status = params.status;
217
+ if (params.status) {
218
+ // Sequential-progress invariant: opening a task means everything
219
+ // before it is finished. Cascade-close every earlier pending or
220
+ // in_progress item (ids are sequential) so the model never has to
221
+ // mark skipped steps done by hand. `blocked` is left untouched.
222
+ if (params.status === "in_progress")
223
+ for (const other of todos)
224
+ if (
225
+ other.id < t.id &&
226
+ (other.status === "pending" ||
227
+ other.status === "in_progress")
228
+ )
229
+ other.status = "done";
230
+ t.status = params.status;
231
+ }
187
232
  if (params.text) t.text = params.text;
188
233
  persistTodos();
189
234
  return ok(todoSummary());