@xynogen/pix-todo 0.1.14 → 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 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, previously hardcoded). The delay and the per-tool toggle are read from `~/.pi/agent/pix.json`:
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
- "delayMs": 10000,
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.14",
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,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
@@ -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;
@@ -124,6 +125,7 @@ async function run(
124
125
  params: Record<string, unknown>,
125
126
  ) => Promise<{
126
127
  content: Array<{ type: string; text: string }>;
128
+ details?: unknown;
127
129
  isError?: boolean;
128
130
  }>,
129
131
  params: Record<string, unknown>,
@@ -788,33 +790,88 @@ describe("todo card layout", () => {
788
790
  const call = host.renderCall({ action: "list" }, tagTheme, {});
789
791
  expect(call.render(80).join("\n")).toBe("");
790
792
  });
791
- });
792
793
 
793
- describe("renderResult snapshot isolation", () => {
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 () => {
794
+ test("expanded mode restores a collapsed checklist", async () => {
798
795
  const host = makeHost();
799
796
  registerTodo(host.pi);
800
797
  await host.emit("session_start", {}, { sessionManager: host.sessionManager });
801
- 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");
802
806
 
803
- // Render once with a per-row state bag; snapshot is taken here.
804
- const state: Record<string, unknown> = {};
805
- const ctx = { state, invalidate: () => {} };
806
- const first = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
807
- expect(first).toContain("1. alpha");
808
- expect(first).toContain("2. bravo");
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
+ });
828
+
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
+ });
809
854
 
810
- // Mutate underlying todos via a new execute call.
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" });
811
861
  await run(host.execute, { action: "set", items: "changed" });
812
862
 
813
- // Re-render the SAME row (same state bag) — must still show the snapshot.
814
- const second = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
815
- expect(second).toContain("1. alpha");
816
- expect(second).toContain("2. bravo");
817
- expect(second).not.toContain("changed");
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");
818
875
  });
819
876
  });
820
877
 
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: "◐",
@@ -112,7 +68,7 @@ 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. */
@@ -218,37 +174,41 @@ export default function registerTodo(pi: ExtensionAPI): void {
218
174
  renderCall() {
219
175
  return new Text("", 0, 0);
220
176
  },
221
- renderResult(_result, _options, theme, context) {
222
- // Snapshot this row's todos once (live `todos` mutate across calls;
223
- // a card should keep the state it was created with).
224
- const state = context.state as {
225
- snapshot?: TodoItem[];
226
- collapsed?: boolean;
227
- timer?: ReturnType<typeof setTimeout>;
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());
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);
237
185
  }
238
- const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
239
- return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
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);
240
195
  },
241
196
 
242
197
  async execute(_id, params) {
243
- // AgentToolResult now requires a `details` field. These todo results have
244
- // no structured details, so emit `undefined` via small local helpers.
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
+ });
245
205
  const ok = (text: string) => ({
246
206
  content: [{ type: "text" as const, text }],
247
- details: undefined,
207
+ details: details("success"),
248
208
  });
249
209
  const fail = (text: string) => ({
250
210
  content: [{ type: "text" as const, text }],
251
- details: undefined,
211
+ details: details("error"),
252
212
  isError: true,
253
213
  });
254
214
  switch (params.action) {