@maxiaochao/pi-toolkit 0.1.3

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,340 @@
1
+ /**
2
+ * Lightweight todo extension with branch-aware session persistence.
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.
6
+ */
7
+
8
+ import { StringEnum } from "@earendil-works/pi-ai";
9
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
+ import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
11
+ 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
+ }
28
+
29
+ interface TodoDetails {
30
+ action: TodoAction;
31
+ todos: StoredTodo[];
32
+ nextId: number;
33
+ error?: string;
34
+ }
35
+
36
+ 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.",
39
+ }),
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).",
44
+ })),
45
+ });
46
+
47
+ const WIDGET_KEY = "todo-list";
48
+
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
+ function sortedTodos(todos: Todo[]): Todo[] {
69
+ return [...todos].sort((a, b) => a.id - b.id);
70
+ }
71
+
72
+ function statusMarker(status: TodoStatus): string {
73
+ if (status === "doing") return "◼";
74
+ if (status === "done") return "✔";
75
+ return "◻";
76
+ }
77
+
78
+ function themedStatus(theme: Theme, status: TodoStatus, text: string): string {
79
+ if (status === "doing") return theme.fg("warning", text);
80
+ if (status === "done") return theme.fg("success", text);
81
+ return theme.fg("dim", text);
82
+ }
83
+
84
+ 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)`;
88
+ }
89
+
90
+ function renderTodoLine(theme: Theme, todo: Todo): string {
91
+ const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
92
+ if (todo.status === "done") {
93
+ return `${icon} \x1b[9m${theme.fg("dim", `#${todo.id} ${todo.text}`)}\x1b[29m`;
94
+ }
95
+ return `${icon} ${theme.fg("accent", `#${todo.id}`)} ${theme.fg("text", todo.text)}`;
96
+ }
97
+
98
+ class TodoListComponent {
99
+ private cachedWidth?: number;
100
+ private cachedLines?: string[];
101
+
102
+ constructor(
103
+ private readonly todos: Todo[],
104
+ private readonly theme: Theme,
105
+ private readonly onClose: () => void,
106
+ ) {}
107
+
108
+ handleInput(data: string): void {
109
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
110
+ }
111
+
112
+ render(width: number): string[] {
113
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
114
+
115
+ const border = this.theme.fg("borderMuted", "-".repeat(Math.max(0, width - 10)));
116
+ const lines = ["", truncateToWidth(`${this.theme.fg("borderMuted", "---")}${this.theme.fg("accent", " Todos ")}${border}`, width), ""];
117
+
118
+ if (this.todos.length === 0) {
119
+ lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todos")}`, width));
120
+ } else {
121
+ lines.push(truncateToWidth(
122
+ `${this.theme.fg("accent", "●")} ${this.theme.fg("muted", taskSummary(this.todos))}`,
123
+ width,
124
+ ));
125
+ lines.push("");
126
+ for (const todo of sortedTodos(this.todos)) {
127
+ lines.push(truncateToWidth(` ${renderTodoLine(this.theme, todo)}`, width));
128
+ }
129
+ }
130
+
131
+ lines.push("", truncateToWidth(` ${this.theme.fg("dim", "Press Escape to close")}`, width), "");
132
+ this.cachedWidth = width;
133
+ this.cachedLines = lines;
134
+ return lines;
135
+ }
136
+
137
+ invalidate(): void {
138
+ this.cachedWidth = undefined;
139
+ this.cachedLines = undefined;
140
+ }
141
+ }
142
+
143
+ export default function (pi: ExtensionAPI) {
144
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
145
+
146
+ let todos: Todo[] = [];
147
+ let nextId = 1;
148
+
149
+ const snapshot = (): StoredTodo[] => todos.map((todo) => ({ ...todo }));
150
+
151
+ const refreshUI = (ctx: ExtensionContext) => {
152
+ if (!ctx.hasUI) return;
153
+ if (todos.length === 0) {
154
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
155
+ return;
156
+ }
157
+
158
+ ctx.ui.setWidget(
159
+ WIDGET_KEY,
160
+ (_tui, theme) => ({
161
+ render(width: number): string[] {
162
+ return [
163
+ truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(todos))}`, width),
164
+ ...sortedTodos(todos).map((todo) => truncateToWidth(` ${renderTodoLine(theme, todo)}`, width)),
165
+ ];
166
+ },
167
+ invalidate() {},
168
+ }),
169
+ );
170
+ };
171
+
172
+ const reconstructState = (ctx: ExtensionContext) => {
173
+ todos = [];
174
+ nextId = 1;
175
+ for (const entry of ctx.sessionManager.getBranch()) {
176
+ if (entry.type !== "message") continue;
177
+ const message = entry.message;
178
+ if (message.role !== "toolResult" || message.toolName !== "todo") continue;
179
+ const details = message.details as TodoDetails | undefined;
180
+ if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
181
+ todos = details.todos.map(normalizeTodo);
182
+ nextId = details.nextId;
183
+ }
184
+ refreshUI(ctx);
185
+ };
186
+
187
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
188
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
189
+ pi.on("session_shutdown", async (_event, ctx) => {
190
+ if (!ctx.hasUI) return;
191
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
192
+ });
193
+
194
+ pi.registerTool({
195
+ name: "todo",
196
+ 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.`,
223
+ parameters: TodoParams,
224
+
225
+ 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
+ }
290
+
291
+ refreshUI(ctx);
292
+ return {
293
+ content: [{ type: "text" as const, text }],
294
+ details: { action: params.action, todos: snapshot(), nextId, ...(error ? { error } : {}) } as TodoDetails,
295
+ };
296
+ },
297
+
298
+ renderCall(args, theme) {
299
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
300
+ if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
301
+ if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
302
+ if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
303
+ return new Text(text, 0, 0);
304
+ },
305
+
306
+ renderResult(result, _options, theme) {
307
+ const details = result.details as TodoDetails | undefined;
308
+ if (!details) {
309
+ const item = result.content[0];
310
+ return new Text(item?.type === "text" ? item.text : "", 0, 0);
311
+ }
312
+ if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
313
+
314
+ if (details.action === "list") {
315
+ const list = details.todos.map(normalizeTodo);
316
+ 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);
320
+ }
321
+
322
+ const item = result.content[0];
323
+ const message = item?.type === "text" ? item.text : "";
324
+ return new Text(theme.fg("success", "> ") + theme.fg("muted", message), 0, 0);
325
+ },
326
+ });
327
+
328
+ pi.registerCommand("todos", {
329
+ description: "Show all todos on the current branch",
330
+ handler: async (_args, ctx) => {
331
+ if (ctx.mode !== "tui") {
332
+ ctx.ui.notify("/todos requires interactive mode", "error");
333
+ return;
334
+ }
335
+ await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
336
+ new TodoListComponent(todos, theme, () => done()),
337
+ );
338
+ },
339
+ });
340
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@maxiaochao/pi-toolkit",
3
+ "version": "0.1.3",
4
+ "description": "Personal pi coding agent toolkit: /todos extension + /cache_export interactive cache dashboard.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension"
9
+ ],
10
+ "files": [
11
+ "extensions",
12
+ "README.md",
13
+ "CHANGELOG.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions/todo.ts",
22
+ "./extensions/cache-export/index.ts"
23
+ ]
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-ai": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
30
+ },
31
+ "scripts": {
32
+ "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs",
33
+ "load-test": "pi -e ./extensions/todo.ts && pi -e ./extensions/cache-export/index.ts"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }