@maxiaochao/pi-toolkit 0.8.2 → 0.9.1

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.
@@ -0,0 +1,151 @@
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 TodoActionInput {
23
+ action: TodoAction;
24
+ text?: string;
25
+ items?: string[];
26
+ id?: number;
27
+ status?: TodoStatus;
28
+ }
29
+
30
+ export interface TodoActionResult extends TodoState {
31
+ message: string;
32
+ error?: string;
33
+ }
34
+
35
+ export type TodoCounts = Record<TodoStatus, number>;
36
+
37
+ export function normalizeTodo(todo: StoredTodo): Todo {
38
+ const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done" || todo.status === "cancelled"
39
+ ? todo.status
40
+ : todo.done
41
+ ? "done"
42
+ : "todo";
43
+ return { id: todo.id, text: todo.text, status };
44
+ }
45
+
46
+ export function normalizeTodoFlow(todos: Todo[]): void {
47
+ const active = todos.filter((todo) => todo.status === "doing");
48
+ for (const todo of active.slice(1)) todo.status = "todo";
49
+ if (active.length > 0) return;
50
+
51
+ const next = todos.find((todo) => todo.status === "todo");
52
+ if (next) next.status = "doing";
53
+ }
54
+
55
+ export function restoreTodoState(stored: StoredTodo[], nextId?: number): TodoState {
56
+ const todos = stored.map(normalizeTodo).sort((a, b) => a.id - b.id);
57
+ normalizeTodoFlow(todos);
58
+ const minimumNextId = todos.reduce((max, todo) => Math.max(max, todo.id + 1), 1);
59
+ return { todos, nextId: Math.max(nextId ?? 1, minimumNextId) };
60
+ }
61
+
62
+ export function countTodoStatuses(todos: Todo[]): TodoCounts {
63
+ return todos.reduce<TodoCounts>(
64
+ (counts, todo) => {
65
+ counts[todo.status] += 1;
66
+ return counts;
67
+ },
68
+ { todo: 0, doing: 0, done: 0, cancelled: 0 },
69
+ );
70
+ }
71
+
72
+ export function activeTodos(todos: Todo[]): Todo[] {
73
+ return todos.filter((todo) => todo.status === "doing" || todo.status === "todo");
74
+ }
75
+
76
+ function cloneState(state: TodoState): TodoState {
77
+ return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
78
+ }
79
+
80
+ function fail(state: TodoState, error: string): TodoActionResult {
81
+ return { ...cloneState(state), message: `Error: ${error}`, error };
82
+ }
83
+
84
+ function cleanText(text: string | undefined): string | undefined {
85
+ const cleaned = text?.trim();
86
+ return cleaned ? cleaned : undefined;
87
+ }
88
+
89
+ function hasActiveDuplicate(todos: Todo[], text: string): boolean {
90
+ return todos.some((todo) => (todo.status === "todo" || todo.status === "doing") && todo.text === text);
91
+ }
92
+
93
+ export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
94
+ const next = cloneState(state);
95
+
96
+ switch (input.action) {
97
+ case "list":
98
+ return { ...next, message: next.todos.length ? "Listed todos" : "No todos" };
99
+
100
+ case "replace": {
101
+ if (!input.items?.length) return fail(state, "items required for replace");
102
+ const items = input.items.map(cleanText);
103
+ if (items.some((item) => item === undefined)) return fail(state, "replace items must not be empty");
104
+ const texts = items as string[];
105
+ if (new Set(texts).size !== texts.length) return fail(state, "replace items must be unique");
106
+ const firstId = next.nextId;
107
+ next.todos = texts.map((text, index) => ({ id: firstId + index, text, status: index === 0 ? "doing" : "todo" }));
108
+ next.nextId += next.todos.length;
109
+ return { ...next, message: `Replaced plan with ${next.todos.length} tasks` };
110
+ }
111
+
112
+ case "add": {
113
+ const text = cleanText(input.text);
114
+ if (!text) return fail(state, "text required for add");
115
+ if (hasActiveDuplicate(next.todos, text)) return fail(state, `active task already exists: ${text}`);
116
+ if (input.status === "doing") {
117
+ for (const todo of next.todos) if (todo.status === "doing") todo.status = "todo";
118
+ }
119
+ const todo: Todo = { id: next.nextId++, text, status: input.status ?? "todo" };
120
+ next.todos.push(todo);
121
+ normalizeTodoFlow(next.todos);
122
+ return { ...next, message: `Added todo #${todo.id} [${todo.status}]: ${todo.text}` };
123
+ }
124
+
125
+ case "update": {
126
+ if (input.id === undefined || input.status === undefined) return fail(state, "id and status required for update");
127
+ const todo = next.todos.find((item) => item.id === input.id);
128
+ if (!todo) return fail(state, `#${input.id} not found`);
129
+ if (input.status === "doing") {
130
+ for (const item of next.todos) if (item.status === "doing") item.status = "todo";
131
+ }
132
+ todo.status = input.status;
133
+ normalizeTodoFlow(next.todos);
134
+ return { ...next, message: `Todo #${todo.id} is now ${todo.status}` };
135
+ }
136
+
137
+ case "remove": {
138
+ if (input.id === undefined) return fail(state, "id required for remove");
139
+ const index = next.todos.findIndex((item) => item.id === input.id);
140
+ if (index < 0) return fail(state, `#${input.id} not found`);
141
+ const [removed] = next.todos.splice(index, 1);
142
+ normalizeTodoFlow(next.todos);
143
+ return { ...next, message: `Removed todo #${removed.id}: ${removed.text}` };
144
+ }
145
+
146
+ case "clear": {
147
+ const count = next.todos.length;
148
+ return { todos: [], nextId: 1, message: `Cleared ${count} todos` };
149
+ }
150
+ }
151
+ }
@@ -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
+ activeTodos,
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,24 +52,29 @@ 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)}`;
@@ -143,14 +128,14 @@ class TodoListComponent {
143
128
  export default function (pi: ExtensionAPI) {
144
129
  if (process.env.PI_SUBAGENT_CHILD === "1") return;
145
130
 
146
- let todos: Todo[] = [];
147
- let nextId = 1;
131
+ let state: TodoState = { todos: [], nextId: 1 };
148
132
 
149
- const snapshot = (): StoredTodo[] => todos.map((todo) => ({ ...todo }));
133
+ const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
150
134
 
151
135
  const refreshUI = (ctx: ExtensionContext) => {
152
136
  if (!ctx.hasUI) return;
153
- if (todos.length === 0) {
137
+ const visible = activeTodos(state.todos);
138
+ if (visible.length === 0) {
154
139
  ctx.ui.setWidget(WIDGET_KEY, undefined);
155
140
  return;
156
141
  }
@@ -160,8 +145,8 @@ export default function (pi: ExtensionAPI) {
160
145
  (_tui, theme) => ({
161
146
  render(width: number): string[] {
162
147
  return [
163
- truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(todos))}`, width),
164
- ...sortedTodos(todos).map((todo) => truncateToWidth(` ${renderTodoLine(theme, todo)}`, width)),
148
+ truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(state.todos))}`, width),
149
+ ...sortedTodos(visible).map((todo) => truncateToWidth(` ${renderTodoLine(theme, todo)}`, width)),
165
150
  ];
166
151
  },
167
152
  invalidate() {},
@@ -170,16 +155,14 @@ export default function (pi: ExtensionAPI) {
170
155
  };
171
156
 
172
157
  const reconstructState = (ctx: ExtensionContext) => {
173
- todos = [];
174
- nextId = 1;
158
+ state = { todos: [], nextId: 1 };
175
159
  for (const entry of ctx.sessionManager.getBranch()) {
176
160
  if (entry.type !== "message") continue;
177
161
  const message = entry.message;
178
162
  if (message.role !== "toolResult" || message.toolName !== "todo") continue;
179
163
  const details = message.details as TodoDetails | undefined;
180
164
  if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
181
- todos = details.todos.map(normalizeTodo);
182
- nextId = details.nextId;
165
+ state = restoreTodoState(details.todos, details.nextId);
183
166
  }
184
167
  refreshUI(ctx);
185
168
  };
@@ -187,116 +170,39 @@ export default function (pi: ExtensionAPI) {
187
170
  pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
188
171
  pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
189
172
  pi.on("session_shutdown", async (_event, ctx) => {
190
- if (!ctx.hasUI) return;
191
- ctx.ui.setWidget(WIDGET_KEY, undefined);
173
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
192
174
  });
193
175
 
194
176
  pi.registerTool({
195
177
  name: "todo",
196
178
  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.`,
