@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 +1 -1
- package/src/todo.test.ts +93 -2
- package/src/todo.ts +43 -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,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
|
|
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
|
-
|
|
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)
|
|
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());
|