@xynogen/pix-todo 0.1.13 → 0.1.15
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 +3 -1
- package/src/todo.test.ts +102 -23
- package/src/todo.ts +54 -78
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.15",
|
|
4
4
|
"description": "Pi tool — durable execution checklist (todo)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@xynogen/pix-data": "^0.3.2",
|
|
38
|
+
"@xynogen/pix-pretty": "^1.7.20",
|
|
37
39
|
"typebox": "^1.1.38"
|
|
38
40
|
},
|
|
39
41
|
"peerDependencies": {
|
package/src/todo.test.ts
CHANGED
|
@@ -33,6 +33,7 @@ function makeHost(
|
|
|
33
33
|
params: Record<string, unknown>,
|
|
34
34
|
) => Promise<{
|
|
35
35
|
content: Array<{ type: string; text: string }>;
|
|
36
|
+
details?: unknown;
|
|
36
37
|
isError?: boolean;
|
|
37
38
|
}>)
|
|
38
39
|
| null = null;
|
|
@@ -47,16 +48,21 @@ function makeHost(
|
|
|
47
48
|
context: unknown,
|
|
48
49
|
) => { render(width: number): string[] })
|
|
49
50
|
| null = null;
|
|
51
|
+
let capturedRenderCall:
|
|
52
|
+
| ((args: unknown, theme: unknown, context: unknown) => { render(width: number): string[] })
|
|
53
|
+
| null = null;
|
|
50
54
|
|
|
51
55
|
const pi = {
|
|
52
56
|
registerTool(def: {
|
|
53
57
|
name: string;
|
|
54
58
|
parameters: unknown;
|
|
55
59
|
execute: typeof capturedExecute;
|
|
60
|
+
renderCall?: typeof capturedRenderCall;
|
|
56
61
|
renderResult?: typeof capturedRender;
|
|
57
62
|
}) {
|
|
58
63
|
capturedParameters = def.parameters;
|
|
59
64
|
capturedExecute = def.execute;
|
|
65
|
+
if (def.renderCall) capturedRenderCall = def.renderCall;
|
|
60
66
|
if (def.renderResult) capturedRender = def.renderResult;
|
|
61
67
|
},
|
|
62
68
|
appendEntry(type: string, data: unknown) {
|
|
@@ -93,6 +99,10 @@ function makeHost(
|
|
|
93
99
|
if (!capturedExecute) throw new Error("execute not captured");
|
|
94
100
|
return capturedExecute;
|
|
95
101
|
},
|
|
102
|
+
get renderCall() {
|
|
103
|
+
if (!capturedRenderCall) throw new Error("renderCall not captured");
|
|
104
|
+
return capturedRenderCall;
|
|
105
|
+
},
|
|
96
106
|
get render() {
|
|
97
107
|
if (!capturedRender) throw new Error("render not captured");
|
|
98
108
|
return capturedRender;
|
|
@@ -115,6 +125,7 @@ async function run(
|
|
|
115
125
|
params: Record<string, unknown>,
|
|
116
126
|
) => Promise<{
|
|
117
127
|
content: Array<{ type: string; text: string }>;
|
|
128
|
+
details?: unknown;
|
|
118
129
|
isError?: boolean;
|
|
119
130
|
}>,
|
|
120
131
|
params: Record<string, unknown>,
|
|
@@ -772,44 +783,112 @@ describe("renderTodoLines (colored TUI render)", () => {
|
|
|
772
783
|
});
|
|
773
784
|
});
|
|
774
785
|
|
|
775
|
-
describe("
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
786
|
+
describe("todo card layout", () => {
|
|
787
|
+
test("keeps the call row empty so the collapsed card is one line", () => {
|
|
788
|
+
const host = makeHost();
|
|
789
|
+
registerTodo(host.pi);
|
|
790
|
+
const call = host.renderCall({ action: "list" }, tagTheme, {});
|
|
791
|
+
expect(call.render(80).join("\n")).toBe("");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("expanded mode restores a collapsed checklist", async () => {
|
|
780
795
|
const host = makeHost();
|
|
781
796
|
registerTodo(host.pi);
|
|
782
797
|
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
783
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
798
|
+
const result = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
799
|
+
const rendered = host
|
|
800
|
+
.render(result, { expanded: true }, tagTheme, {
|
|
801
|
+
state: { collapsed: true },
|
|
802
|
+
invalidate: () => {},
|
|
803
|
+
})
|
|
804
|
+
.render(80)
|
|
805
|
+
.join("\n");
|
|
784
806
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
807
|
+
expect(rendered).toContain("1. alpha");
|
|
808
|
+
expect(rendered).toContain("[muted]○[/]");
|
|
809
|
+
expect(rendered).not.toContain("[success]✓[/] [toolTitle]<b>todo</b>[/]");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("failed todo actions render their exact error", async () => {
|
|
813
|
+
const host = makeHost();
|
|
814
|
+
registerTodo(host.pi);
|
|
815
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
816
|
+
const result = await run(host.execute, { action: "update", id: 99, status: "done" });
|
|
817
|
+
const rendered = host
|
|
818
|
+
.render(result, { expanded: false }, tagTheme, {
|
|
819
|
+
state: { collapsed: true },
|
|
820
|
+
invalidate: () => {},
|
|
821
|
+
})
|
|
822
|
+
.render(80)
|
|
823
|
+
.join("\n")
|
|
824
|
+
.trimEnd();
|
|
825
|
+
|
|
826
|
+
expect(rendered).toBe("No todo with id 99.");
|
|
827
|
+
});
|
|
791
828
|
|
|
792
|
-
|
|
829
|
+
test("a collapsed result is exactly one shared-style line", async () => {
|
|
830
|
+
const host = makeHost();
|
|
831
|
+
registerTodo(host.pi);
|
|
832
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
833
|
+
await run(host.execute, { action: "set", items: "foundation\ntodo renderer" });
|
|
834
|
+
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
835
|
+
const activeResult = await run(host.execute, {
|
|
836
|
+
action: "update",
|
|
837
|
+
id: 2,
|
|
838
|
+
status: "in_progress",
|
|
839
|
+
});
|
|
840
|
+
const lines = host
|
|
841
|
+
.render(activeResult, { expanded: false }, tagTheme, {
|
|
842
|
+
state: { collapsed: true },
|
|
843
|
+
invalidate: () => {},
|
|
844
|
+
})
|
|
845
|
+
.render(120);
|
|
846
|
+
|
|
847
|
+
expect(result.details).toBeDefined();
|
|
848
|
+
expect(lines).toHaveLength(1);
|
|
849
|
+
expect(lines[0]?.trimEnd()).toBe(
|
|
850
|
+
"[success]✓[/] [toolTitle]<b>todo</b>[/] [muted]#2 todo renderer[/] [dim]·[/] [dim]1/2 done[/]",
|
|
851
|
+
);
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
describe("renderResult snapshot isolation", () => {
|
|
856
|
+
test("successful results carry immutable snapshots", async () => {
|
|
857
|
+
const host = makeHost();
|
|
858
|
+
registerTodo(host.pi);
|
|
859
|
+
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
860
|
+
const original = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
793
861
|
await run(host.execute, { action: "set", items: "changed" });
|
|
794
862
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
863
|
+
const details = original.details as { snapshot: TodoItem[] };
|
|
864
|
+
expect(details.snapshot.map((item) => item.text)).toEqual(["alpha", "bravo"]);
|
|
865
|
+
const rendered = host
|
|
866
|
+
.render(original, { expanded: true }, tagTheme, {
|
|
867
|
+
state: {},
|
|
868
|
+
invalidate: () => {},
|
|
869
|
+
})
|
|
870
|
+
.render(80)
|
|
871
|
+
.join("\n");
|
|
872
|
+
expect(rendered).toContain("1. alpha");
|
|
873
|
+
expect(rendered).toContain("2. bravo");
|
|
874
|
+
expect(rendered).not.toContain("changed");
|
|
800
875
|
});
|
|
801
876
|
});
|
|
802
877
|
|
|
803
878
|
describe("renderTodoSummaryLine (collapsed one-liner)", () => {
|
|
804
|
-
test("empty list renders
|
|
805
|
-
expect(renderTodoSummaryLine([], tagTheme)).toBe(
|
|
879
|
+
test("empty list renders a compact tool row", () => {
|
|
880
|
+
expect(renderTodoSummaryLine([], tagTheme)).toBe(
|
|
881
|
+
"[success]✓[/] [toolTitle]<b>todo</b>[/] [muted]empty[/]",
|
|
882
|
+
);
|
|
806
883
|
});
|
|
807
884
|
|
|
808
|
-
test("renders
|
|
885
|
+
test("renders active work and progress in one row", () => {
|
|
809
886
|
const items: TodoItem[] = [
|
|
810
887
|
{ id: 1, text: "a", status: "done" },
|
|
811
|
-
{ id: 2, text: "b", status: "
|
|
888
|
+
{ id: 2, text: "b", status: "in_progress" },
|
|
812
889
|
];
|
|
813
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
890
|
+
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
891
|
+
"[success]✓[/] [toolTitle]<b>todo</b>[/] [muted]#2 b[/] [dim]·[/] [dim]1/2 done[/]",
|
|
892
|
+
);
|
|
814
893
|
});
|
|
815
894
|
});
|
package/src/todo.ts
CHANGED
|
@@ -9,66 +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";
|
|
15
|
+
import { formatCollapsedToolRow } from "@xynogen/pix-pretty/utils";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
|
|
18
18
|
import { once } from "./once.ts";
|
|
19
19
|
|
|
20
|
-
// ── Collapse config from ~/.pi/agent/pix.json ────────────────────────────────
|
|
21
|
-
|
|
22
|
-
interface CollapseConf {
|
|
23
|
-
enabled: boolean;
|
|
24
|
-
delaySec: number;
|
|
25
|
-
tools: Record<string, boolean | undefined>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const DEFAULT_COLLAPSE: CollapseConf = {
|
|
29
|
-
enabled: true,
|
|
30
|
-
delaySec: 10,
|
|
31
|
-
tools: {},
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
function readCollapseConfig(): CollapseConf {
|
|
35
|
-
try {
|
|
36
|
-
const home = process.env.HOME ?? "";
|
|
37
|
-
if (!home) return DEFAULT_COLLAPSE;
|
|
38
|
-
const p = join(home, ".pi/agent", "pix.json");
|
|
39
|
-
if (!existsSync(p)) return DEFAULT_COLLAPSE;
|
|
40
|
-
const raw = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
|
|
41
|
-
const c = raw?.collapse as Record<string, unknown> | undefined;
|
|
42
|
-
if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
|
|
43
|
-
return {
|
|
44
|
-
enabled: typeof c.enabled === "boolean" ? c.enabled : true,
|
|
45
|
-
delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
|
|
46
|
-
tools:
|
|
47
|
-
c.tools && typeof c.tools === "object"
|
|
48
|
-
? (c.tools as Record<string, boolean | undefined>)
|
|
49
|
-
: {},
|
|
50
|
-
};
|
|
51
|
-
} catch {
|
|
52
|
-
return DEFAULT_COLLAPSE;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let collapseConf: CollapseConf | null = null;
|
|
57
|
-
function getCollapseConfig(): CollapseConf {
|
|
58
|
-
if (!collapseConf) collapseConf = readCollapseConfig();
|
|
59
|
-
return collapseConf;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function shouldCollapseTodo(): boolean {
|
|
63
|
-
const c = getCollapseConfig();
|
|
64
|
-
const perTool = c.tools.todo;
|
|
65
|
-
return typeof perTool === "boolean" ? perTool : c.enabled;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function collapseDelayMs(): number {
|
|
69
|
-
return getCollapseConfig().delaySec * 1000;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
20
|
export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
|
|
73
21
|
|
|
74
22
|
export interface TodoItem {
|
|
@@ -77,6 +25,15 @@ export interface TodoItem {
|
|
|
77
25
|
status: TodoStatus;
|
|
78
26
|
}
|
|
79
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
|
+
|
|
80
37
|
const TODO_GLYPH: Record<TodoStatus, string> = {
|
|
81
38
|
pending: "○",
|
|
82
39
|
in_progress: "◐",
|
|
@@ -99,9 +56,19 @@ export type TodoTheme = {
|
|
|
99
56
|
|
|
100
57
|
/** One-line dim summary used once a card has collapsed. */
|
|
101
58
|
export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
|
|
102
|
-
if (!items.length) return theme
|
|
59
|
+
if (!items.length) return formatCollapsedToolRow(theme, "todo", "empty");
|
|
103
60
|
const done = items.filter((t) => t.status === "done").length;
|
|
104
|
-
|
|
61
|
+
const active = items.find((t) => t.status === "in_progress");
|
|
62
|
+
const blocked = items.filter((t) => t.status === "blocked").length;
|
|
63
|
+
const meta = [`${done}/${items.length} done`, blocked > 0 ? `${blocked} blocked` : ""]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(" · ");
|
|
66
|
+
const target = active
|
|
67
|
+
? `#${active.id} ${active.text}`
|
|
68
|
+
: done === items.length
|
|
69
|
+
? "complete"
|
|
70
|
+
: "checklist";
|
|
71
|
+
return formatCollapsedToolRow(theme, "todo", target, meta, "success");
|
|
105
72
|
}
|
|
106
73
|
|
|
107
74
|
/** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
|
|
@@ -202,37 +169,46 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
202
169
|
}),
|
|
203
170
|
),
|
|
204
171
|
}),
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
state.collapsed = true;
|
|
219
|
-
context.invalidate();
|
|
220
|
-
}, collapseDelayMs());
|
|
172
|
+
// The result already owns the checklist and its collapsed `✓ todo …` row.
|
|
173
|
+
// Keeping the call renderer empty prevents a duplicate standalone header.
|
|
174
|
+
renderCall() {
|
|
175
|
+
return new Text("", 0, 0);
|
|
176
|
+
},
|
|
177
|
+
renderResult(result, options, theme, context) {
|
|
178
|
+
const details = result.details as TodoResultDetails | undefined;
|
|
179
|
+
const resultText = result.content
|
|
180
|
+
.filter((part) => part.type === "text")
|
|
181
|
+
.map((part) => part.text)
|
|
182
|
+
.join("\n");
|
|
183
|
+
if (context.isError || details?.outcome === "error" || !details) {
|
|
184
|
+
return new Text(resultText, 0, 0);
|
|
221
185
|
}
|
|
222
|
-
|
|
223
|
-
|
|
186
|
+
|
|
187
|
+
const collapsed = tickCollapse(
|
|
188
|
+
"todo",
|
|
189
|
+
context.state as CollapseState,
|
|
190
|
+
context.invalidate,
|
|
191
|
+
options.expanded,
|
|
192
|
+
);
|
|
193
|
+
const render = collapsed ? renderTodoSummaryLine : renderTodoLines;
|
|
194
|
+
return new Text(render(details.snapshot, theme as TodoTheme), 0, 0);
|
|
224
195
|
},
|
|
225
196
|
|
|
226
197
|
async execute(_id, params) {
|
|
227
|
-
|
|
228
|
-
|
|
198
|
+
const action = params.action as TodoAction;
|
|
199
|
+
const details = (outcome: TodoResultDetails["outcome"]): TodoResultDetails => ({
|
|
200
|
+
_type: "todoResult",
|
|
201
|
+
action,
|
|
202
|
+
outcome,
|
|
203
|
+
snapshot: todos.map((item) => ({ ...item })),
|
|
204
|
+
});
|
|
229
205
|
const ok = (text: string) => ({
|
|
230
206
|
content: [{ type: "text" as const, text }],
|
|
231
|
-
details:
|
|
207
|
+
details: details("success"),
|
|
232
208
|
});
|
|
233
209
|
const fail = (text: string) => ({
|
|
234
210
|
content: [{ type: "text" as const, text }],
|
|
235
|
-
details:
|
|
211
|
+
details: details("error"),
|
|
236
212
|
isError: true,
|
|
237
213
|
});
|
|
238
214
|
switch (params.action) {
|