179
+ 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
180
  parameters: TodoParams,
224
181
 
225
182
  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
- }
183
+ const result = applyTodoAction(state, params);
184
+ state = { todos: result.todos, nextId: result.nextId };
185
+ const text = params.action === "list"
186
+ ? state.todos.length
187
+ ? sortedTodos(state.todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
188
+ : "No todos"
189
+ : result.message;
290
190
 
291
191
  refreshUI(ctx);
292
192
  return {
293
193
  content: [{ type: "text" as const, text }],
294
- details: { action: params.action, todos: snapshot(), nextId, ...(error ? { error } : {}) } as TodoDetails,
194
+ details: {
195
+ action: params.action,
196
+ todos: snapshot(),
197
+ nextId: state.nextId,
198
+ ...(result.error ? { error: result.error } : {}),
199
+ } as TodoDetails,
295
200
  };
296
201
  },
297
202
 
298
203
  renderCall(args, theme) {
299
204
  let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
205
+ if (args.items) text += ` ${theme.fg("dim", `${args.items.length} tasks`)}`;
300
206
  if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
301
207
  if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
302
208
  if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
@@ -314,9 +220,7 @@ Execution discipline:
314
220
  if (details.action === "list") {
315
221
  const list = details.todos.map(normalizeTodo);
316
222
  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);
223
+ return new Text(sortedTodos(list).map((todo) => renderTodoLine(theme, todo)).join("\n"), 0, 0);
320
224
  }
