@xynogen/pix-todo 0.1.14 → 0.1.16
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/README.md +2 -2
- package/package.json +2 -1
- package/src/todo.test.ts +91 -21
- package/src/todo.ts +42 -79
package/README.md
CHANGED
|
@@ -8,13 +8,13 @@ Registers the `todo` tool, which gives the agent a persistent task checklist tha
|
|
|
8
8
|
|
|
9
9
|
## Auto-collapse
|
|
10
10
|
|
|
11
|
-
The checklist card auto-collapses after a configurable delay (default 10 seconds,
|
|
11
|
+
The checklist card uses the shared `@xynogen/pix-data/collapse` state machine and auto-collapses after a configurable delay (default 10 seconds) to a row such as `✓ todo #2 release prep · 1/2 done`. Expanding an elapsed card restores the immutable checklist snapshot for that result, with its colored status glyphs, without restarting the timer. Failed actions keep their exact diagnostic instead of rendering a checklist. The delay and per-tool toggle are read from `~/.pi/agent/pix.json`:
|
|
12
12
|
|
|
13
13
|
```jsonc
|
|
14
14
|
{
|
|
15
15
|
"collapse": {
|
|
16
16
|
"enabled": true,
|
|
17
|
-
"
|
|
17
|
+
"delaySec": 10,
|
|
18
18
|
"tools": { "todo": true }
|
|
19
19
|
}
|
|
20
20
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-todo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Pi tool — durable execution checklist (todo)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@xynogen/pix-data": "^0.3.2",
|
|
37
38
|
"@xynogen/pix-pretty": "^1.7.20",
|
|
38
39
|
"typebox": "^1.1.38"
|
|
39
40
|
},
|
package/src/todo.test.ts
CHANGED
|
@@ -27,12 +27,14 @@ function makeHost(
|
|
|
27
27
|
}> = [],
|
|
28
28
|
) {
|
|
29
29
|
let capturedParameters: unknown;
|
|
30
|
+
let capturedRenderShell: unknown;
|
|
30
31
|
let capturedExecute:
|
|
31
32
|
| ((
|
|
32
33
|
id: string,
|
|
33
34
|
params: Record<string, unknown>,
|
|
34
35
|
) => Promise<{
|
|
35
36
|
content: Array<{ type: string; text: string }>;
|
|
37
|
+
details?: unknown;
|
|
36
38
|
isError?: boolean;
|
|
37
39
|
}>)
|
|
38
40
|
| null = null;
|
|
@@ -56,10 +58,12 @@ function makeHost(
|
|
|
56
58
|
name: string;
|
|
57
59
|
parameters: unknown;
|
|
58
60
|
execute: typeof capturedExecute;
|
|
61
|
+
renderShell?: unknown;
|
|
59
62
|
renderCall?: typeof capturedRenderCall;
|
|
60
63
|
renderResult?: typeof capturedRender;
|
|
61
64
|
}) {
|
|
62
65
|
capturedParameters = def.parameters;
|
|
66
|
+
capturedRenderShell = def.renderShell;
|
|
63
67
|
capturedExecute = def.execute;
|
|
64
68
|
if (def.renderCall) capturedRenderCall = def.renderCall;
|
|
65
69
|
if (def.renderResult) capturedRender = def.renderResult;
|
|
@@ -98,6 +102,9 @@ function makeHost(
|
|
|
98
102
|
if (!capturedExecute) throw new Error("execute not captured");
|
|
99
103
|
return capturedExecute;
|
|
100
104
|
},
|
|
105
|
+
get renderShell() {
|
|
106
|
+
return capturedRenderShell;
|
|
107
|
+
},
|
|
101
108
|
get renderCall() {
|
|
102
109
|
if (!capturedRenderCall) throw new Error("renderCall not captured");
|
|
103
110
|
return capturedRenderCall;
|
|
@@ -124,6 +131,7 @@ async function run(
|
|
|
124
131
|
params: Record<string, unknown>,
|
|
125
132
|
) => Promise<{
|
|
126
133
|
content: Array<{ type: string; text: string }>;
|
|
134
|
+
details?: unknown;
|
|
127
135
|
isError?: boolean;
|
|
128
136
|
}>,
|
|
129
137
|
params: Record<string, unknown>,
|
|
@@ -775,46 +783,108 @@ describe("renderTodoLines (colored TUI render)", () => {
|
|
|
775
783
|
expect(out).toContain("[text]3. charlie[/]"); // pending body text
|
|
776
784
|
});
|
|
777
785
|
|
|
778
|
-
test("shows the done/total count header", () => {
|
|
786
|
+
test("shows the done/total count header in blue", () => {
|
|
779
787
|
const out = renderTodoLines(items, tagTheme);
|
|
780
|
-
expect(out).toContain("[
|
|
788
|
+
expect(out).toContain("[accent]Todos 1/4 done:[/]");
|
|
789
|
+
expect(out).not.toContain("[muted]Todos 1/4 done:[/]");
|
|
781
790
|
});
|
|
782
791
|
});
|
|
783
792
|
|
|
784
793
|
describe("todo card layout", () => {
|
|
794
|
+
test("uses the self-rendered shell so the compact checkmark has no leading space", () => {
|
|
795
|
+
const host = makeHost();
|
|
796
|
+
registerTodo(host.pi);
|
|
797
|
+
expect(host.renderShell).toBe("self");
|
|
798
|
+
});
|
|
799
|
+
|
|
785
800
|
test("keeps the call row empty so the collapsed card is one line", () => {
|
|
786
801
|
const host = makeHost();
|
|
787
802
|
registerTodo(host.pi);
|
|
788
803
|
const call = host.renderCall({ action: "list" }, tagTheme, {});
|
|
789
804
|
expect(call.render(80).join("\n")).toBe("");
|
|
790
805
|
});
|
|
791
|
-
});
|
|
792
806
|
|
|
793
|
-
|
|
794
|
-
// The card snapshots `todos` on first render; later execute() mutations must
|
|
795
|
-
// NOT bleed into an already-rendered card. Guards the invariant the inline
|
|
796
|
-
// comment defends.
|
|
797
|
-
test("a rendered card keeps its state after todos mutate", async () => {
|
|
807
|
+
test("expanded mode restores a collapsed checklist", async () => {
|
|
798
808
|
const host = makeHost();
|
|
799
809
|
registerTodo(host.pi);
|
|
800
810
|
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
801
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
811
|
+
const result = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
812
|
+
const rendered = host
|
|
813
|
+
.render(result, { expanded: true }, tagTheme, {
|
|
814
|
+
state: { collapsed: true },
|
|
815
|
+
invalidate: () => {},
|
|
816
|
+
})
|
|
817
|
+
.render(80)
|
|
818
|
+
.join("\n");
|
|
802
819
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
820
|
+
expect(rendered).toContain("1. alpha");
|
|
821
|
+
expect(rendered).toContain("[muted]○[/]");
|
|
822
|
+
expect(rendered).not.toContain("[success]✓[/] [toolTitle]<b>todo</b>[/]");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("failed todo actions render their exact error", async () => {
|
|
826
|
+
const host = makeHost();
|
|
827
|
+
registerTodo(host.pi);
|
|
828
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
829
|
+
const result = await run(host.execute, { action: "update", id: 99, status: "done" });
|
|
830
|
+
const rendered = host
|
|
831
|
+
.render(result, { expanded: false }, tagTheme, {
|
|
832
|
+
state: { collapsed: true },
|
|
833
|
+
invalidate: () => {},
|
|
834
|
+
})
|
|
835
|
+
.render(80)
|
|
836
|
+
.join("\n")
|
|
837
|
+
.trimEnd();
|
|
838
|
+
|
|
839
|
+
expect(rendered).toBe("No todo with id 99.");
|
|
840
|
+
});
|
|
809
841
|
|
|
810
|
-
|
|
842
|
+
test("a collapsed result is exactly one shared-style line", async () => {
|
|
843
|
+
const host = makeHost();
|
|
844
|
+
registerTodo(host.pi);
|
|
845
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
846
|
+
await run(host.execute, { action: "set", items: "foundation\ntodo renderer" });
|
|
847
|
+
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
848
|
+
const activeResult = await run(host.execute, {
|
|
849
|
+
action: "update",
|
|
850
|
+
id: 2,
|
|
851
|
+
status: "in_progress",
|
|
852
|
+
});
|
|
853
|
+
const lines = host
|
|
854
|
+
.render(activeResult, { expanded: false }, tagTheme, {
|
|
855
|
+
state: { collapsed: true },
|
|
856
|
+
invalidate: () => {},
|
|
857
|
+
})
|
|
858
|
+
.render(120);
|
|
859
|
+
|
|
860
|
+
expect(result.details).toBeDefined();
|
|
861
|
+
expect(lines).toHaveLength(1);
|
|
862
|
+
expect(lines[0]?.trimEnd()).toBe(
|
|
863
|
+
"[success]✓[/] [toolTitle]<b>todo</b>[/] [muted]#2 todo renderer[/] [dim]·[/] [dim]1/2 done[/]",
|
|
864
|
+
);
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe("renderResult snapshot isolation", () => {
|
|
869
|
+
test("successful results carry immutable snapshots", async () => {
|
|
870
|
+
const host = makeHost();
|
|
871
|
+
registerTodo(host.pi);
|
|
872
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
873
|
+
const original = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
811
874
|
await run(host.execute, { action: "set", items: "changed" });
|
|
812
875
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
876
|
+
const details = original.details as { snapshot: TodoItem[] };
|
|
877
|
+
expect(details.snapshot.map((item) => item.text)).toEqual(["alpha", "bravo"]);
|
|
878
|
+
const rendered = host
|
|
879
|
+
.render(original, { expanded: true }, tagTheme, {
|
|
880
|
+
state: {},
|
|
881
|
+
invalidate: () => {},
|
|
882
|
+
})
|
|
883
|
+
.render(80)
|
|
884
|
+
.join("\n");
|
|
885
|
+
expect(rendered).toContain("1. alpha");
|
|
886
|
+
expect(rendered).toContain("2. bravo");
|
|
887
|
+
expect(rendered).not.toContain("changed");
|
|
818
888
|
});
|
|
819
889
|
});
|
|
820
890
|
|
package/src/todo.ts
CHANGED
|
@@ -9,67 +9,14 @@
|
|
|
9
9
|
* checklist is seeded by the model via the tool's `set` action.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
14
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-data/collapse";
|
|
16
15
|
import { formatCollapsedToolRow } from "@xynogen/pix-pretty/utils";
|
|
17
16
|
import { Type } from "typebox";
|
|
18
17
|
|
|
19
18
|
import { once } from "./once.ts";
|
|
20
19
|
|
|
21
|
-
// ── Collapse config from ~/.pi/agent/pix.json ────────────────────────────────
|
|
22
|
-
|
|
23
|
-
interface CollapseConf {
|
|
24
|
-
enabled: boolean;
|
|
25
|
-
delaySec: number;
|
|
26
|
-
tools: Record<string, boolean | undefined>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const DEFAULT_COLLAPSE: CollapseConf = {
|
|
30
|
-
enabled: true,
|
|
31
|
-
delaySec: 10,
|
|
32
|
-
tools: {},
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function readCollapseConfig(): CollapseConf {
|
|
36
|
-
try {
|
|
37
|
-
const home = process.env.HOME ?? "";
|
|
38
|
-
if (!home) return DEFAULT_COLLAPSE;
|
|
39
|
-
const p = join(home, ".pi/agent", "pix.json");
|
|
40
|
-
if (!existsSync(p)) return DEFAULT_COLLAPSE;
|
|
41
|
-
const raw = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
|
|
42
|
-
const c = raw?.collapse as Record<string, unknown> | undefined;
|
|
43
|
-
if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
|
|
44
|
-
return {
|
|
45
|
-
enabled: typeof c.enabled === "boolean" ? c.enabled : true,
|
|
46
|
-
delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
|
|
47
|
-
tools:
|
|
48
|
-
c.tools && typeof c.tools === "object"
|
|
49
|
-
? (c.tools as Record<string, boolean | undefined>)
|
|
50
|
-
: {},
|
|
51
|
-
};
|
|
52
|
-
} catch {
|
|
53
|
-
return DEFAULT_COLLAPSE;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let collapseConf: CollapseConf | null = null;
|
|
58
|
-
function getCollapseConfig(): CollapseConf {
|
|
59
|
-
if (!collapseConf) collapseConf = readCollapseConfig();
|
|
60
|
-
return collapseConf;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function shouldCollapseTodo(): boolean {
|
|
64
|
-
const c = getCollapseConfig();
|
|
65
|
-
const perTool = c.tools.todo;
|
|
66
|
-
return typeof perTool === "boolean" ? perTool : c.enabled;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function collapseDelayMs(): number {
|
|
70
|
-
return getCollapseConfig().delaySec * 1000;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
20
|
export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
|
|
74
21
|
|
|
75
22
|
export interface TodoItem {
|
|
@@ -78,6 +25,15 @@ export interface TodoItem {
|
|
|
78
25
|
status: TodoStatus;
|
|
79
26
|
}
|
|
80
27
|
|
|
28
|
+
type TodoAction = "list" | "set" | "add" | "update" | "clear";
|
|
29
|
+
|
|
30
|
+
interface TodoResultDetails {
|
|
31
|
+
_type: "todoResult";
|
|
32
|
+
action: TodoAction;
|
|
33
|
+
outcome: "success" | "error";
|
|
34
|
+
snapshot: TodoItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
81
37
|
const TODO_GLYPH: Record<TodoStatus, string> = {
|
|
82
38
|
pending: "○",
|
|
83
39
|
in_progress: "◐",
|
|
@@ -98,7 +54,7 @@ export type TodoTheme = {
|
|
|
98
54
|
bold: (text: string) => string;
|
|
99
55
|
};
|
|
100
56
|
|
|
101
|
-
/** One-line
|
|
57
|
+
/** One-line shared summary used once a card has collapsed. */
|
|
102
58
|
export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
|
|
103
59
|
if (!items.length) return formatCollapsedToolRow(theme, "todo", "empty");
|
|
104
60
|
const done = items.filter((t) => t.status === "done").length;
|
|
@@ -112,14 +68,14 @@ export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): stri
|
|
|
112
68
|
: done === items.length
|
|
113
69
|
? "complete"
|
|
114
70
|
: "checklist";
|
|
115
|
-
return formatCollapsedToolRow(theme, "todo", target, meta);
|
|
71
|
+
return formatCollapsedToolRow(theme, "todo", target, meta, "success");
|
|
116
72
|
}
|
|
117
73
|
|
|
118
74
|
/** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
|
|
119
75
|
export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
|
|
120
76
|
if (!items.length) return theme.fg("muted", "(no todos)");
|
|
121
77
|
const done = items.filter((t) => t.status === "done").length;
|
|
122
|
-
const head = theme.fg("
|
|
78
|
+
const head = theme.fg("accent", `Todos ${done}/${items.length} done:`);
|
|
123
79
|
const lines = items.map((t) => {
|
|
124
80
|
const color = TODO_COLOR[t.status];
|
|
125
81
|
const glyph = theme.fg(color, TODO_GLYPH[t.status]);
|
|
@@ -178,6 +134,9 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
178
134
|
pi.registerTool({
|
|
179
135
|
name: "todo",
|
|
180
136
|
label: "Todo",
|
|
137
|
+
// Avoid the default Box shell's one-cell x padding: Todo owns its compact
|
|
138
|
+
// result row and should align its status glyph with other compact tools.
|
|
139
|
+
renderShell: "self",
|
|
181
140
|
description:
|
|
182
141
|
"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.",
|
|
183
142
|
promptSnippet:
|
|
@@ -218,37 +177,41 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
218
177
|
renderCall() {
|
|
219
178
|
return new Text("", 0, 0);
|
|
220
179
|
},
|
|
221
|
-
renderResult(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!state.snapshot) state.snapshot = todos.map((t) => ({ ...t }));
|
|
230
|
-
// Start the collapse timer once per row; invalidate() triggers rerender.
|
|
231
|
-
// Config-driven: reads from ~/.pi/agent/pix.json collapse section.
|
|
232
|
-
if (shouldCollapseTodo() && !state.collapsed && !state.timer) {
|
|
233
|
-
state.timer = setTimeout(() => {
|
|
234
|
-
state.collapsed = true;
|
|
235
|
-
context.invalidate();
|
|
236
|
-
}, collapseDelayMs());
|
|
180
|
+
renderResult(result, options, theme, context) {
|
|
181
|
+
const details = result.details as TodoResultDetails | undefined;
|
|
182
|
+
const resultText = result.content
|
|
183
|
+
.filter((part) => part.type === "text")
|
|
184
|
+
.map((part) => part.text)
|
|
185
|
+
.join("\n");
|
|
186
|
+
if (context.isError || details?.outcome === "error" || !details) {
|
|
187
|
+
return new Text(resultText, 0, 0);
|
|
237
188
|
}
|
|
238
|
-
|
|
239
|
-
|
|
189
|
+
|
|
190
|
+
const collapsed = tickCollapse(
|
|
191
|
+
"todo",
|
|
192
|
+
context.state as CollapseState,
|
|
193
|
+
context.invalidate,
|
|
194
|
+
options.expanded,
|
|
195
|
+
);
|
|
196
|
+
const render = collapsed ? renderTodoSummaryLine : renderTodoLines;
|
|
197
|
+
return new Text(render(details.snapshot, theme as TodoTheme), 0, 0);
|
|
240
198
|
},
|
|
241
199
|
|
|
242
200
|
async execute(_id, params) {
|
|
243
|
-
|
|
244
|
-
|
|
201
|
+
const action = params.action as TodoAction;
|
|
202
|
+
const details = (outcome: TodoResultDetails["outcome"]): TodoResultDetails => ({
|
|
203
|
+
_type: "todoResult",
|
|
204
|
+
action,
|
|
205
|
+
outcome,
|
|
206
|
+
snapshot: todos.map((item) => ({ ...item })),
|
|
207
|
+
});
|
|
245
208
|
const ok = (text: string) => ({
|
|
246
209
|
content: [{ type: "text" as const, text }],
|
|
247
|
-
details:
|
|
210
|
+
details: details("success"),
|
|
248
211
|
});
|
|
249
212
|
const fail = (text: string) => ({
|
|
250
213
|
content: [{ type: "text" as const, text }],
|
|
251
|
-
details:
|
|
214
|
+
details: details("error"),
|
|
252
215
|
isError: true,
|
|
253
216
|
});
|
|
254
217
|
switch (params.action) {
|