@xynogen/pix-todo 0.1.5 → 0.1.6

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.6",
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,28 @@ 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
+
317
360
  test("update unknown id returns error", async () => {
318
361
  const host = makeHost();
319
362
  registerTodo(host.pi);
@@ -652,3 +695,51 @@ describe("renderTodoLines (colored TUI render)", () => {
652
695
  expect(out).toContain("[muted]Todos 1/4 done:[/]");
653
696
  });
654
697
  });
698
+
699
+ describe("renderResult snapshot isolation", () => {
700
+ // The card snapshots `todos` on first render; later execute() mutations must
701
+ // NOT bleed into an already-rendered card. Guards the invariant the inline
702
+ // comment defends.
703
+ test("a rendered card keeps its state after todos mutate", async () => {
704
+ const host = makeHost();
705
+ registerTodo(host.pi);
706
+ await host.emit(
707
+ "session_start",
708
+ {},
709
+ { sessionManager: host.sessionManager },
710
+ );
711
+ await run(host.execute, { action: "set", items: "alpha\nbravo" });
712
+
713
+ // Render once with a per-row state bag; snapshot is taken here.
714
+ const state: Record<string, unknown> = {};
715
+ const ctx = { state, invalidate: () => {} };
716
+ const first = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
717
+ expect(first).toContain("1. alpha");
718
+ expect(first).toContain("2. bravo");
719
+
720
+ // Mutate underlying todos via a new execute call.
721
+ await run(host.execute, { action: "set", items: "changed" });
722
+
723
+ // Re-render the SAME row (same state bag) — must still show the snapshot.
724
+ const second = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
725
+ expect(second).toContain("1. alpha");
726
+ expect(second).toContain("2. bravo");
727
+ expect(second).not.toContain("changed");
728
+ });
729
+ });
730
+
731
+ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
732
+ test("empty list renders muted placeholder", () => {
733
+ expect(renderTodoSummaryLine([], tagTheme)).toBe("[muted](no todos)[/]");
734
+ });
735
+
736
+ test("renders single dim done/total line with check", () => {
737
+ const items: TodoItem[] = [
738
+ { id: 1, text: "a", status: "done" },
739
+ { id: 2, text: "b", status: "pending" },
740
+ ];
741
+ expect(renderTodoSummaryLine(items, tagTheme)).toBe(
742
+ "[muted]Todos 1/2 done ✓[/]",
743
+ );
744
+ });
745
+ });
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 the previous in_progress item, so just open the next.",
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,15 @@ 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
+ // Single-active invariant: opening a new task closes any other
219
+ // in_progress one, so the checklist always shows one focus.
220
+ if (params.status === "in_progress")
221
+ for (const other of todos)
222
+ if (other.id !== t.id && other.status === "in_progress")
223
+ other.status = "done";
224
+ t.status = params.status;
225
+ }
187
226
  if (params.text) t.text = params.text;
188
227
  persistTodos();
189
228
  return ok(todoSummary());