@xynogen/pix-todo 0.1.27 → 0.3.0

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/todo.test.ts +121 -26
  3. package/src/todo.ts +125 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.27",
3
+ "version": "0.3.0",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/todo.test.ts CHANGED
@@ -702,6 +702,71 @@ describe("skip-guard on marking done", () => {
702
702
  });
703
703
  });
704
704
 
705
+ // ─── Unordered lists (ordered:false) ────────────────────────────────────
706
+
707
+ describe("unordered lists", () => {
708
+ test("ordered:false opening a later item does NOT cascade-close earlier ones", async () => {
709
+ const host = makeHost();
710
+ registerTodo(host.pi);
711
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
712
+ await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
713
+ const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
714
+ // Earlier items stay pending — no silent completion.
715
+ expect(text(result)).toContain("\u25cb 1. a");
716
+ expect(text(result)).toContain("\u25cb 2. b");
717
+ expect(text(result)).toContain("\u25d0 3. c");
718
+ });
719
+
720
+ test("ordered:false marking a later item done does NOT warn about earlier ones", async () => {
721
+ const host = makeHost();
722
+ registerTodo(host.pi);
723
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
724
+ await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
725
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
726
+ expect(text(result)).not.toContain("\u26a0");
727
+ });
728
+
729
+ test("defaults to ordered (cascade-close) when ordered omitted", async () => {
730
+ const host = makeHost();
731
+ registerTodo(host.pi);
732
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
733
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
734
+ const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
735
+ expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
736
+ });
737
+
738
+ test("ordered flag persists and restores", async () => {
739
+ const host = makeHost();
740
+ registerTodo(host.pi);
741
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
742
+ host.appendCalls.length = 0;
743
+ await run(host.execute, { action: "set", items: "a\nb", ordered: false });
744
+ const data = host.appendCalls[0]?.data as { ordered?: boolean };
745
+ expect(data.ordered).toBe(false);
746
+
747
+ // Restore into a fresh host and confirm cascade stays disabled.
748
+ delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
749
+ const host2 = makeHost([
750
+ {
751
+ type: "custom",
752
+ customType: "todo-state",
753
+ data: {
754
+ todos: [
755
+ { id: 1, text: "a", status: "pending" },
756
+ { id: 2, text: "b", status: "pending" },
757
+ ],
758
+ nextTodoId: 3,
759
+ ordered: false,
760
+ },
761
+ },
762
+ ]);
763
+ registerTodo(host2.pi);
764
+ await host2.emit("session_start", {}, { sessionManager: host2.sessionManager });
765
+ const result = await run(host2.execute, { action: "update", id: 2, status: "in_progress" });
766
+ expect(text(result)).toContain("\u25cb 1. a"); // NOT cascade-closed
767
+ });
768
+ });
769
+
705
770
  // ─── Turn-based reminder ────────────────────────────────────────────────────────────
706
771
 
