@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 +1 -1
- package/src/todo.test.ts +140 -3
- package/src/todo.ts +49 -4
package/package.json
CHANGED
package/src/todo.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import registerTodo, {
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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());
|