@maxiaochao/pi-toolkit 0.9.0 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2026-09-30 - v0.9.2
2
+
3
+ - show contiguous queue positions in the todo widget while retaining stable internal task IDs
4
+
5
+ ## 2026-09-30 - v0.9.1
6
+
7
+ - redesign the todo lifecycle with plan replacement, cancellation, automatic advancement, active-only widget rendering, concise model guidance, and deterministic state tests
8
+
1
9
  ## 2026-09-30 - v0.9.0
2
10
 
3
11
  - merge the Codex-style apply_patch extension into the toolkit (from the standalone @maxiaochao/pi-codex-edit v0.1.6): it now ships as extensions/codex-edit/ registered in pi.extensions, its parser and model-routing tests join npm test, and the separate package, release script and publish workflow are removed
package/README.md CHANGED
@@ -38,6 +38,8 @@ To synchronize the file directly from a checkout instead:
38
38
  bash scripts/install.sh
39
39
  ```
40
40
 
41
+ The `todo` tool maintains a session checklist for explicit plans and multi-step work. It automatically keeps one task active and advances the next queued task; `replace` starts a clean plan after a change of direction, while `cancelled` records obsolete work honestly. The persistent widget numbers active work by queue position; `/todos` shows active and closed tasks with their stable internal IDs.
42
+
41
43
  The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly. While the BTW overlay has focus, use `Up` / `Down` to scroll its transcript one line at a time and `PageUp` / `PageDown` to move by one transcript page.
42
44
 
43
45
  Use `/cache_export [path]` to write an interactive per-request cache dashboard. It defaults to the latest Context segment for current-state diagnosis, and the View selector can show any earlier segment or all session history. Compaction and model changes both break context charts into segments; cache-hit accumulation also resets at model changes, while TTL and other inferred interruptions remain diagnostic events rather than segment boundaries.
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ activeTodoRows,
4
+ activeTodos,
5
+ applyTodoAction,
6
+ restoreTodoState,
7
+ } from "../todo-state.ts";
8
+
9
+ const empty = { todos: [], nextId: 1 };
10
+
11
+ assert.deepEqual(
12
+ activeTodoRows([
13
+ { id: 8, text: "closed", status: "done" },
14
+ { id: 9, text: "current", status: "doing" },
15
+ { id: 11, text: "next", status: "todo" },
16
+ ]).map(({ position, todo }) => ({ position, id: todo.id })),
17
+ [
18
+ { position: 1, id: 9 },
19
+ { position: 2, id: 11 },
20
+ ],
21
+ "active rows expose contiguous queue positions independently of internal IDs",
22
+ );
23
+
24
+ const replaced = applyTodoAction(empty, {
25
+ action: "replace",
26
+ items: ["Inspect the failure", "Implement the fix", "Run verification"],
27
+ });
28
+ assert.deepEqual(
29
+ replaced.todos.map(({ id, text, status }) => ({ id, text, status })),
30
+ [
31
+ { id: 1, text: "Inspect the failure", status: "doing" },
32
+ { id: 2, text: "Implement the fix", status: "todo" },
33
+ { id: 3, text: "Run verification", status: "todo" },
34
+ ],
35
+ "replace installs a fresh ordered plan and starts its first task",
36
+ );
37
+ assert.equal(replaced.nextId, 4, "replace advances the ID sequence past the new plan");
38
+
39
+ const completed = applyTodoAction(replaced, { action: "update", id: 1, status: "done" });
40
+ assert.deepEqual(
41
+ completed.todos.map(({ id, status }) => ({ id, status })),
42
+ [
43
+ { id: 1, status: "done" },
44
+ { id: 2, status: "doing" },
45
+ { id: 3, status: "todo" },
46
+ ],
47
+ "finishing active work advances the next queued task",
48
+ );
49
+
50
+ const cancelled = applyTodoAction(completed, { action: "update", id: 2, status: "cancelled" });
51
+ assert.equal(cancelled.todos[2].status, "doing", "cancelling active work advances the next queued task");
52
+ assert.deepEqual(activeTodos(cancelled.todos).map((todo) => todo.id), [3], "closed work is absent from the active view");
53
+
54
+ const restarted = applyTodoAction(cancelled, { action: "add", text: "Review the redirected implementation", status: "doing" });
55
+ assert.equal(restarted.todos.find((todo) => todo.id === 3).status, "todo", "starting another task demotes the previous active task");
56
+ assert.equal(restarted.todos.find((todo) => todo.id === 4).status, "doing", "the requested task becomes active");
57
+
58
+ const removed = applyTodoAction(restarted, { action: "remove", id: 4 });
59
+ assert.equal(removed.todos.find((todo) => todo.id === 3).status, "doing", "removing active work advances the queue");
60
+ assert.equal(removed.nextId, 5, "removing a task does not reuse its ID");
61
+
62
+ const duplicate = applyTodoAction(removed, { action: "add", text: "Run verification" });
63
+ assert.match(duplicate.error ?? "", /already exists/, "active duplicates are rejected");
64
+ assert.deepEqual(duplicate.todos, removed.todos, "a rejected mutation preserves the previous state");
65
+
66
+ const replacedAgain = applyTodoAction(removed, { action: "replace", items: ["New direction"] });
67
+ assert.deepEqual(
68
+ replacedAgain.todos,
69
+ [{ id: 5, text: "New direction", status: "doing" }],
70
+ "replace discards the obsolete plan without reusing an old task ID",
71
+ );
72
+ assert.equal(replacedAgain.nextId, 6);
73
+
74
+ const invalidReplace = applyTodoAction(replacedAgain, { action: "replace", items: [" "] });
75
+ assert.ok(invalidReplace.error, "replace rejects empty task text");
76
+ assert.deepEqual(invalidReplace.todos, replacedAgain.todos, "invalid replacement is atomic");
77
+
78
+ const restored = restoreTodoState([
79
+ { id: 4, text: "legacy complete", done: true },
80
+ { id: 2, text: "first active", status: "doing" },
81
+ { id: 3, text: "second active", status: "doing" },
82
+ ], 2);
83
+ assert.deepEqual(
84
+ restored.todos.map(({ id, status }) => ({ id, status })),
85
+ [
86
+ { id: 2, status: "doing" },
87
+ { id: 3, status: "todo" },
88
+ { id: 4, status: "done" },
89
+ ],
90
+ "restoration migrates legacy entries and enforces one active task",
91
+ );
92
+ assert.equal(restored.nextId, 5, "restoration repairs stale next IDs");
93
+
94
+ const cleared = applyTodoAction(restored, { action: "clear" });
95
+ assert.deepEqual(cleared.todos, []);
96
+ assert.equal(cleared.nextId, 1, "clear resets the ID sequence");
97
+
98
+ console.log("Todo state tests passed");
@@ -0,0 +1,160 @@
1
+ export type TodoStatus = "todo" | "doing" | "done" | "cancelled";
2
+ export type TodoAction = "list" | "replace" | "add" | "update" | "remove" | "clear";
3
+
4
+ export interface Todo {
5
+ id: number;
6
+ text: string;
7
+ status: TodoStatus;
8
+ }
9
+
10
+ export interface StoredTodo {
11
+ id: number;
12
+ text: string;
13
+ status?: TodoStatus;
14
+ done?: boolean;
15
+ }
16
+
17
+ export interface TodoState {
18
+ todos: Todo[];
19
+ nextId: number;
20
+ }
21
+
22
+ export interface ActiveTodoRow {
23
+ position: number;
24
+ todo: Todo;
25
+ }
26
+
27
+ export interface TodoActionInput {
28
+ action: TodoAction;
29
+ text?: string;
30
+ items?: string[];
31
+ id?: number;
32
+ status?: TodoStatus;
33
+ }
34
+
35
+ export interface TodoActionResult extends TodoState {
36
+ message: string;
37
+ error?: string;
38
+ }
39
+
40
+ export type TodoCounts = Record<TodoStatus, number>;
41
+
42
+ export function normalizeTodo(todo: StoredTodo): Todo {
43
+ const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done" || todo.status === "cancelled"
44
+ ? todo.status
45
+ : todo.done
46
+ ? "done"
47
+ : "todo";
48
+ return { id: todo.id, text: todo.text, status };
49
+ }
50
+
51
+ export function normalizeTodoFlow(todos: Todo[]): void {
52
+ const active = todos.filter((todo) => todo.status === "doing");
53
+ for (const todo of active.slice(1)) todo.status = "todo";
54
+ if (active.length > 0) return;
55
+
56
+ const next = todos.find((todo) => todo.status === "todo");
57
+ if (next) next.status = "doing";
58
+ }
59
+
60
+ export function restoreTodoState(stored: StoredTodo[], nextId?: number): TodoState {
61
+ const todos = stored.map(normalizeTodo).sort((a, b) => a.id - b.id);
62
+ normalizeTodoFlow(todos);
63
+ const minimumNextId = todos.reduce((max, todo) => Math.max(max, todo.id + 1), 1);
64
+ return { todos, nextId: Math.max(nextId ?? 1, minimumNextId) };
65
+ }
66
+
67
+ export function countTodoStatuses(todos: Todo[]): TodoCounts {
68
+ return todos.reduce<TodoCounts>(
69
+ (counts, todo) => {
70
+ counts[todo.status] += 1;
71
+ return counts;
72
+ },
73
+ { todo: 0, doing: 0, done: 0, cancelled: 0 },
74
+ );
75
+ }
76
+
77
+ export function activeTodos(todos: Todo[]): Todo[] {
78
+ return todos.filter((todo) => todo.status === "doing" || todo.status === "todo");
79
+ }
80
+
81
+ export function activeTodoRows(todos: Todo[]): ActiveTodoRow[] {
82
+ return activeTodos(todos).map((todo, index) => ({ position: index + 1, todo }));
83
+ }
84
+
85
+ function cloneState(state: TodoState): TodoState {
86
+ return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
87
+ }
88
+
89
+ function fail(state: TodoState, error: string): TodoActionResult {
90
+ return { ...cloneState(state), message: `Error: ${error}`, error };
91
+ }
92
+
93
+ function cleanText(text: string | undefined): string | undefined {
94
+ const cleaned = text?.trim();
95
+ return cleaned ? cleaned : undefined;
96
+ }
97
+
98
+ function hasActiveDuplicate(todos: Todo[], text: string): boolean {
99
+ return todos.some((todo) => (todo.status === "todo" || todo.status === "doing") && todo.text === text);
100
+ }
101
+
102
+ export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
103
+ const next = cloneState(state);
104
+
105
+ switch (input.action) {
106
+ case "list":
107
+ return { ...next, message: next.todos.length ? "Listed todos" : "No todos" };
108
+
109
+ case "replace": {
110
+ if (!input.items?.length) return fail(state, "items required for replace");
111
+ const items = input.items.map(cleanText);
112
+ if (items.some((item) => item === undefined)) return fail(state, "replace items must not be empty");
113
+ const texts = items as string[];
114
+ if (new Set(texts).size !== texts.length) return fail(state, "replace items must be unique");
115
+ const firstId = next.nextId;
116
+ next.todos = texts.map((text, index) => ({ id: firstId + index, text, status: index === 0 ? "doing" : "todo" }));
117
+ next.nextId += next.todos.length;
118
+ return { ...next, message: `Replaced plan with ${next.todos.length} tasks` };
119
+ }
120
+
121
+ case "add": {
122
+ const text = cleanText(input.text);
123
+ if (!text) return fail(state, "text required for add");
124
+ if (hasActiveDuplicate(next.todos, text)) return fail(state, `active task already exists: ${text}`);
125
+ if (input.status === "doing") {
126
+ for (const todo of next.todos) if (todo.status === "doing") todo.status = "todo";
127
+ }
128
+ const todo: Todo = { id: next.nextId++, text, status: input.status ?? "todo" };
129
+ next.todos.push(todo);
130
+ normalizeTodoFlow(next.todos);
131
+ return { ...next, message: `Added todo #${todo.id} [${todo.status}]: ${todo.text}` };
132
+ }
133
+
134
+ case "update": {
135
+ if (input.id === undefined || input.status === undefined) return fail(state, "id and status required for update");
136
+ const todo = next.todos.find((item) => item.id === input.id);
137
+ if (!todo) return fail(state, `#${input.id} not found`);
138
+ if (input.status === "doing") {
139
+ for (const item of next.todos) if (item.status === "doing") item.status = "todo";
140
+ }
141
+ todo.status = input.status;
142
+ normalizeTodoFlow(next.todos);
143
+ return { ...next, message: `Todo #${todo.id} is now ${todo.status}` };
144
+ }
145
+
146
+ case "remove": {
147
+ if (input.id === undefined) return fail(state, "id required for remove");
148
+ const index = next.todos.findIndex((item) => item.id === input.id);
149
+ if (index < 0) return fail(state, `#${input.id} not found`);
150
+ const [removed] = next.todos.splice(index, 1);
151
+ normalizeTodoFlow(next.todos);
152
+ return { ...next, message: `Removed todo #${removed.id}: ${removed.text}` };
153
+ }
154
+
155
+ case "clear": {
156
+ const count = next.todos.length;
157
+ return { todos: [], nextId: 1, message: `Cleared ${count} todos` };
158
+ }
159
+ }
160
+ }
@@ -1,30 +1,25 @@
1
1
  /**
2
- * Lightweight todo extension with branch-aware session persistence.
2
+ * Session-scoped todo extension with branch-aware persistence.
3
3
  *
4
- * Provides one `todo` tool, a `/todos` dialog, a persistent below-editor
5
- * widget, and footer counts. It has no subagent integration or prompt hooks.
4
+ * Provides the `todo` tool, a compact active-work widget, and `/todos` current-plan view.
6
5
  */
