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 +21 -0
- package/README.md +117 -0
- package/package.json +37 -0
- package/src/index.ts +346 -0
- package/src/render.ts +121 -0
- package/src/schemas.ts +47 -0
- package/src/store.ts +163 -0
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
|
+
}
|