pi-todo 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mrg2400xx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # pi-todo
2
+
3
+ Persistent per-project todo tracker with a live TUI widget for [Pi](https://github.com/nicobailon/pi-coding-agent).
4
+
5
+ ## What It Does
6
+
7
+ - Registers a `todo` tool the LLM can call to manage a task list
8
+ - Stores tasks in `.pi/todo.json` in the project root (per-project, persistent)
9
+ - Shows a **live widget** above the editor with the current todo state
10
+ - Auto-loads existing todos on session start
11
+
12
+ ## Installation
13
+
14
+ Add the extension path to `~/.pi/agent/settings.json`:
15
+
16
+ ```json
17
+ {
18
+ "extensions": ["~/.pi/agent/extensions/todo/src/index.ts"]
19
+ }
20
+ ```
21
+
22
+ Or if you already have extensions:
23
+
24
+ ```json
25
+ {
26
+ "extensions": ["~/.pi/agent/extensions/todo/src/index.ts", "other/extension/path"]
27
+ }
28
+ ```
29
+
30
+ Then restart Pi (or run `/reload`).
31
+
32
+ ## Tool: `todo`
33
+
34
+ ### Actions
35
+
36
+ | Action | Required Fields | Description |
37
+ |--------|----------------|-------------|
38
+ | `add` | `text` | Create a new todo item |
39
+ | `update` | `id` | Modify an item's text/status/priority/assignee/blockedBy |
40
+ | `toggle` | `id` | Cycle status: pending → in-progress → done → pending |
41
+ | `remove` | `id` | Delete an item |
42
+ | `list` | — | List all items (optional: `filter`) |
43
+ | `clear` | — | Remove all completed items |
44
+ | `reorder` | `id`, `direction` | Move an item up or down in the list |
45
+
46
+ ### Parameters
47
+
48
+ | Field | Type | Required | Description |
49
+ |-------|------|----------|-------------|
50
+ | `action` | `string` | yes | One of: add, update, toggle, remove, list, clear, reorder |
51
+ | `id` | `string` | conditional | Task ID (e.g. T-001) |
52
+ | `text` | `string` | conditional | Task description |
53
+ | `status` | `string` | no | pending, in-progress, done, blocked |
54
+ | `priority` | `string` | no | low, medium, high, critical (default: medium) |
55
+ | `assignee` | `string` | no | Who's doing it |
56
+ | `filter` | `string` | no | Filter for list: all, pending, in-progress, done, blocked |
57
+ | `blockedBy` | `string` | no | Task ID this is blocked by |
58
+ | `direction` | `string` | no | Reorder direction: up or down |
59
+
60
+ ### Examples
61
+
62
+ ```
63
+ todo({ action: "add", text: "Build StorefrontController", priority: "high", assignee: "Ultron" })
64
+ todo({ action: "toggle", id: "T-001" })
65
+ todo({ action: "list", filter: "pending" })
66
+ todo({ action: "reorder", id: "T-003", direction: "up" })
67
+ todo({ action: "clear" })
68
+ ```
69
+
70
+ ## Slash Commands
71
+
72
+ - `/todo` — List all todo items
73
+ - `/todo clear` — Clear completed items
74
+
75
+ ## Storage
76
+
77
+ Tasks are stored in `.pi/todo.json` in the project root:
78
+
79
+ ```json
80
+ {
81
+ "items": [
82
+ {
83
+ "id": "T-001",
84
+ "text": "Build StorefrontController",
85
+ "status": "in-progress",
86
+ "priority": "high",
87
+ "assignee": "Ultron",
88
+ "createdAt": "2026-07-05T19:02:00Z",
89
+ "updatedAt": "2026-07-05T19:02:00Z",
90
+ "blockedBy": null
91
+ }
92
+ ],
93
+ "counter": 1
94
+ }
95
+ ```
96
+
97
+ ## Widget
98
+
99
+ The live widget appears above the editor and shows:
100
+
101
+ ```
102
+ 📋 TODO · 3 tasks
103
+ 2 pending · 1 active
104
+ ⚪ T-001 [HIGH] Build StorefrontController · Ultron
105
+ ⚪ T-002 [MED] Port landing.html · Maya
106
+ 🔵 T-003 [LOW] Verify storefront routes · Quinn
107
+ ```
108
+
109
+ Status icons: ⚪ pending, 🔵 in-progress, ✅ done, 🔴 blocked
110
+
111
+ ## Design Decisions
112
+
113
+ - **Per-project, not global** — Each project has its own todo list
114
+ - **Main agent only** — Subagents cannot call the todo tool
115
+ - **Auto-generated IDs** — T-001, T-002, T-003...
116
+ - **Synchronous file I/O** — Avoids race conditions (Node single-threaded)
117
+ - **No external dependencies** — Uses only Pi's built-in typebox
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-todo",
3
+ "version": "1.0.0",
4
+ "description": "Persistent per-project todo tracker with live TUI widget for Pi",
5
+ "author": "mrg2400xx",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mrg2400xx/pi-todo.git"
11
+ },
12
+ "homepage": "https://github.com/mrg2400xx/pi-todo#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/mrg2400xx/pi-todo/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "todo",
21
+ "task-tracker",
22
+ "productivity"
23
+ ],
24
+ "files": [
25
+ "src/**/*.ts",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "dependencies": {
35
+ "typebox": "^1.1.24"
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * pi-todo — Persistent per-project todo tracker with live TUI widget
3
+ *
4
+ * Registers a `todo` tool the LLM can call to manage a task list stored in
5
+ * `.pi/todo.json` in the project root. A live widget above the editor shows
6
+ * the current state at all times.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
11
+ import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
12
+ import { Text, type Component } from "@earendil-works/pi-tui";
13
+ import { TodoParams } from "./schemas.ts";
14
+ import {
15
+ addItem,
16
+ clearCompleted,
17
+ listItems,
18
+ readStore,
19
+ removeItem,
20
+ reorderItem,
21
+ toggleItem,
22
+ updateItem,
23
+ type TodoItem,
24
+ type TodoPriority,
25
+ type TodoStatus,
26
+ } from "./store.ts";
27
+ import { createWidgetComponent, renderTodoResult } from "./render.ts";
28
+
29
+ const WIDGET_KEY = "todo";
30
+
31
+ /** Update the live widget from the current store state */
32
+ function refreshWidget(pi: ExtensionAPI, cwd: string): void {
33
+ // We need a UI context — grab it from the last known context
34
+ // The widget is set via ctx.ui, but we can also use pi's event system
35
+ // For now, we store the last context and use it here
36
+ const ctx = lastCtx;
37
+ if (!ctx || !ctx.hasUI) return;
38
+ const store = readStore(cwd);
39
+ if (store.items.length === 0) {
40
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
41
+ return;
42
+ }
43
+ ctx.ui.setWidget(WIDGET_KEY, createWidgetComponent(store.items));
44
+ }
45
+
46
+ // Track the last known UI context for widget updates
47
+ let lastCtx: ExtensionContext | null = null;
48
+
49
+ interface TodoDetails {
50
+ action: string;
51
+ items: TodoItem[];
52
+ item?: TodoItem;
53
+ removed?: number;
54
+ moved?: boolean;
55
+ error?: string;
56
+ }
57
+
58
+ export default function registerTodoExtension(pi: ExtensionAPI): void {
59
+ const tool: ToolDefinition<typeof TodoParams, TodoDetails> = {
60
+ name: "todo",
61
+ label: "Todo",
62
+ description: `Manage a persistent per-project todo list stored in .pi/todo.json. The list survives across sessions and is shown as a live widget above the editor.
63
+
64
+ Actions:
65
+ - add: Create a new todo item (requires text, optional: priority, assignee, blockedBy)
66
+ - update: Modify an existing item by ID (requires id, optional: text, status, priority, assignee, blockedBy)
67
+ - toggle: Cycle status pending → in-progress → done → pending (requires id)
68
+ - remove: Delete an item by ID (requires id)
69
+ - list: List all items, optionally filtered by status (optional: filter)
70
+ - clear: Remove all completed items
71
+ - reorder: Move an item up or down in the list (requires id, direction)
72
+
73
+ Only the main agent can modify the todo list. Subagents cannot call this tool.`,
74
+ parameters: TodoParams,
75
+ promptSnippet: "Manage a persistent todo list with add, update, toggle, remove, list, clear, and reorder actions.",
76
+ promptGuidelines: [
77
+ "Use the todo tool to track multi-step tasks — add items before starting work, toggle to in-progress when beginning, toggle to done when complete.",
78
+ "Always set the assignee field to the agent doing the work (e.g. Ultron, Marcus, Quinn, Maya).",
79
+ "Use blockedBy to mark tasks that can't start until another task is done.",
80
+ ],
81
+
82
+ async execute(toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<TodoDetails>> {
83
+ lastCtx = ctx;
84
+ const cwd = ctx.cwd;
85
+ const width = process.stdout.columns || 120;
86
+
87
+ try {
88
+ switch (params.action) {
89
+ case "add": {
90
+ if (!params.text) {
91
+ return {
92
+ content: [{ type: "text", text: "Error: 'text' is required for add action." }],
93
+ isError: true,
94
+ details: { action: "add", items: [], error: "text is required" },
95
+ };
96
+ }
97
+ const item = addItem(
98
+ cwd,
99
+ params.text,
100
+ params.priority as TodoPriority | undefined,
101
+ params.assignee,
102
+ params.blockedBy,
103
+ );
104
+ refreshWidget(pi, cwd);
105
+ const all = listItems(cwd);
106
+ return {
107
+ content: [{ type: "text", text: `Added: ${item.id} — ${item.text}` }],
108
+ details: { action: "add", items: all, item },
109
+ };
110
+ }
111
+
112
+ case "update": {
113
+ if (!params.id) {
114
+ return {
115
+ content: [{ type: "text", text: "Error: 'id' is required for update action." }],
116
+ isError: true,
117
+ details: { action: "update", items: [], error: "id is required" },
118
+ };
119
+ }
120
+ const updated = updateItem(cwd, params.id, {
121
+ text: params.text,
122
+ status: params.status as TodoStatus | undefined,
123
+ priority: params.priority as TodoPriority | undefined,
124
+ assignee: params.assignee,
125
+ blockedBy: params.blockedBy,
126
+ });
127
+ if (!updated) {
128
+ return {
129
+ content: [{ type: "text", text: `Error: Item ${params.id} not found.` }],
130
+ isError: true,
131
+ details: { action: "update", items: [], error: "item not found" },
132
+ };
133
+ }
134
+ refreshWidget(pi, cwd);
135
+ const all = listItems(cwd);
136
+ return {
137
+ content: [{ type: "text", text: `Updated: ${updated.id} — ${updated.text} [${updated.status}]` }],
138
+ details: { action: "update", items: all, item: updated },
139
+ };
140
+ }
141
+
142
+ case "toggle": {
143
+ if (!params.id) {
144
+ return {
145
+ content: [{ type: "text", text: "Error: 'id' is required for toggle action." }],
146
+ isError: true,
147
+ details: { action: "toggle", items: [], error: "id is required" },
148
+ };
149
+ }
150
+ const toggled = toggleItem(cwd, params.id);
151
+ if (!toggled) {
152
+ return {
153
+ content: [{ type: "text", text: `Error: Item ${params.id} not found.` }],
154
+ isError: true,
155
+ details: { action: "toggle", items: [], error: "item not found" },
156
+ };
157
+ }
158
+ refreshWidget(pi, cwd);
159
+ const all = listItems(cwd);
160
+ return {
161
+ content: [{ type: "text", text: `Toggled: ${toggled.id} — ${toggled.text} → ${toggled.status}` }],
162
+ details: { action: "toggle", items: all, item: toggled },
163
+ };
164
+ }
165
+
166
+ case "remove": {
167
+ if (!params.id) {
168
+ return {
169
+ content: [{ type: "text", text: "Error: 'id' is required for remove action." }],
170
+ isError: true,
171
+ details: { action: "remove", items: [], error: "id is required" },
172
+ };
173
+ }
174
+ const removed = removeItem(cwd, params.id);
175
+ if (!removed) {
176
+ return {
177
+ content: [{ type: "text", text: `Error: Item ${params.id} not found.` }],
178
+ isError: true,
179
+ details: { action: "remove", items: [], error: "item not found" },
180
+ };
181
+ }
182
+ refreshWidget(pi, cwd);
183
+ const all = listItems(cwd);
184
+ return {
185
+ content: [{ type: "text", text: `Removed: ${params.id}` }],
186
+ details: { action: "remove", items: all },
187
+ };
188
+ }
189
+
190
+ case "list": {
191
+ const items = listItems(cwd, params.filter);
192
+ return {
193
+ content: [{ type: "text", text: formatListText(items) }],
194
+ details: { action: "list", items },
195
+ };
196
+ }
197
+
198
+ case "clear": {
199
+ const count = clearCompleted(cwd);
200
+ refreshWidget(pi, cwd);
201
+ const all = listItems(cwd);
202
+ return {
203
+ content: [{ type: "text", text: `Cleared ${count} completed item${count === 1 ? "" : "s"}.` }],
204
+ details: { action: "clear", items: all, removed: count },
205
+ };
206
+ }
207
+
208
+ case "reorder": {
209
+ if (!params.id) {
210
+ return {
211
+ content: [{ type: "text", text: "Error: 'id' is required for reorder action." }],
212
+ isError: true,
213
+ details: { action: "reorder", items: [], error: "id is required" },
214
+ };
215
+ }
216
+ if (!params.direction) {
217
+ return {
218
+ content: [{ type: "text", text: "Error: 'direction' is required for reorder action." }],
219
+ isError: true,
220
+ details: { action: "reorder", items: [], error: "direction is required" },
221
+ };
222
+ }
223
+ const moved = reorderItem(cwd, params.id, params.direction);
224
+ if (!moved) {
225
+ return {
226
+ content: [{ type: "text", text: `Error: Could not reorder ${params.id} (not found or already at ${params.direction === "up" ? "top" : "bottom"}).` }],
227
+ isError: true,
228
+ details: { action: "reorder", items: [], error: "cannot reorder" },
229
+ };
230
+ }
231
+ refreshWidget(pi, cwd);
232
+ const all = listItems(cwd);
233
+ return {
234
+ content: [{ type: "text", text: `Reordered: ${params.id} ${params.direction}` }],
235
+ details: { action: "reorder", items: all, moved: true },
236
+ };
237
+ }
238
+
239
+ default:
240
+ return {
241
+ content: [{ type: "text", text: `Error: Unknown action '${params.action}'.` }],
242
+ isError: true,
243
+ details: { action: String(params.action), items: [], error: "unknown action" },
244
+ };
245
+ }
246
+ } catch (error) {
247
+ const msg = error instanceof Error ? error.message : String(error);
248
+ return {
249
+ content: [{ type: "text", text: `Error: ${msg}` }],
250
+ isError: true,
251
+ details: { action: String(params.action), items: [], error: msg },
252
+ };
253
+ }
254
+ },
255
+
256
+ renderCall(args, theme) {
257
+ const action = args.action || "?";
258
+ if (action === "list") {
259
+ return new Text(`${theme.fg("toolTitle", theme.bold("todo "))}list${args.filter ? ` ${theme.fg("dim", args.filter)}` : ""}`, 0, 0);
260
+ }
261
+ if (action === "add" && args.text) {
262
+ const preview = args.text.length > 50 ? `${args.text.slice(0, 49)}…` : args.text;
263
+ return new Text(`${theme.fg("toolTitle", theme.bold("todo "))}add ${theme.fg("accent", `"${preview}"`)}`, 0, 0);
264
+ }
265
+ const target = args.id || "";
266
+ return new Text(`${theme.fg("toolTitle", theme.bold("todo "))}${action}${target ? ` ${theme.fg("accent", target)}` : ""}`, 0, 0);
267
+ },
268
+
269
+ renderResult(result, options, theme, _context) {
270
+ const details = result.details as TodoDetails | undefined;
271
+ if (!details) {
272
+ const text = typeof result.content === "string"
273
+ ? result.content
274
+ : result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
275
+ return new Text(text, 0, 0);
276
+ }
277
+ if (result.isError) {
278
+ const text = typeof result.content === "string"
279
+ ? result.content
280
+ : result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
281
+ return new Text(theme.fg("error", text), 0, 0);
282
+ }
283
+ const width = process.stdout.columns || 120;
284
+ return renderTodoResult(details.items, theme, width);
285
+ },
286
+ };
287
+
288
+ pi.registerTool(tool);
289
+
290
+ // Slash command: /todo
291
+ pi.registerCommand("todo", {
292
+ description: "List all todo items, or clear completed items with '/todo clear'",
293
+ async handler(args, ctx) {
294
+ lastCtx = ctx;
295
+ const cwd = ctx.cwd;
296
+ const arg = args.trim();
297
+
298
+ if (arg === "clear") {
299
+ const count = clearCompleted(cwd);
300
+ refreshWidget(pi, cwd);
301
+ ctx.ui.notify(`Cleared ${count} completed item${count === 1 ? "" : "s"}.`, "info");
302
+ return;
303
+ }
304
+
305
+ const items = listItems(cwd);
306
+ if (items.length === 0) {
307
+ ctx.ui.notify("No todo items.", "info");
308
+ return;
309
+ }
310
+ ctx.ui.notify(formatListText(items), "info");
311
+ },
312
+ });
313
+
314
+ // Session start: load existing todos and render widget
315
+ pi.on("session_start", (_event, ctx) => {
316
+ lastCtx = ctx;
317
+ const cwd = ctx.cwd;
318
+ const todoPath = `${cwd}/.pi/todo.json`;
319
+ if (!fs.existsSync(todoPath)) return;
320
+ const store = readStore(cwd);
321
+ if (store.items.length === 0) return;
322
+ if (ctx.hasUI) {
323
+ ctx.ui.setWidget(WIDGET_KEY, createWidgetComponent(store.items));
324
+ }
325
+ });
326
+
327
+ // Session shutdown: clear widget
328
+ pi.on("session_shutdown", (_event, ctx) => {
329
+ if (ctx.hasUI) {
330
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
331
+ }
332
+ });
333
+ }
334
+
335
+ /** Format items as a plain text list for slash command output */
336
+ function formatListText(items: TodoItem[]): string {
337
+ if (items.length === 0) return "No todo items.";
338
+ const lines: string[] = [];
339
+ for (const item of items) {
340
+ const icon = item.status === "done" ? "✅" : item.status === "in-progress" ? "🔵" : item.status === "blocked" ? "🔴" : "⚪";
341
+ const assignee = item.assignee ? ` · ${item.assignee}` : "";
342
+ const blocked = item.blockedBy ? ` (blocked by ${item.blockedBy})` : "";
343
+ lines.push(`${icon} ${item.id} [${item.priority.toUpperCase()}] ${item.text}${assignee}${blocked}`);
344
+ }
345
+ return lines.join("\n");
346
+ }
package/src/render.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * TUI rendering for the todo extension — widget and tool result components
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Container, Text, type Component } from "@earendil-works/pi-tui";
7
+ import type { TodoItem, TodoStatus, TodoPriority } from "./store.ts";
8
+
9
+ type Theme = ExtensionContext["ui"]["theme"];
10
+
11
+ const STATUS_ICON: Record<TodoStatus, string> = {
12
+ pending: "⚪",
13
+ "in-progress": "🔵",
14
+ done: "✅",
15
+ blocked: "🔴",
16
+ };
17
+
18
+ const PRIORITY_LABEL: Record<TodoPriority, string> = {
19
+ low: "LOW",
20
+ medium: "MED",
21
+ high: "HIGH",
22
+ critical: "CRIT",
23
+ };
24
+
25
+ const PRIORITY_COLOR: Record<TodoPriority, string> = {
26
+ low: "dim",
27
+ medium: "dim",
28
+ high: "warning",
29
+ critical: "error",
30
+ };
31
+
32
+ /** Truncate text to fit within maxWidth (visible characters) */
33
+ function truncate(text: string, maxWidth: number): string {
34
+ if (text.length <= maxWidth) return text;
35
+ return text.slice(0, maxWidth - 1) + "…";
36
+ }
37
+
38
+ /** Build the widget content lines for the todo list */
39
+ export function buildWidgetLines(items: TodoItem[], theme: Theme, width: number): string[] {
40
+ if (items.length === 0) return [];
41
+
42
+ const lines: string[] = [];
43
+ const header = `${theme.fg("accent", theme.bold("📋 TODO"))} ${theme.fg("dim", `· ${items.length} task${items.length === 1 ? "" : "s"}`)}`;
44
+ lines.push(truncate(header, width));
45
+
46
+ const pending = items.filter((i) => i.status === "pending").length;
47
+ const inProgress = items.filter((i) => i.status === "in-progress").length;
48
+ const done = items.filter((i) => i.status === "done").length;
49
+ const blocked = items.filter((i) => i.status === "blocked").length;
50
+
51
+ const parts: string[] = [];
52
+ if (pending > 0) parts.push(`${pending} pending`);
53
+ if (inProgress > 0) parts.push(`${inProgress} active`);
54
+ if (blocked > 0) parts.push(`${blocked} blocked`);
55
+ if (done > 0) parts.push(`${done} done`);
56
+ if (parts.length > 0) {
57
+ lines.push(truncate(` ${theme.fg("dim", parts.join(" · "))}`, width));
58
+ }
59
+
60
+ const maxItems = Math.min(items.length, 8);
61
+ for (let i = 0; i < maxItems; i++) {
62
+ const item = items[i]!;
63
+ const icon = STATUS_ICON[item.status];
64
+ const priColor = PRIORITY_COLOR[item.priority] ?? "dim";
65
+ const priLabel = PRIORITY_LABEL[item.priority] ?? "MED";
66
+ const assignee = item.assignee ? ` ${theme.fg("dim", `· ${item.assignee}`)}` : "";
67
+ const blocked = item.blockedBy ? ` ${theme.fg("error", `(blocked by ${item.blockedBy})`)}` : "";
68
+ const text = truncate(item.text, width - 20);
69
+ lines.push(
70
+ truncate(` ${icon} ${theme.fg(priColor, `[${priLabel}]`)} ${text}${assignee}${blocked}`, width),
71
+ );
72
+ }
73
+
74
+ if (items.length > maxItems) {
75
+ lines.push(truncate(` ${theme.fg("dim", `… and ${items.length - maxItems} more`)}`, width));
76
+ }
77
+
78
+ return lines;
79
+ }
80
+
81
+ /** Create the widget component factory for setWidget() */
82
+ export function createWidgetComponent(items: TodoItem[]): (tui: unknown, theme: Theme) => Component {
83
+ return (_tui: unknown, theme: Theme) => {
84
+ const width = process.stdout.columns || 120;
85
+ const lines = buildWidgetLines(items, theme, width);
86
+ const container = new Container();
87
+ for (const line of lines) {
88
+ container.addChild(new Text(line, 1, 0));
89
+ }
90
+ return container;
91
+ };
92
+ }
93
+
94
+ /** Render the tool result as a text summary */
95
+ export function renderTodoResult(items: TodoItem[], theme: Theme, width: number): Component {
96
+ const container = new Container();
97
+ container.addChild(new Text("", 0, 0));
98
+
99
+ if (items.length === 0) {
100
+ container.addChild(new Text(theme.fg("dim", " No todo items found."), 0, 0));
101
+ return container;
102
+ }
103
+
104
+ for (const item of items) {
105
+ const icon = STATUS_ICON[item.status];
106
+ const priColor = PRIORITY_COLOR[item.priority] ?? "dim";
107
+ const priLabel = PRIORITY_LABEL[item.priority] ?? "MED";
108
+ const assignee = item.assignee ? ` · ${item.assignee}` : "";
109
+ const blocked = item.blockedBy ? ` (blocked by ${item.blockedBy})` : "";
110
+ const text = truncate(item.text, width - 25);
111
+ container.addChild(
112
+ new Text(
113
+ ` ${icon} ${theme.bold(item.id)} ${theme.fg(priColor, `[${priLabel}]`)} ${text}${theme.fg("dim", assignee)}${blocked ? theme.fg("error", blocked) : ""}`,
114
+ 0,
115
+ 0,
116
+ ),
117
+ );
118
+ }
119
+
120
+ return container;
121
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * TypeBox parameter schema for the todo tool
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ const StatusEnum = Type.Unsafe({
8
+ type: "string",
9
+ enum: ["pending", "in-progress", "done", "blocked"],
10
+ description: "Task status: pending, in-progress, done, or blocked",
11
+ });
12
+
13
+ const PriorityEnum = Type.Unsafe({
14
+ type: "string",
15
+ enum: ["low", "medium", "high", "critical"],
16
+ description: "Task priority: low, medium, high, or critical",
17
+ });
18
+
19
+ const ActionEnum = Type.Unsafe({
20
+ type: "string",
21
+ enum: ["add", "update", "toggle", "remove", "list", "clear", "reorder"],
22
+ description: "Action to perform on the todo list",
23
+ });
24
+
25
+ const FilterEnum = Type.Unsafe({
26
+ type: "string",
27
+ enum: ["all", "pending", "in-progress", "done", "blocked"],
28
+ description: "Filter for list action: all, pending, in-progress, done, or blocked",
29
+ });
30
+
31
+ const DirectionEnum = Type.Unsafe({
32
+ type: "string",
33
+ enum: ["up", "down"],
34
+ description: "Reorder direction: up (earlier) or down (later)",
35
+ });
36
+
37
+ export const TodoParams = Type.Object({
38
+ action: ActionEnum,
39
+ id: Type.Optional(Type.String({ description: "Task ID (e.g. T-001). Required for update, toggle, remove, reorder." })),
40
+ text: Type.Optional(Type.String({ description: "Task description text. Required for add, optional for update." })),
41
+ status: Type.Optional(StatusEnum),
42
+ priority: Type.Optional(PriorityEnum),
43
+ assignee: Type.Optional(Type.String({ description: "Who is assigned to this task (e.g. Ultron, Marcus, Quinn)" })),
44
+ filter: Type.Optional(FilterEnum),
45
+ blockedBy: Type.Optional(Type.String({ description: "Task ID this item is blocked by (null to clear)" })),
46
+ direction: Type.Optional(DirectionEnum),
47
+ });
package/src/store.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * File-based todo store — reads and writes .pi/todo.json in the project root.
3
+ * All operations are synchronous to avoid race conditions (Node single-threaded).
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+
9
+ export type TodoStatus = "pending" | "in-progress" | "done" | "blocked";
10
+ export type TodoPriority = "low" | "medium" | "high" | "critical";
11
+
12
+ export interface TodoItem {
13
+ id: string;
14
+ text: string;
15
+ status: TodoStatus;
16
+ priority: TodoPriority;
17
+ assignee: string | null;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ blockedBy: string | null;
21
+ }
22
+
23
+ export interface TodoStore {
24
+ items: TodoItem[];
25
+ counter: number;
26
+ }
27
+
28
+ const DEFAULT_PRIORITY: TodoPriority = "medium";
29
+
30
+ /** Get the path to the todo file for the given project root */
31
+ export function getTodoPath(cwd: string): string {
32
+ return path.join(cwd, ".pi", "todo.json");
33
+ }
34
+
35
+ /** Read the todo store from disk. Returns empty store if file doesn't exist. */
36
+ export function readStore(cwd: string): TodoStore {
37
+ const todoPath = getTodoPath(cwd);
38
+ try {
39
+ const raw = fs.readFileSync(todoPath, "utf-8");
40
+ const parsed = JSON.parse(raw) as TodoStore;
41
+ if (!parsed.items || !Array.isArray(parsed.items)) {
42
+ return { items: [], counter: 0 };
43
+ }
44
+ return parsed;
45
+ } catch {
46
+ return { items: [], counter: 0 };
47
+ }
48
+ }
49
+
50
+ /** Write the todo store to disk. Creates .pi/ directory if needed. */
51
+ export function writeStore(cwd: string, store: TodoStore): void {
52
+ const todoPath = getTodoPath(cwd);
53
+ const dir = path.dirname(todoPath);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ fs.writeFileSync(todoPath, JSON.stringify(store, null, "\t") + "\n", "utf-8");
56
+ }
57
+
58
+ /** Generate the next task ID */
59
+ function nextId(counter: number): string {
60
+ return `T-${String(counter).padStart(3, "0")}`;
61
+ }
62
+
63
+ /** Create a new todo item */
64
+ export function addItem(
65
+ cwd: string,
66
+ text: string,
67
+ priority?: TodoPriority,
68
+ assignee?: string,
69
+ blockedBy?: string,
70
+ ): TodoItem {
71
+ const store = readStore(cwd);
72
+ store.counter += 1;
73
+ const now = new Date().toISOString();
74
+ const item: TodoItem = {
75
+ id: nextId(store.counter),
76
+ text,
77
+ status: "pending",
78
+ priority: priority ?? DEFAULT_PRIORITY,
79
+ assignee: assignee ?? null,
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ blockedBy: blockedBy ?? null,
83
+ };
84
+ store.items.push(item);
85
+ writeStore(cwd, store);
86
+ return item;
87
+ }
88
+
89
+ /** Update an existing todo item by ID. Returns the updated item or null if not found. */
90
+ export function updateItem(
91
+ cwd: string,
92
+ id: string,
93
+ updates: Partial<Pick<TodoItem, "text" | "status" | "priority" | "assignee" | "blockedBy">>,
94
+ ): TodoItem | null {
95
+ const store = readStore(cwd);
96
+ const item = store.items.find((i) => i.id === id);
97
+ if (!item) return null;
98
+ if (updates.text !== undefined) item.text = updates.text;
99
+ if (updates.status !== undefined) item.status = updates.status;
100
+ if (updates.priority !== undefined) item.priority = updates.priority;
101
+ if (updates.assignee !== undefined) item.assignee = updates.assignee;
102
+ if (updates.blockedBy !== undefined) item.blockedBy = updates.blockedBy;
103
+ item.updatedAt = new Date().toISOString();
104
+ writeStore(cwd, store);
105
+ return item;
106
+ }
107
+
108
+ /** Toggle status: pending → in-progress → done → pending. Returns updated item or null. */
109
+ export function toggleItem(cwd: string, id: string): TodoItem | null {
110
+ const store = readStore(cwd);
111
+ const item = store.items.find((i) => i.id === id);
112
+ if (!item) return null;
113
+ const cycle: Record<TodoStatus, TodoStatus> = {
114
+ pending: "in-progress",
115
+ "in-progress": "done",
116
+ done: "pending",
117
+ blocked: "in-progress",
118
+ };
119
+ item.status = cycle[item.status];
120
+ item.updatedAt = new Date().toISOString();
121
+ writeStore(cwd, store);
122
+ return item;
123
+ }
124
+
125
+ /** Remove an item by ID. Returns true if removed, false if not found. */
126
+ export function removeItem(cwd: string, id: string): boolean {
127
+ const store = readStore(cwd);
128
+ const before = store.items.length;
129
+ store.items = store.items.filter((i) => i.id !== id);
130
+ if (store.items.length === before) return false;
131
+ writeStore(cwd, store);
132
+ return true;
133
+ }
134
+
135
+ /** List items, optionally filtered by status. */
136
+ export function listItems(cwd: string, filter?: string): TodoItem[] {
137
+ const store = readStore(cwd);
138
+ if (!filter || filter === "all") return store.items;
139
+ return store.items.filter((i) => i.status === filter);
140
+ }
141
+
142
+ /** Remove all completed items. Returns the count removed. */
143
+ export function clearCompleted(cwd: string): number {
144
+ const store = readStore(cwd);
145
+ const before = store.items.length;
146
+ store.items = store.items.filter((i) => i.status !== "done");
147
+ const removed = before - store.items.length;
148
+ if (removed > 0) writeStore(cwd, store);
149
+ return removed;
150
+ }
151
+
152
+ /** Reorder an item up (earlier) or down (later) in the list. Returns true if moved. */
153
+ export function reorderItem(cwd: string, id: string, direction: "up" | "down"): boolean {
154
+ const store = readStore(cwd);
155
+ const index = store.items.findIndex((i) => i.id === id);
156
+ if (index === -1) return false;
157
+ if (direction === "up" && index === 0) return false;
158
+ if (direction === "down" && index === store.items.length - 1) return false;
159
+ const swapWith = direction === "up" ? index - 1 : index + 1;
160
+ [store.items[index], store.items[swapWith]] = [store.items[swapWith]!, store.items[index]!];
161
+ writeStore(cwd, store);
162
+ return true;
163
+ }