@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 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.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("[muted]Todos 1/4 done:[/]");
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
- 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 () => {
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
- // 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");
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
- // Mutate underlying todos via a new execute call.
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
- // 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");
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 dim summary used once a card has collapsed. */
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("muted", `Todos ${done}/${items.length} done:`);
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(_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());
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
- const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
239
- return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
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
- // AgentToolResult now requires a `details` field. These todo results have
244
- // no structured details, so emit `undefined` via small local helpers.
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: undefined,
210
+ details: details("success"),
248
211
  });
249
212
  const fail = (text: string) => ({
250
213
  content: [{ type: "text" as const, text }],
251
- details: undefined,
214
+ details: details("error"),
252
215
  isError: true,
253
216
  });
254
217
  switch (params.action) {