321
225
 
322
226
  const item = result.content[0];
@@ -326,14 +230,14 @@ Execution discipline:
326
230
  });
327
231
 
328
232
  pi.registerCommand("todos", {
329
- description: "Show all todos on the current branch",
233
+ description: "Show active and closed tasks in the current plan",
330
234
  handler: async (_args, ctx) => {
331
235
  if (ctx.mode !== "tui") {
332
236
  ctx.ui.notify("/todos requires interactive mode", "error");
333
237
  return;
334
238
  }
335
239
  await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
336
- new TodoListComponent(todos, theme, () => done()),
240
+ new TodoListComponent(state.todos, theme, () => done()),
337
241
  );
338
242
  },
339
243
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.8.2",
4
- "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export extensions, browser automation, and bundled theme/skills.",
3
+ "version": "0.9.1",
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": [
7
7
  "pi-package",
@@ -25,7 +25,8 @@
25
25
  "extensions": [
26
26
  "./extensions/todo.ts",
27
27
  "./extensions/cache-export/index.ts",
28
- "./extensions/btw.ts"
28
+ "./extensions/btw.ts",
29
+ "./extensions/codex-edit/index.ts"
29
30
  ],
30
31
  "skills": [
31
32
  "./skills"
@@ -47,8 +48,8 @@
47
48
  "ws": "^8.19.0"
48
49
  },
49
50
  "scripts": {
50
- "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",
51
- "load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts",
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
+ "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",
52
53
  "eval:agents": "node evaluations/agents-md/run.mjs",
53
54
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
54
55
  "postinstall": "node scripts/install-agents.mjs"