@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 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.13",
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("renderResult snapshot isolation", () => {
776
- // The card snapshots `todos` on first render; later execute() mutations must
777
- // NOT bleed into an already-rendered card. Guards the invariant the inline
778
- // comment defends.
779
- test("a rendered card keeps its state after todos mutate", async () => {
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
- // Render once with a per-row state bag; snapshot is taken here.
786
- const state: Record<string, unknown> = {};
787
- const ctx = { state, invalidate: () => {} };
788
- const first = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
789
- expect(first).toContain("1. alpha");
790
- 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
+ });
791
828
 
792
- // Mutate underlying todos via a new execute call.
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
- // Re-render the SAME row (same state bag) — must still show the snapshot.
796
- const second = host.render({}, {}, tagTheme, ctx).render(80).join("\n");
797
- expect(second).toContain("1. alpha");
798
- expect(second).toContain("2. bravo");
799
- 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");
800
875
  });
801
876
  });
802
877
 
803
878
  describe("renderTodoSummaryLine (collapsed one-liner)", () => {
804
- test("empty list renders muted placeholder", () => {
805
- expect(renderTodoSummaryLine([], tagTheme)).toBe("[muted](no todos)[/]");
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 single dim done/total line with check", () => {
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: "pending" },
888
+ { id: 2, text: "b", status: "in_progress" },
812
889
  ];
813
- expect(renderTodoSummaryLine(items, tagTheme)).toBe("[muted]Todos 1/2 done ✓[/]");
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.fg("muted", "(no todos)");
59
+ if (!items.length) return formatCollapsedToolRow(theme, "todo", "empty");
103
60
  const done = items.filter((t) => t.status === "done").length;
104
- return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
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
- renderResult(_result, _options, theme, context) {
206
- // Snapshot this row's todos once (live `todos` mutate across calls;
207
- // a card should keep the state it was created with).
208
- const state = context.state as {
209
- snapshot?: TodoItem[];
210
- collapsed?: boolean;
211
- timer?: ReturnType<typeof setTimeout>;
212
- };
213
- if (!state.snapshot) state.snapshot = todos.map((t) => ({ ...t }));
214
- // Start the collapse timer once per row; invalidate() triggers rerender.
215
- // Config-driven: reads from ~/.pi/agent/pix.json collapse section.
216
- if (shouldCollapseTodo() && !state.collapsed && !state.timer) {
217
- state.timer = setTimeout(() => {
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
- const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
223
- 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);
224
195
  },
225
196
 
226
197
  async execute(_id, params) {
227
- // AgentToolResult now requires a `details` field. These todo results have
228
- // 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
+ });
229
205
  const ok = (text: string) => ({
230
206
  content: [{ type: "text" as const, text }],
231
- details: undefined,
207
+ details: details("success"),
232
208
  });
233
209
  const fail = (text: string) => ({
234
210
  content: [{ type: "text" as const, text }],
235
- details: undefined,
211
+ details: details("error"),
236
212
  isError: true,
237
213
  });
238
214
  switch (params.action) {