7
6
 
8
7
  import { StringEnum } from "@earendil-works/pi-ai";
9
8
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
9
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
11
10
  import { Type } from "typebox";
12
-
13
- type TodoStatus = "todo" | "doing" | "done";
14
- type TodoAction = "list" | "add" | "update" | "toggle" | "clear";
15
-
16
- interface Todo {
17
- id: number;
18
- text: string;
19
- status: TodoStatus;
20
- }
21
-
22
- interface StoredTodo {
23
- id: number;
24
- text: string;
25
- status?: TodoStatus;
26
- done?: boolean;
27
- }
11
+ import {
12
+ activeTodoRows,
13
+ applyTodoAction,
14
+ countTodoStatuses,
15
+ normalizeTodo,
16
+ restoreTodoState,
17
+ type StoredTodo,
18
+ type Todo,
19
+ type TodoAction,
20
+ type TodoState,
21
+ type TodoStatus,
22
+ } from "./todo-state.ts";
28
23
 
29
24
  interface TodoDetails {
30
25
  action: TodoAction;
@@ -34,37 +29,22 @@ interface TodoDetails {
34
29
  }
35
30
 
36
31
  const TodoParams = Type.Object({
37
- action: StringEnum(["list", "add", "update", "toggle", "clear"] as const, {
38
- description: "Operation to perform. Prefer update over toggle when setting an explicit status.",
32
+ action: StringEnum(["list", "replace", "add", "update", "remove", "clear"] as const, {
33
+ description: "list current tasks; replace the plan; add one; update status; remove one; or clear all",
39
34
  }),
40
- text: Type.Optional(Type.String({ description: "Specific, outcome-oriented task text. Required for add." })),
41
- id: Type.Optional(Type.Number({ description: "Todo ID. Required for update and toggle." })),
42
- status: Type.Optional(StringEnum(["todo", "doing", "done"] as const, {
43
- description: "Explicit task state. Required for update; optional for add (defaults to todo).",
35
+ items: Type.Optional(Type.Array(Type.String(), {
36
+ minItems: 1,
37
+ description: "Complete ordered task texts for replace",
38
+ })),
39
+ text: Type.Optional(Type.String({ description: "Task text for add" })),
40
+ id: Type.Optional(Type.Integer({ minimum: 1, description: "Task ID for update or remove" })),
41
+ status: Type.Optional(StringEnum(["todo", "doing", "done", "cancelled"] as const, {
42
+ description: "New status for update; optional initial status for add",
44
43
  })),
45
44
  });
46
45
 
47
46
  const WIDGET_KEY = "todo-list";
48
47
 
49
- function normalizeTodo(todo: StoredTodo): Todo {
50
- const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done"
51
- ? todo.status
52
- : todo.done
53
- ? "done"
54
- : "todo";
55
- return { id: todo.id, text: todo.text, status };
56
- }
57
-
58
- function countStatuses(todos: Todo[]): Record<TodoStatus, number> {
59
- return todos.reduce<Record<TodoStatus, number>>(
60
- (counts, todo) => {
61
- counts[todo.status] += 1;
62
- return counts;
63
- },
64
- { todo: 0, doing: 0, done: 0 },
65
- );
66
- }
67
-
68
48
  function sortedTodos(todos: Todo[]): Todo[] {
69
49
  return [...todos].sort((a, b) => a.id - b.id);
70
50
  }
@@ -72,29 +52,39 @@ function sortedTodos(todos: Todo[]): Todo[] {
72
52
  function statusMarker(status: TodoStatus): string {
73
53
  if (status === "doing") return "◼";
74
54
  if (status === "done") return "✔";
55
+ if (status === "cancelled") return "✖";
75
56
  return "◻";
76
57
  }
77
58
 
78
59
  function themedStatus(theme: Theme, status: TodoStatus, text: string): string {
79
60
  if (status === "doing") return theme.fg("warning", text);
80
61
  if (status === "done") return theme.fg("success", text);
62
+ if (status === "cancelled") return theme.fg("error", text);
81
63
  return theme.fg("dim", text);
82
64
  }
83
65
 
84
66
  function taskSummary(todos: Todo[]): string {
85
- const counts = countStatuses(todos);
86
- const noun = todos.length === 1 ? "task" : "tasks";
87
- return `${todos.length} ${noun} (${counts.done} done, ${counts.doing} in progress, ${counts.todo} open)`;
67
+ const counts = countTodoStatuses(todos);
68
+ const active = counts.todo + counts.doing;
69
+ const parts = [`${active} active (${counts.doing} doing, ${counts.todo} queued)`];
70
+ if (counts.done > 0) parts.push(`${counts.done} done`);
71
+ if (counts.cancelled > 0) parts.push(`${counts.cancelled} cancelled`);
72
+ return parts.join(" · ");
88
73
  }
89
74
 
90
75
  function renderTodoLine(theme: Theme, todo: Todo): string {
91
76
  const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
92
- if (todo.status === "done") {
77
+ if (todo.status === "done" || todo.status === "cancelled") {
93
78
  return `${icon} \x1b[9m${theme.fg("dim", `#${todo.id} ${todo.text}`)}\x1b[29m`;
94
79
  }
95
80
  return `${icon} ${theme.fg("accent", `#${todo.id}`)} ${theme.fg("text", todo.text)}`;
96
81
  }
97
82
 
83
+ function renderActiveTodoLine(theme: Theme, todo: Todo, position: number): string {
84
+ const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
85
+ return `${icon} ${theme.fg("accent", `${position}.`)} ${theme.fg("text", todo.text)}`;
86
+ }
87
+
98
88
  class TodoListComponent {
99
89
  private cachedWidth?: number;
100
90
  private cachedLines?: string[];
@@ -143,14 +133,14 @@ class TodoListComponent {
143
133
  export default function (pi: ExtensionAPI) {
144
134
  if (process.env.PI_SUBAGENT_CHILD === "1") return;
145
135
 
146
- let todos: Todo[] = [];
147
- let nextId = 1;
136
+ let state: TodoState = { todos: [], nextId: 1 };
148
137
 
149
- const snapshot = (): StoredTodo[] => todos.map((todo) => ({ ...todo }));
138
+ const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
150
139
 
151
140
  const refreshUI = (ctx: ExtensionContext) => {
152
141
  if (!ctx.hasUI) return;
153
- if (todos.length === 0) {
142
+ const visible = activeTodoRows(state.todos);
143
+ if (visible.length === 0) {
154
144
  ctx.ui.setWidget(WIDGET_KEY, undefined);
155
145
  return;
156
146
  }
@@ -160,8 +150,8 @@ export default function (pi: ExtensionAPI) {
160
150
  (_tui, theme) => ({
161
151
  render(width: number): string[] {
162
152
  return [
163
- truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(todos))}`, width),
164
- ...sortedTodos(todos).map((todo) => truncateToWidth(` ${renderTodoLine(theme, todo)}`, width)),
153
+ truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(state.todos))}`, width),
154
+ ...visible.map(({ position, todo }) => truncateToWidth(` ${renderActiveTodoLine(theme, todo, position)}`, width)),
165
155
  ];
166
156
  },
167
157
  invalidate() {},
@@ -170,16 +160,14 @@ export default function (pi: ExtensionAPI) {
170
160
  };
171
161
 
172
162
  const reconstructState = (ctx: ExtensionContext) => {
173
- todos = [];
174
- nextId = 1;
163
+ state = { todos: [], nextId: 1 };
175
164
  for (const entry of ctx.sessionManager.getBranch()) {
176
165
  if (entry.type !== "message") continue;
177
166
  const message = entry.message;
178
167
  if (message.role !== "toolResult" || message.toolName !== "todo") continue;
179
168
  const details = message.details as TodoDetails | undefined;
180
169
  if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
181
- todos = details.todos.map(normalizeTodo);
182
- nextId = details.nextId;
170
+ state = restoreTodoState(details.todos, details.nextId);
183
171
  }
184
172
  refreshUI(ctx);
185
173
  };
@@ -187,116 +175,39 @@ export default function (pi: ExtensionAPI) {
187
175
  pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
188
176
  pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
189
177
  pi.on("session_shutdown", async (_event, ctx) => {
190
- if (!ctx.hasUI) return;
191
- ctx.ui.setWidget(WIDGET_KEY, undefined);
178
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
192
179
  });
193
180
 
194
181
  pi.registerTool({
195
182
  name: "todo",
196
183
  label: "Todo",
197
- description: `Maintain a visible, session-scoped task list with explicit todo, doing, and done states.
198
-
199
- Use this tool when:
200
- - The user asks for a checklist, plan, or progress tracking.
201
- - Work has multiple concrete steps whose progress should remain visible.
202
- - A task starts, finishes, becomes pending again, or the current state must be inspected.
203
-
204
- Do not create a list for a single trivial action unless the user explicitly requests one. Keep task text specific and outcome-oriented. Avoid duplicate tasks.
205
-
206
- States:
207
- - todo: Planned but not started, or intentionally returned to the queue.
208
- - doing: Actively being worked on now. Set this immediately before starting the task.
209
- - done: Fully completed and, when applicable, verified. Never mark partial or failed work done.
210
-
211
- Actions:
212
- - list: Read the complete current list. Use before updating when IDs or current states are uncertain, and when reporting progress.
213
- - add: Create one task using text. It defaults to todo; pass status only when the task is already active or completed.
214
- - update: Set an exact state using id and status. This is the preferred way to move tasks between todo, doing, and done.
215
- - toggle: Cycle one task todo -> doing -> done -> todo. Use only for quick manual-style transitions when the current state is already known; otherwise use update.
216
- - clear: Remove the entire list and reset IDs. Use only when the user requests a reset or the whole list is obsolete.
217
-
218
- Execution discipline:
219
- - Move a task to doing before performing it.
220
- - Move it to done immediately after successful completion and verification.
221
- - Leave failed, blocked, or incomplete work as doing, or move it back to todo when it is no longer active.
222
- - Keep only genuinely active work in doing; sequential workflows normally have one doing task at a time.`,
184
+ description: "Track explicit checklists and non-trivial multi-step work. Replace the plan when instructions change. The tool keeps one task doing and advances automatically; mark obsolete work cancelled and verified work done.",
223
185
  parameters: TodoParams,
224
186
 
225
187
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
226
- let text: string;
227
- let error: string | undefined;
228
-
229
- switch (params.action) {
230
- case "list":
231
- text = todos.length
232
- ? sortedTodos(todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
233
- : "No todos";
234
- break;
235
-
236
- case "add": {
237
- if (!params.text) {
238
- error = "text required for add";
239
- text = `Error: ${error}`;
240
- break;
241
- }
242
- const todo: Todo = { id: nextId++, text: params.text, status: params.status ?? "todo" };
243
- todos.push(todo);
244
- text = `Added todo #${todo.id} [${todo.status}]: ${todo.text}`;
245
- break;
246
- }
247
-
248
- case "update": {
249
- if (params.id === undefined || params.status === undefined) {
250
- error = "id and status required for update";
251
- text = `Error: ${error}`;
252
- break;
253
- }
254
- const todo = todos.find((item) => item.id === params.id);
255
- if (!todo) {
256
- error = `#${params.id} not found`;
257
- text = `Todo ${error}`;
258
- break;
259
- }
260
- todo.status = params.status;
261
- text = `Todo #${todo.id} is now ${todo.status}`;
262
- break;
263
- }
264
-
265
- case "toggle": {
266
- if (params.id === undefined) {
267
- error = "id required for toggle";
268
- text = `Error: ${error}`;
269
- break;
270
- }
271
- const todo = todos.find((item) => item.id === params.id);
272
- if (!todo) {
273
- error = `#${params.id} not found`;
274
- text = `Todo ${error}`;
275
- break;
276
- }
277
- todo.status = todo.status === "todo" ? "doing" : todo.status === "doing" ? "done" : "todo";
278
- text = `Todo #${todo.id} is now ${todo.status}`;
279
- break;
280
- }
281
-
282
- case "clear": {
283
- const count = todos.length;
284
- todos = [];
285
- nextId = 1;
286
- text = `Cleared ${count} todos`;
287
- break;
288
- }
289
- }
188
+ const result = applyTodoAction(state, params);
189
+ state = { todos: result.todos, nextId: result.nextId };
190
+ const text = params.action === "list"
191
+ ? state.todos.length
192
+ ? sortedTodos(state.todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
193
+ : "No todos"
194
+ : result.message;
290
195
 
291
196
  refreshUI(ctx);
292
197
  return {
293
198
  content: [{ type: "text" as const, text }],
294
- details: { action: params.action, todos: snapshot(), nextId, ...(error ? { error } : {}) } as TodoDetails,
199
+ details: {
200
+ action: params.action,
201
+ todos: snapshot(),
202
+ nextId: state.nextId,
203
+ ...(result.error ? { error: result.error } : {}),
204
+ } as TodoDetails,
295
205
  };
296
206
  },
297
207
 
298
208
  renderCall(args, theme) {
299
209
  let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
210
+ if (args.items) text += ` ${theme.fg("dim", `${args.items.length} tasks`)}`;
300
211
  if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
301
212
  if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
302
213
  if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
@@ -314,9 +225,7 @@ Execution discipline:
314
225
  if (details.action === "list") {
315
226
  const list = details.todos.map(normalizeTodo);
316
227
  if (list.length === 0) return new Text(theme.fg("dim", "No todos"), 0, 0);
317
- const display = sortedTodos(list);
318
- const output = display.map((todo) => renderTodoLine(theme, todo)).join("\n");
319
- return new Text(output, 0, 0);
228
+ return new Text(sortedTodos(list).map((todo) => renderTodoLine(theme, todo)).join("\n"), 0, 0);
320
229
  }
321
230
 
322
231
  const item = result.content[0];
@@ -326,14 +235,14 @@ Execution discipline:
326
235
  });
327
236
 
328
237
  pi.registerCommand("todos", {
329
- description: "Show all todos on the current branch",
238
+ description: "Show active and closed tasks in the current plan",
330
239
  handler: async (_args, ctx) => {
331
240
  if (ctx.mode !== "tui") {
332
241
  ctx.ui.notify("/todos requires interactive mode", "error");
333
242
  return;
334
243
  }
335
244
  await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
336
- new TodoListComponent(todos, theme, () => done()),
245
+ new TodoListComponent(state.todos, theme, () => done()),
337
246
  );
338
247
  },
339
248
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,7 +48,7 @@
48
48
  "ws": "^8.19.0"
49
49
  },
50
50
  "scripts": {
51
- "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node tests/e2e-browser.mjs && cd ../codex-edit && node --experimental-strip-types --test tests/package.test.mjs tests/parser.test.mjs",
51
+ "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node --experimental-strip-types ../tests/todo-state.mjs && node tests/e2e-browser.mjs && cd ../codex-edit && node --experimental-strip-types --test tests/package.test.mjs tests/parser.test.mjs",
52
52
  "load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts && pi -ne -e ./extensions/codex-edit/index.ts",
53
53
  "eval:agents": "node evaluations/agents-md/run.mjs",
54
54
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",