707
772
  describe("turn-based todo reminder", () => {
@@ -764,29 +829,22 @@ describe("renderTodoLines (colored TUI render)", () => {
764
829
  expect(renderTodoLines([], tagTheme)).toBe("[muted](no todos)[/]");
765
830
  });
766
831
 
767
- test("tints each glyph by status", () => {
768
- const out = renderTodoLines(items, tagTheme);
769
- expect(out).toContain("[success]●[/]"); // done
770
- expect(out).toContain("[accent]◐[/]"); // in_progress
771
- expect(out).toContain("[muted]○[/]"); // pending
772
- expect(out).toContain("[error]⊘[/]"); // blocked
773
- });
774
-
775
- test("highlights the in-progress row bold + accent", () => {
832
+ test("colors each card cell by status (glyph + body one unit)", () => {
776
833
  const out = renderTodoLines(items, tagTheme);
777
- expect(out).toContain("<b>[accent]2. bravo[/]</b>");
834
+ // Cards are padded cells colored as a whole; assert the tint wraps each card
835
+ // body (glyph-agnostic — glyph codepoint comes from the icon catalog/mode).
836
+ expect(out).toMatch(/\[success\][^\n]*alpha/); // done card success
837
+ expect(out).toMatch(/<b>\[accent\][^\n]*bravo[^\n]*<\/b>/); // in_progress bold+accent
838
+ expect(out).toMatch(/\[text\][^\n]*charlie/); // pending card white (text)
839
+ expect(out).toMatch(/\[error\][^\n]*delta/); // blocked card error
778
840
  });
779
841
 
780
- test("dims completed rows and uses text color for active-but-not-running", () => {
842
+ test("renders a header row with one column per status", () => {
781
843
  const out = renderTodoLines(items, tagTheme);
782
- expect(out).toContain("[muted]1. alpha[/]"); // done body muted
783
- expect(out).toContain("[text]3. charlie[/]"); // pending body text
784
- });
785
-
786
- test("shows the done/total count header in blue", () => {
787
- const out = renderTodoLines(items, tagTheme);
788
- expect(out).toContain("[accent]Todos 1/4 done:[/]");
789
- expect(out).not.toContain("[muted]Todos 1/4 done:[/]");
844
+ expect(out).toContain("To Do (1)");
845
+ expect(out).toContain("In Progress (1)");
846
+ expect(out).toContain("Blocked (1)");
847
+ expect(out).toContain("Done (1)");
790
848
  });
791
849
  });
792
850
 
@@ -797,11 +855,19 @@ describe("todo card layout", () => {
797
855
  expect(host.renderShell).toBe("self");
798
856
  });
799
857
 
800
- test("keeps the call row empty so the collapsed card is one line", () => {
858
+ test("shows the todo <action> title while open, empty once collapsed", () => {
801
859
  const host = makeHost();
802
860
  registerTodo(host.pi);
803
- const call = host.renderCall({ action: "list" }, tagTheme, {});
804
- expect(call.render(80).join("\n")).toBe("");
861
+ // Open card: title + action visible.
862
+ const open = host.renderCall({ action: "set" }, tagTheme, { state: {}, expanded: false });
863
+ expect(open.render(80).join("\n")).toContain("todo");
864
+ expect(open.render(80).join("\n")).toContain("set");
865
+ // Collapsed card: call row hides so the summary row is the only line.
866
+ const closed = host.renderCall({ action: "set" }, tagTheme, {
867
+ state: { collapsed: true },
868
+ expanded: false,
869
+ });
870
+ expect(closed.render(80).join("\n")).toBe("");
805
871
  });
806
872
 
807
873
  test("expanded mode restores a collapsed checklist", async () => {
@@ -817,11 +883,40 @@ describe("todo card layout", () => {
817
883
  .render(80)
818
884
  .join("\n");
819
885
 
820
- expect(rendered).toContain("1. alpha");
821
- expect(rendered).toContain("[muted]○[/]");
886
+ expect(rendered).toContain("alpha");
887
+ expect(rendered).toContain("[text]○ alpha"); // pending card, no id number
822
888
  expect(rendered).not.toContain("[success]✓ [/] [toolTitle]<b>todo</b>[/]");
823
889
  });
824
890
 
891
+ test("opening a new todo card collapses the previously open one", async () => {
892
+ const host = makeHost();
893
+ registerTodo(host.pi);
894
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
895
+ const r1 = await run(host.execute, { action: "set", items: "a" });
896
+ const r2 = await run(host.execute, { action: "add", items: "b" });
897
+
898
+ // Card 1 renders open (not yet collapsed).
899
+ let invalidated1 = false;
900
+ const state1 = { collapsed: false } as { collapsed?: boolean };
901
+ host.render(r1, { expanded: false }, tagTheme, {
902
+ state: state1,
903
+ invalidate: () => {
904
+ invalidated1 = true;
905
+ },
906
+ });
907
+ expect(state1.collapsed).toBe(false);
908
+
909
+ // Card 2 renders open → it claims focus and collapses card 1.
910
+ const state2 = { collapsed: false } as { collapsed?: boolean };
911
+ host.render(r2, { expanded: false }, tagTheme, {
912
+ state: state2,
913
+ invalidate: () => {},
914
+ });
915
+ expect(state1.collapsed).toBe(true); // previous card force-collapsed
916
+ expect(invalidated1).toBe(true); // and re-rendered
917
+ expect(state2.collapsed).toBe(false); // newest stays open
918
+ });
919
+
825
920
  test("failed todo actions render their exact error", async () => {
826
921
  const host = makeHost();
827
922
  registerTodo(host.pi);
@@ -882,8 +977,8 @@ describe("renderResult snapshot isolation", () => {
882
977
  })
883
978
  .render(80)
884
979
  .join("\n");
885
- expect(rendered).toContain("1. alpha");
886
- expect(rendered).toContain("2. bravo");
980
+ expect(rendered).toContain("alpha");
981
+ expect(rendered).toContain("bravo");
887
982
  expect(rendered).not.toContain("changed");
888
983
  });
889
984
  });
package/src/todo.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
15
- import { dotJoin, formatCollapsedToolRow } from "@xynogen/pix-pretty/utils";
15
+ import { dotJoin, formatCollapsedToolRow, termW } from "@xynogen/pix-pretty/utils";
16
16
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
17
17
  import { once } from "@xynogen/pix-runtime/once";
18
18
  import { Type } from "typebox";
@@ -47,7 +47,7 @@ function todoGlyph(status: TodoStatus): string {
47
47
 
48
48
  /** Theme color key per status — drives both glyph and (for active) row tint. */
49
49
  const TODO_COLOR: Record<TodoStatus, string> = {
50
- pending: "muted",
50
+ pending: "text",
51
51
  in_progress: "accent",
52
52
  done: "success",
53
53
  blocked: "error",
@@ -76,23 +76,78 @@ export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): stri
76
76
  return formatCollapsedToolRow(theme, "todo", target, meta, status);
77
77
  }
78
78
 
79
- /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
79
+ /** Kanban columns in workflow order: To Do In Progress Done → Blocked. */
80
+ const KANBAN_LANES: ReadonlyArray<{ status: TodoStatus; title: string }> = [
81
+ { status: "pending", title: "To Do" },
82
+ { status: "in_progress", title: "In Progress" },
83
+ { status: "done", title: "Done" },
84
+ { status: "blocked", title: "Blocked" },
85
+ ];
86
+
87
+ const COL_SEP = " │ "; // vertical separator between columns
88
+ const MIN_COL_WIDTH = 12;
89
+
90
+ /** Pad plain text to `w` (truncate with … if longer) BEFORE coloring, so ANSI
91
+ * codes never throw off column alignment. */
92
+ function cell(text: string, w: number): string {
93
+ if (text.length > w) return `${text.slice(0, Math.max(0, w - 1))}…`;
94
+ return text.padEnd(w);
95
+ }
96
+
97
+ /**
98
+ * Horizontal kanban board for the TUI: one column per status, cards stacked
99
+ * under each header, aligned into a table. Each column sizes to its own widest
100
+ * cell (header or card) so the table stays compact, capped at the even terminal
101
+ * split so a single long card can't blow out the row (text truncates with …).
102
+ * ponytail: per-column shrink-to-fit, capped at even split. Upgrade path is a
103
+ * width-responsive fallback to stacked swimlanes on very narrow terminals
104
+ * (see pix-sec design doc).
105
+ */
80
106
  export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
81
107
  if (!items.length) return theme.fg("muted", "(no todos)");
82
- const done = items.filter((t) => t.status === "done").length;
83
- const head = theme.fg("accent", `Todos ${done}/${items.length} done:`);
84
- const lines = items.map((t) => {
85
- const color = TODO_COLOR[t.status];
86
- const glyph = theme.fg(color, todoGlyph(t.status));
87
- const body = `${t.id}. ${t.text}`;
88
- // Highlight the in-flight task so the eye lands on it first.
89
- const label =
90
- t.status === "in_progress"
91
- ? theme.bold(theme.fg("accent", body))
92
- : theme.fg(t.status === "done" ? "muted" : "text", body);
93
- return `${glyph} ${label}`;
108
+
109
+ const cols = KANBAN_LANES.map((lane) => {
110
+ const laneItems = items.filter((t) => t.status === lane.status);
111
+ return {
112
+ ...lane,
113
+ items: laneItems,
114
+ header: `${lane.title} (${laneItems.length})`,
115
+ cards: laneItems.map((t) => `${todoGlyph(t.status)} ${t.text}`),
116
+ };
94
117
  });
95
- return `${head}\n${lines.join("\n")}`;
118
+ const gutters = (cols.length - 1) * COL_SEP.length;
119
+ // Cap: even terminal split — the old fixed width, now an upper bound.
120
+ const cap = Math.max(MIN_COL_WIDTH, Math.floor((termW() - gutters) / cols.length));
121
+ // Compact: shrink each column to its widest cell, but never past the cap.
122
+ const widths = cols.map((c) =>
123
+ Math.min(cap, Math.max(c.header.length, ...c.cards.map((s) => s.length))),
124
+ );
125
+ const rowCount = Math.max(...cols.map((c) => c.items.length));
126
+ const sep = theme.fg("muted", COL_SEP);
127
+
128
+ const headerRow = cols
129
+ .map((c, i) => theme.fg(TODO_COLOR[c.status], cell(c.header, widths[i] ?? 0)))
130
+ .join(sep);
131
+
132
+ const rows: string[] = [];
133
+ for (let r = 0; r < rowCount; r++) {
134
+ const row = cols
135
+ .map((c, i) => {
136
+ const w = widths[i] ?? 0;
137
+ const t = c.items[r];
138
+ if (!t) return " ".repeat(w);
139
+ const padded = cell(c.cards[r] ?? "", w);
140
+ // Card body shares its status color (matches the glyph); the in-flight
141
+ // card is also bolded so the eye lands on it first.
142
+ return t.status === "in_progress"
143
+ ? theme.bold(theme.fg("accent", padded))
144
+ : theme.fg(TODO_COLOR[t.status], padded);
145
+ })
146
+ .join(sep);
147
+ rows.push(row);
148
+ }
149
+
150
+ return [headerRow, ...rows].join("\n");
96
151
  }
97
152
 
98
153
  /**
@@ -121,9 +176,33 @@ export default function registerTodo(pi: ExtensionAPI): void {
121
176
  once(pi, "pix-todo", () => {
122
177
  let todos: TodoItem[] = [];
123
178
  let nextTodoId = 1;
179
+ // Whether the current list is a sequential run. Ordered lists cascade-close
180
+ // earlier items and warn on out-of-order completion; unordered lists treat
181
+ // every item as independent. Default true — plans are usually sequential.
182
+ let ordered = true;
183
+
184
+ // Single-open policy: only the newest todo card stays expanded. When a new
185
+ // card first renders (its state bag is unseen), collapse the previously
186
+ // focused card so two boards are never open at once. A card that is itself
187
+ // already collapsed never steals focus, so the invalidate re-render it
188
+ // triggers can't ping-pong.
189
+ let focused: { state: CollapseState; invalidate: () => void } | undefined;
190
+ function focusCard(state: CollapseState, invalidate: () => void) {
191
+ if (focused?.state === state) return; // already the focused card
192
+ const prev = focused;
193
+ focused = { state, invalidate };
194
+ if (prev && !prev.state.collapsed) {
195
+ if (prev.state.timer) {
196
+ clearTimeout(prev.state.timer);
197
+ prev.state.timer = undefined;
198
+ }
199
+ prev.state.collapsed = true;
200
+ prev.invalidate();
201
+ }
202
+ }
124
203
 
125
204
  function persistTodos() {
126
- pi.appendEntry("todo-state", { todos, nextTodoId });
205
+ pi.appendEntry("todo-state", { todos, nextTodoId, ordered });
127
206
  }
128
207
 
129
208
  function todoSummary(): string {
@@ -150,6 +229,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
150
229
  "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
151
230
  "Mark each item in_progress before working it via `todo(action:'update', id, status)`; opening one auto-closes every earlier item, so just open the next and skipped steps mark done themselves.",
152
231
  "When marking an item done, the tool checks for earlier incomplete items and warns you — resolve each skipped item (mark done or blocked) before moving on.",
232
+ "If the list is NOT a sequential run (items independent, done in any order), pass `ordered:false` on `set` — that disables the cascade-close and skip warning.",
153
233
  "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
154
234
  ],
155
235
  parameters: Type.Object({
@@ -176,11 +256,22 @@ export default function registerTodo(pi: ExtensionAPI): void {
176
256
  description: "For update: replacement text (optional).",
177
257
  }),
178
258
  ),
259
+ ordered: Type.Optional(
260
+ Type.Boolean({
261
+ description:
262
+ "For set: true (default) = sequential run (opening/completing an item cascades to earlier ones and warns on skips); false = independent items done in any order.",
263
+ }),
264
+ ),
179
265
  }),
180
- // The result already owns the checklist and its collapsed `✓ todo …` row.
181
- // Keeping the call renderer empty prevents a duplicate standalone header.
182
- renderCall() {
183
- return new Text("", 0, 0);
266
+ // Show the `todo <action>` title like other tool calls. Hidden once the
267
+ // card collapses — the collapsed summary row already carries the title.
268
+ renderCall(args, theme, context) {
269
+ const state = context.state as CollapseState;
270
+ if (state?.collapsed && !context.expanded) return new Text("", 0, 0);
271
+ const t = theme as TodoTheme;
272
+ const action = (args as { action?: string })?.action ?? "";
273
+ const title = t.fg("toolTitle", t.bold("todo"));
274
+ return new Text(action ? `${title} ${t.fg("muted", action)}` : title, 0, 0);
184
275
  },
185
276
  renderResult(result, options, theme, context) {
186
277
  const details = result.details as TodoResultDetails | undefined;
@@ -198,6 +289,8 @@ export default function registerTodo(pi: ExtensionAPI): void {
198
289
  context.invalidate,
199
290
  options.expanded,
200
291
  );
292
+ // An open card claims focus, collapsing any earlier open board.
293
+ if (!collapsed) focusCard(context.state as CollapseState, context.invalidate);
201
294
  const render = collapsed ? renderTodoSummaryLine : renderTodoLines;
202
295
  return new Text(render(details.snapshot, theme as TodoTheme), 0, 0);
203
296
  },
@@ -226,6 +319,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
226
319
  case "set": {
227
320
  const texts = parseItems(params.items ?? "");
228
321
  if (!texts.length) return fail("set requires non-empty `items`.");
322
+ ordered = (params.ordered as boolean | undefined) ?? true;
229
323
  nextTodoId = 1;
230
324
  todos = texts.map((text) => ({
231
325
  id: nextTodoId++,
@@ -249,11 +343,12 @@ export default function registerTodo(pi: ExtensionAPI): void {
249
343
  if (!t) return fail(`No todo with id ${params.id}.`);
250
344
  let skipWarning = "";
251
345
  if (params.status) {
252
- // Sequential-progress invariant: opening a task means everything
253
- // before it is finished. Cascade-close every earlier pending or
254
- // in_progress item (ids are sequential) so the model never has to
255
- // mark skipped steps done by hand. `blocked` is left untouched.
256
- if (params.status === "in_progress")
346
+ // Sequential-progress invariant (ordered lists only): opening a task
347
+ // means everything before it is finished. Cascade-close every earlier
348
+ // pending or in_progress item so the model never has to mark skipped
349
+ // steps done by hand. `blocked` is left untouched. Unordered lists
350
+ // treat each item independently — no cascade, no skip warning.
351
+ if (ordered && params.status === "in_progress")
257
352
  for (const other of todos)
258
353
  if (
259
354
  other.id < t.id &&
@@ -261,7 +356,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
261
356
  )
262
357
  other.status = "done";
263
358
 
264
- if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
359
+ if (ordered && params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
265
360
 
266
361
  t.status = params.status;
267
362
  }
@@ -273,6 +368,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
273
368
  case "clear":
274
369
  todos = [];
275
370
  nextTodoId = 1;
371
+ ordered = true;
276
372
  persistTodos();
277
373
  return ok("Todos cleared.");
278
374
 
@@ -313,7 +409,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
313
409
  const entries = ctx.sessionManager.getEntries() as Array<{
314
410
  type: string;
315
411
  customType?: string;
316
- data?: { todos?: TodoItem[]; nextTodoId?: number };
412
+ data?: { todos?: TodoItem[]; nextTodoId?: number; ordered?: boolean };
317
413
  }>;
318
414
  const lastTodo = entries
319
415
  .filter((e) => e.type === "custom" && e.customType === "todo-state")
@@ -321,6 +417,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
321
417
  if (Array.isArray(lastTodo?.data?.todos)) {
322
418
  todos = lastTodo.data.todos;
323
419
  nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
420
+ ordered = lastTodo.data.ordered ?? true;
324
421
  }
325
422
  });
326
423
  });