@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.
- package/package.json +1 -1
- package/src/todo.test.ts +121 -26
- package/src/todo.ts +125 -28
package/package.json
CHANGED
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("
|
|
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
|
-
|
|
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("
|
|
842
|
+
test("renders a header row with one column per status", () => {
|
|
781
843
|
const out = renderTodoLines(items, tagTheme);
|
|
782
|
-
expect(out).toContain("
|
|
783
|
-
expect(out).toContain("
|
|
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("
|
|
858
|
+
test("shows the todo <action> title while open, empty once collapsed", () => {
|
|
801
859
|
const host = makeHost();
|
|
802
860
|
registerTodo(host.pi);
|
|
803
|
-
|
|
804
|
-
|
|
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("
|
|
821
|
-
expect(rendered).toContain("[
|
|
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("
|
|
886
|
-
expect(rendered).toContain("
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
renderCall() {
|
|
183
|
-
|
|
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
|
|
253
|
-
// before it is finished. Cascade-close every earlier
|
|
254
|
-
// in_progress item
|
|
255
|
-
//
|
|
256
|
-
|
|
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
|
});
|