@agnishc/edb-todo 0.6.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +29 -8
- package/package.json +1 -1
- package/src/component.ts +166 -58
- package/src/index.ts +79 -23
- package/src/prompt.ts +13 -15
- package/src/schemas.ts +6 -0
- package/src/state.ts +103 -51
- package/src/types.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.2] - 2026-05-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `todo_remove` tool — permanently remove tasks by ID
|
|
7
|
+
- Interactive keyboard navigation in `/todos` viewer (↑↓/jk, g/G, Home/End)
|
|
8
|
+
- Toggle completed task visibility with `c` key in `/todos` viewer
|
|
9
|
+
- Timestamps on tasks: `createdAt`, `startedAt`, `completedAt`
|
|
10
|
+
- Status transition tracking — timestamps update automatically when tasks move between states
|
|
11
|
+
- Percentage display in progress bar
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Replaced module-level mutable globals (`tasks`, `idCounter`) with `TodoStore` class
|
|
15
|
+
- Deduplicated rendering logic — shared `priorityColor()`, `priorityLabel()` helpers used everywhere
|
|
16
|
+
- Priority labels now display as `High`/`Medium`/`Low` instead of `HIG`/`MED`/`LOW`
|
|
17
|
+
- In-progress icon changed from `→` to `●` for visual consistency
|
|
18
|
+
- `/todos` viewer now shows cursor indicator (`❯`) on focused task
|
|
19
|
+
- Section headers show task counts
|
|
20
|
+
- Updated widget status bar to use `●` for active count
|
|
21
|
+
- `todo_write` prompt guidelines now explicitly state completed tasks are never auto-deleted
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Rendering inconsistency between widget, viewer, and tool results — all now use the same styling
|
|
25
|
+
|
|
26
|
+
## [0.8.1] - 2026-05-11
|
|
27
|
+
|
|
3
28
|
## [0.6.0] - 2026-05-11
|
|
4
29
|
|
|
5
30
|
### Changed
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ A Pi CLI extension that gives the agent a structured task list to prevent **goal
|
|
|
8
8
|
2. Before every agent turn, active tasks are injected into the system prompt so the model always knows what remains and what it's currently doing
|
|
9
9
|
3. A live widget above the editor shows the current task list at all times
|
|
10
10
|
4. State is reconstructed from the session branch, so `/tree` navigation and forking work correctly
|
|
11
|
+
5. Completed tasks remain visible in the list — they are never auto-deleted, providing a full audit trail
|
|
11
12
|
|
|
12
13
|
## Tools
|
|
13
14
|
|
|
@@ -15,25 +16,45 @@ A Pi CLI extension that gives the agent a structured task list to prevent **goal
|
|
|
15
16
|
|------|-------------|
|
|
16
17
|
| `todo_write` | Replace the entire task list (atomic update) — always pass all tasks |
|
|
17
18
|
| `todo_read` | Read the current task list and statuses |
|
|
19
|
+
| `todo_remove` | Permanently remove tasks by ID — use for cleanup, not for completed tasks |
|
|
18
20
|
|
|
19
21
|
## Task statuses
|
|
20
22
|
|
|
21
23
|
| Status | Icon | Meaning |
|
|
22
24
|
|--------|------|---------|
|
|
23
25
|
| `pending` | `○` | Not started |
|
|
24
|
-
| `in_progress` |
|
|
25
|
-
| `completed` | `✓` | Done |
|
|
26
|
+
| `in_progress` | `●` | Actively working — only one at a time |
|
|
27
|
+
| `completed` | `✓` | Done — remains in the list |
|
|
26
28
|
|
|
27
|
-
##
|
|
29
|
+
## Task priorities
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
| Priority | Label | Color |
|
|
32
|
+
|----------|-------|-------|
|
|
33
|
+
| `high` | High | Red |
|
|
34
|
+
| `medium` | Medium | Yellow |
|
|
35
|
+
| `low` | Low | Dim |
|
|
32
36
|
|
|
33
|
-
##
|
|
37
|
+
## Interactive viewer
|
|
34
38
|
|
|
35
39
|
```
|
|
36
|
-
/todos — open the
|
|
40
|
+
/todos — open the full-screen task viewer
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Keyboard controls in the viewer:
|
|
44
|
+
|
|
45
|
+
| Key | Action |
|
|
46
|
+
|-----|--------|
|
|
47
|
+
| `↑` / `k` | Move cursor up |
|
|
48
|
+
| `↓` / `j` | Move cursor down |
|
|
49
|
+
| `g` / `Home` | Jump to first task |
|
|
50
|
+
| `G` / `End` | Jump to last task |
|
|
51
|
+
| `c` | Toggle completed tasks visibility |
|
|
52
|
+
| `Esc` / `Ctrl+C` | Close viewer |
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pi install npm:@agnishc/edb-todo
|
|
37
58
|
```
|
|
38
59
|
|
|
39
60
|
## License
|
package/package.json
CHANGED
package/src/component.ts
CHANGED
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { priorityColor, priorityLabel } from "./state";
|
|
2
3
|
import type { Task, TaskDetails } from "./types";
|
|
3
|
-
import { PRIORITY_ORDER
|
|
4
|
+
import { PRIORITY_ORDER } from "./types";
|
|
4
5
|
|
|
5
6
|
// ── /todos interactive viewer ──────────────────────────────────────────────────
|
|
6
7
|
|
|
7
8
|
export class TodoViewComponent {
|
|
9
|
+
private cursorIndex: number = 0;
|
|
10
|
+
private showCompleted: boolean = true;
|
|
8
11
|
private cachedWidth?: number;
|
|
9
12
|
private cachedLines?: string[];
|
|
13
|
+
private flatTasks: Task[] = [];
|
|
10
14
|
|
|
11
15
|
constructor(
|
|
12
16
|
private readonly tasks: Task[],
|
|
13
17
|
private readonly theme: any,
|
|
14
18
|
private readonly onClose: () => void,
|
|
15
|
-
) {
|
|
19
|
+
) {
|
|
20
|
+
this.rebuildFlatTasks();
|
|
21
|
+
if (this.flatTasks.length > 0) {
|
|
22
|
+
this.cursorIndex = Math.min(this.cursorIndex, this.flatTasks.length - 1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
handleInput(data: string): void {
|
|
18
27
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
19
28
|
this.onClose();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
33
|
+
if (this.cursorIndex > 0) this.cursorIndex--;
|
|
34
|
+
this.invalidate();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
39
|
+
if (this.cursorIndex < this.flatTasks.length - 1) this.cursorIndex++;
|
|
40
|
+
this.invalidate();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (data === "c") {
|
|
45
|
+
this.showCompleted = !this.showCompleted;
|
|
46
|
+
this.rebuildFlatTasks();
|
|
47
|
+
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.flatTasks.length - 1));
|
|
48
|
+
this.invalidate();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (matchesKey(data, "home") || data === "g") {
|
|
53
|
+
this.cursorIndex = 0;
|
|
54
|
+
this.invalidate();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (matchesKey(data, "end") || data === "G") {
|
|
59
|
+
this.cursorIndex = Math.max(0, this.flatTasks.length - 1);
|
|
60
|
+
this.invalidate();
|
|
61
|
+
return;
|
|
20
62
|
}
|
|
21
63
|
}
|
|
22
64
|
|
|
@@ -31,7 +73,9 @@ export class TodoViewComponent {
|
|
|
31
73
|
const titleText = " Tasks ";
|
|
32
74
|
const sideLen = Math.max(0, width - titleText.length - 3);
|
|
33
75
|
const headerLine =
|
|
34
|
-
th.fg("borderMuted", "─".repeat(3)) +
|
|
76
|
+
th.fg("borderMuted", "─".repeat(3)) +
|
|
77
|
+
th.fg("accent", th.bold(titleText)) +
|
|
78
|
+
th.fg("borderMuted", "─".repeat(sideLen));
|
|
35
79
|
lines.push(truncateToWidth(headerLine, width));
|
|
36
80
|
lines.push("");
|
|
37
81
|
|
|
@@ -41,43 +85,72 @@ export class TodoViewComponent {
|
|
|
41
85
|
// ── Progress bar ──
|
|
42
86
|
const completedCount = this.tasks.filter((t) => t.status === "completed").length;
|
|
43
87
|
const total = this.tasks.length;
|
|
44
|
-
const barWidth = Math.min(
|
|
88
|
+
const barWidth = Math.min(20, width - 22);
|
|
45
89
|
const filled = total > 0 ? Math.round((completedCount / total) * barWidth) : 0;
|
|
46
90
|
const empty = barWidth - filled;
|
|
47
91
|
const bar = `[${th.fg("success", "█".repeat(filled))}${th.fg("dim", "░".repeat(empty))}]`;
|
|
48
|
-
|
|
92
|
+
const pct = total > 0 ? Math.round((completedCount / total) * 100) : 0;
|
|
93
|
+
lines.push(
|
|
94
|
+
truncateToWidth(
|
|
95
|
+
` ${bar} ${th.fg("muted", `${completedCount}/${total}`)} ${th.fg("dim", `(${pct}%)`)}`,
|
|
96
|
+
width,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
49
99
|
lines.push("");
|
|
50
100
|
|
|
51
|
-
// ──
|
|
101
|
+
// ── Build sections ──
|
|
52
102
|
const inProgress = this.tasks.filter((t) => t.status === "in_progress");
|
|
103
|
+
const pending = this.tasks
|
|
104
|
+
.filter((t) => t.status === "pending")
|
|
105
|
+
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
106
|
+
const done = this.tasks.filter((t) => t.status === "completed");
|
|
107
|
+
|
|
108
|
+
let flatIdx = 0;
|
|
109
|
+
|
|
53
110
|
if (inProgress.length > 0) {
|
|
54
|
-
lines.push(
|
|
55
|
-
|
|
111
|
+
lines.push(
|
|
112
|
+
truncateToWidth(
|
|
113
|
+
` ${th.fg("accent", th.bold("In Progress"))} ${th.fg("dim", `(${inProgress.length})`)}`,
|
|
114
|
+
width,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
for (const t of inProgress) {
|
|
118
|
+
lines.push(...this.renderTask(t, width, flatIdx));
|
|
119
|
+
flatIdx++;
|
|
120
|
+
}
|
|
56
121
|
lines.push("");
|
|
57
122
|
}
|
|
58
123
|
|
|
59
|
-
// ── Pending (sorted by priority) ──
|
|
60
|
-
const pending = this.tasks
|
|
61
|
-
.filter((t) => t.status === "pending")
|
|
62
|
-
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
63
124
|
if (pending.length > 0) {
|
|
64
|
-
lines.push(
|
|
65
|
-
|
|
125
|
+
lines.push(
|
|
126
|
+
truncateToWidth(` ${th.fg("muted", th.bold("Pending"))} ${th.fg("dim", `(${pending.length})`)}`, width),
|
|
127
|
+
);
|
|
128
|
+
for (const t of pending) {
|
|
129
|
+
lines.push(...this.renderTask(t, width, flatIdx));
|
|
130
|
+
flatIdx++;
|
|
131
|
+
}
|
|
66
132
|
lines.push("");
|
|
67
133
|
}
|
|
68
134
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
for (const t of done)
|
|
135
|
+
if (done.length > 0 && this.showCompleted) {
|
|
136
|
+
lines.push(
|
|
137
|
+
truncateToWidth(` ${th.fg("dim", th.bold("Completed"))} ${th.fg("dim", `(${done.length})`)}`, width),
|
|
138
|
+
);
|
|
139
|
+
for (const t of done) {
|
|
140
|
+
lines.push(...this.renderTask(t, width, flatIdx));
|
|
141
|
+
flatIdx++;
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
} else if (done.length > 0 && !this.showCompleted) {
|
|
145
|
+
lines.push(truncateToWidth(` ${th.fg("dim", `${done.length} completed — press c to show`)}`, width));
|
|
74
146
|
lines.push("");
|
|
75
147
|
}
|
|
76
148
|
}
|
|
77
149
|
|
|
78
150
|
// ── Footer ──
|
|
79
151
|
lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(width)), width));
|
|
80
|
-
|
|
152
|
+
const keys = ["↑↓ navigate", "c toggle completed", "esc close"];
|
|
153
|
+
lines.push(truncateToWidth(` ${th.fg("dim", keys.join(" • "))}`, width));
|
|
81
154
|
lines.push("");
|
|
82
155
|
|
|
83
156
|
this.cachedWidth = width;
|
|
@@ -85,32 +158,59 @@ export class TodoViewComponent {
|
|
|
85
158
|
return lines;
|
|
86
159
|
}
|
|
87
160
|
|
|
88
|
-
private renderTask(task: Task, width: number): string[] {
|
|
161
|
+
private renderTask(task: Task, width: number, flatIdx: number): string[] {
|
|
89
162
|
const th = this.theme;
|
|
163
|
+
const isFocused = flatIdx === this.cursorIndex;
|
|
90
164
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
165
|
+
// ── Status icon ──
|
|
166
|
+
let icon: string;
|
|
167
|
+
if (task.status === "completed") {
|
|
168
|
+
icon = th.fg("success", "✓");
|
|
169
|
+
} else if (task.status === "in_progress") {
|
|
170
|
+
icon = th.fg("accent", "●");
|
|
171
|
+
} else {
|
|
172
|
+
icon = th.fg("dim", "○");
|
|
173
|
+
}
|
|
97
174
|
|
|
98
|
-
|
|
99
|
-
const
|
|
175
|
+
// ── Priority badge ──
|
|
176
|
+
const pColor = priorityColor(task.priority);
|
|
177
|
+
const pLabel = th.fg(pColor, priorityLabel(task.priority));
|
|
100
178
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
179
|
+
// ── Content ──
|
|
180
|
+
let contentText: string;
|
|
181
|
+
if (task.status === "completed") {
|
|
182
|
+
contentText = th.fg("dim", th.strikethrough(task.content));
|
|
183
|
+
} else if (task.status === "in_progress") {
|
|
184
|
+
contentText = th.fg("text", th.bold(task.content));
|
|
185
|
+
} else {
|
|
186
|
+
contentText = th.fg("muted", task.content);
|
|
187
|
+
}
|
|
107
188
|
|
|
189
|
+
// ── ID hint ──
|
|
108
190
|
const idHint = th.fg("dim", ` [${task.id}]`);
|
|
109
191
|
|
|
110
|
-
|
|
192
|
+
// ── Cursor indicator ──
|
|
193
|
+
const cursor = isFocused ? th.fg("accent", "❯") : " ";
|
|
194
|
+
|
|
195
|
+
return [truncateToWidth(` ${cursor} ${icon} ${pLabel} ${contentText}${idHint}`, width)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private rebuildFlatTasks(): void {
|
|
199
|
+
const inProgress = this.tasks.filter((t) => t.status === "in_progress");
|
|
200
|
+
const pending = this.tasks
|
|
201
|
+
.filter((t) => t.status === "pending")
|
|
202
|
+
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
203
|
+
const done = this.showCompleted ? this.tasks.filter((t) => t.status === "completed") : [];
|
|
204
|
+
|
|
205
|
+
this.flatTasks = [...inProgress, ...pending, ...done];
|
|
111
206
|
}
|
|
112
207
|
|
|
113
|
-
|
|
208
|
+
invalidate(): void {
|
|
209
|
+
this.cachedWidth = undefined;
|
|
210
|
+
this.cachedLines = undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Tool result rendering (inline, used in both tools) ─────────────────────
|
|
114
214
|
|
|
115
215
|
static renderTaskResult(details: TaskDetails | undefined, expanded: boolean, theme: any): any {
|
|
116
216
|
if (!details?.tasks?.length) {
|
|
@@ -119,25 +219,38 @@ export class TodoViewComponent {
|
|
|
119
219
|
|
|
120
220
|
const list = details.tasks;
|
|
121
221
|
const doneCount = list.filter((t) => t.status === "completed").length;
|
|
122
|
-
|
|
222
|
+
const inProgCount = list.filter((t) => t.status === "in_progress").length;
|
|
223
|
+
const total = list.length;
|
|
224
|
+
|
|
225
|
+
// ── Summary line ──
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
if (inProgCount > 0) parts.push(theme.fg("accent", `● ${inProgCount} active`));
|
|
228
|
+
parts.push(theme.fg("success", `✓ ${doneCount}/${total} done`));
|
|
229
|
+
let output = parts.join(" ");
|
|
123
230
|
|
|
231
|
+
// ── Task lines ──
|
|
124
232
|
const display = expanded ? list : list.slice(0, 5);
|
|
125
233
|
for (const t of display) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
let icon: string;
|
|
235
|
+
if (t.status === "completed") {
|
|
236
|
+
icon = theme.fg("success", "✓");
|
|
237
|
+
} else if (t.status === "in_progress") {
|
|
238
|
+
icon = theme.fg("accent", "●");
|
|
239
|
+
} else {
|
|
240
|
+
icon = theme.fg("dim", "○");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const pColor = priorityColor(t.priority);
|
|
244
|
+
const pLabel = theme.fg(pColor, priorityLabel(t.priority));
|
|
245
|
+
|
|
246
|
+
let content: string;
|
|
247
|
+
if (t.status === "completed") {
|
|
248
|
+
content = theme.fg("dim", theme.strikethrough(t.content));
|
|
249
|
+
} else if (t.status === "in_progress") {
|
|
250
|
+
content = theme.fg("text", theme.bold(t.content));
|
|
251
|
+
} else {
|
|
252
|
+
content = theme.fg("muted", t.content);
|
|
253
|
+
}
|
|
141
254
|
|
|
142
255
|
output += `\n${icon} ${pLabel} ${content}`;
|
|
143
256
|
}
|
|
@@ -148,9 +261,4 @@ export class TodoViewComponent {
|
|
|
148
261
|
|
|
149
262
|
return new Text(output, 0, 0);
|
|
150
263
|
}
|
|
151
|
-
|
|
152
|
-
invalidate(): void {
|
|
153
|
-
this.cachedWidth = undefined;
|
|
154
|
-
this.cachedLines = undefined;
|
|
155
|
-
}
|
|
156
264
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* edb-todo
|
|
3
3
|
*
|
|
4
4
|
* Task management extension that prevents "goal drift" — the tendency for
|
|
5
5
|
* agents to lose track of the original plan as context grows.
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* 4. State is stored in tool-result details and reconstructed from the session
|
|
12
12
|
* branch, so /tree navigation and forking work correctly
|
|
13
13
|
*
|
|
14
|
-
* Tools: todo_write
|
|
15
|
-
* todo_read
|
|
16
|
-
*
|
|
14
|
+
* Tools: todo_write — replace the entire task list (atomic update)
|
|
15
|
+
* todo_read — read the current task list
|
|
16
|
+
* todo_remove — remove tasks by ID (permanent deletion)
|
|
17
|
+
* Command: /todos — open interactive full-screen task viewer
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -21,8 +22,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
21
22
|
import { Type } from "typebox";
|
|
22
23
|
import { TodoViewComponent } from "./component";
|
|
23
24
|
import { buildSystemPromptBlock, formatListForLLM } from "./prompt";
|
|
24
|
-
import { TodoWriteParams } from "./schemas";
|
|
25
|
-
import {
|
|
25
|
+
import { TodoRemoveParams, TodoWriteParams } from "./schemas";
|
|
26
|
+
import { reconstructState, store, updateWidget } from "./state";
|
|
26
27
|
import type { TaskDetails, TaskPriority, TaskStatus } from "./types";
|
|
27
28
|
|
|
28
29
|
// ── Extension ──────────────────────────────────────────────────────────────────
|
|
@@ -40,8 +41,6 @@ export default function todoExtension(pi: ExtensionAPI): void {
|
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
// ── System-prompt injection ────────────────────────────────────────────
|
|
43
|
-
// Active tasks are injected at the start of every agent turn so the model
|
|
44
|
-
// always knows its current plan — the core mechanism that prevents goal drift.
|
|
45
44
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
46
45
|
const block = buildSystemPromptBlock();
|
|
47
46
|
if (!block) return;
|
|
@@ -68,26 +67,31 @@ export default function todoExtension(pi: ExtensionAPI): void {
|
|
|
68
67
|
"Before starting a task, call todo_write to set it to 'in_progress'. " +
|
|
69
68
|
"Only one task should be 'in_progress' at a time unless tasks are genuinely parallel.",
|
|
70
69
|
"Immediately after completing a task, call todo_write to mark it 'completed'. " +
|
|
71
|
-
"Do not batch completions — mark each task done as soon as it is finished."
|
|
70
|
+
"Do not batch completions — mark each task done as soon as it is finished. " +
|
|
71
|
+
"Completed tasks remain visible in the list; they are never automatically deleted.",
|
|
72
72
|
"todo_write REPLACES the entire list. Always include ALL tasks (both changed and unchanged) in every call.",
|
|
73
|
+
"To permanently remove tasks, use todo_remove instead of omitting them from todo_write.",
|
|
73
74
|
],
|
|
74
75
|
parameters: TodoWriteParams,
|
|
75
76
|
|
|
76
77
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
85
|
-
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const updated = params.tasks.map((t) => ({
|
|
80
|
+
id: t.id ?? store.generateId(),
|
|
81
|
+
content: t.content,
|
|
82
|
+
status: t.status as TaskStatus,
|
|
83
|
+
priority: t.priority as TaskPriority,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
store.applyStatusTransitions(updated);
|
|
88
|
+
store.setTasks(updated);
|
|
89
|
+
store.syncIdCounter();
|
|
86
90
|
updateWidget(ctx);
|
|
87
91
|
|
|
88
92
|
return {
|
|
89
93
|
content: [{ type: "text", text: formatListForLLM() }],
|
|
90
|
-
details: { tasks: [...tasks] } satisfies TaskDetails,
|
|
94
|
+
details: { tasks: [...store.tasks] } satisfies TaskDetails,
|
|
91
95
|
};
|
|
92
96
|
},
|
|
93
97
|
|
|
@@ -96,9 +100,10 @@ export default function todoExtension(pi: ExtensionAPI): void {
|
|
|
96
100
|
const inProg = list.filter((t: any) => t.status === "in_progress").length;
|
|
97
101
|
const done = list.filter((t: any) => t.status === "completed").length;
|
|
98
102
|
const total = list.length;
|
|
103
|
+
|
|
99
104
|
let text = theme.fg("toolTitle", theme.bold("todo_write "));
|
|
100
105
|
text += theme.fg("muted", `${total} task${total !== 1 ? "s" : ""}`);
|
|
101
|
-
if (inProg > 0) text += ` ${theme.fg("accent",
|
|
106
|
+
if (inProg > 0) text += ` ${theme.fg("accent", `● ${inProg} active`)}`;
|
|
102
107
|
if (done > 0) text += ` ${theme.fg("success", `✓ ${done} done`)}`;
|
|
103
108
|
return new Text(text, 0, 0);
|
|
104
109
|
},
|
|
@@ -119,7 +124,7 @@ export default function todoExtension(pi: ExtensionAPI): void {
|
|
|
119
124
|
async execute() {
|
|
120
125
|
return {
|
|
121
126
|
content: [{ type: "text", text: formatListForLLM() }],
|
|
122
|
-
details: { tasks: [...tasks] } satisfies TaskDetails,
|
|
127
|
+
details: { tasks: [...store.tasks] } satisfies TaskDetails,
|
|
123
128
|
};
|
|
124
129
|
},
|
|
125
130
|
|
|
@@ -132,16 +137,67 @@ export default function todoExtension(pi: ExtensionAPI): void {
|
|
|
132
137
|
},
|
|
133
138
|
});
|
|
134
139
|
|
|
140
|
+
// ── Tool: todo_remove ──────────────────────────────────────────────────
|
|
141
|
+
pi.registerTool({
|
|
142
|
+
name: "todo_remove",
|
|
143
|
+
label: "Tasks",
|
|
144
|
+
description:
|
|
145
|
+
"Permanently remove tasks by ID. Use this to clean up tasks that are no longer relevant. " +
|
|
146
|
+
"Completed tasks normally stay in the list for visibility — only remove if truly unwanted.",
|
|
147
|
+
promptSnippet: "Remove tasks by ID",
|
|
148
|
+
promptGuidelines: [
|
|
149
|
+
"Use todo_remove to permanently delete tasks that are no longer needed. " +
|
|
150
|
+
"Completed tasks remain visible by default — only remove if they clutter the list.",
|
|
151
|
+
],
|
|
152
|
+
parameters: TodoRemoveParams,
|
|
153
|
+
|
|
154
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
155
|
+
const removed = store.removeByIds(params.ids);
|
|
156
|
+
updateWidget(ctx);
|
|
157
|
+
|
|
158
|
+
if (removed.length === 0) {
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: "No matching tasks found." }],
|
|
161
|
+
details: { tasks: [...store.tasks] } satisfies TaskDetails,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: `Removed ${removed.length} task${removed.length !== 1 ? "s" : ""}: ${removed.join(", ")}\n\n${formatListForLLM()}`,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
details: { tasks: [...store.tasks] } satisfies TaskDetails,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
renderCall(args, theme) {
|
|
177
|
+
const ids = (args.ids as string[]) ?? [];
|
|
178
|
+
const idStr = ids.map((id: string) => theme.fg("accent", id)).join(", ");
|
|
179
|
+
return new Text(
|
|
180
|
+
theme.fg("toolTitle", theme.bold("todo_remove ")) + theme.fg("muted", `remove ${idStr}`),
|
|
181
|
+
0,
|
|
182
|
+
0,
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
renderResult(result, { expanded }, theme) {
|
|
187
|
+
return TodoViewComponent.renderTaskResult(result.details as TaskDetails | undefined, expanded, theme);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
135
191
|
// ── Command: /todos ────────────────────────────────────────────────────
|
|
136
192
|
pi.registerCommand("todos", {
|
|
137
193
|
description: "Open the interactive task viewer",
|
|
138
194
|
handler: async (_args, ctx) => {
|
|
139
195
|
if (!ctx.hasUI) {
|
|
140
|
-
ctx.ui.notify(tasks.length === 0 ? "No tasks yet." : formatListForLLM(), "info");
|
|
196
|
+
ctx.ui.notify(store.tasks.length === 0 ? "No tasks yet." : formatListForLLM(), "info");
|
|
141
197
|
return;
|
|
142
198
|
}
|
|
143
199
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
144
|
-
return new TodoViewComponent(tasks, theme, () => done());
|
|
200
|
+
return new TodoViewComponent(store.tasks, theme, () => done());
|
|
145
201
|
});
|
|
146
202
|
},
|
|
147
203
|
});
|
package/src/prompt.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { PRIORITY_ORDER
|
|
1
|
+
import { priorityLabel, store } from "./state";
|
|
2
|
+
import { PRIORITY_ORDER } from "./types";
|
|
3
3
|
|
|
4
4
|
// ── System prompt injection ────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Build a plain-text task block for system-prompt injection.
|
|
8
|
-
* Only injected when there are active (non-completed) tasks.
|
|
9
|
-
*/
|
|
10
6
|
export function buildSystemPromptBlock(): string {
|
|
11
|
-
const active = activeTasks();
|
|
7
|
+
const active = store.activeTasks();
|
|
12
8
|
if (active.length === 0) return "";
|
|
13
9
|
|
|
14
10
|
const lines: string[] = [
|
|
@@ -24,15 +20,15 @@ export function buildSystemPromptBlock(): string {
|
|
|
24
20
|
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
25
21
|
|
|
26
22
|
for (const t of [...inProg, ...pending]) {
|
|
27
|
-
const icon =
|
|
28
|
-
const pLabel = `[${t.priority
|
|
23
|
+
const icon = t.status === "in_progress" ? "●" : "○";
|
|
24
|
+
const pLabel = `[${priorityLabel(t.priority)}]`;
|
|
29
25
|
const suffix = t.status === "in_progress" ? " ← in progress" : "";
|
|
30
26
|
lines.push(`${icon} ${pLabel} ${t.content}${suffix}`);
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
const doneCount = tasks.filter((t) => t.status === "completed").length;
|
|
29
|
+
const doneCount = store.tasks.filter((t) => t.status === "completed").length;
|
|
34
30
|
if (doneCount > 0) {
|
|
35
|
-
lines.push("", `${doneCount}/${tasks.length} tasks completed.`);
|
|
31
|
+
lines.push("", `${doneCount}/${store.tasks.length} tasks completed.`);
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
return lines.join("\n");
|
|
@@ -40,10 +36,12 @@ export function buildSystemPromptBlock(): string {
|
|
|
40
36
|
|
|
41
37
|
// ── LLM text formatter ─────────────────────────────────────────────────────────
|
|
42
38
|
|
|
43
|
-
/** Plain text list returned inside tool results (visible to the LLM). */
|
|
44
39
|
export function formatListForLLM(): string {
|
|
45
|
-
if (tasks.length === 0) return "Task list is empty.";
|
|
46
|
-
return tasks
|
|
47
|
-
.map((t) =>
|
|
40
|
+
if (store.tasks.length === 0) return "Task list is empty.";
|
|
41
|
+
return store.tasks
|
|
42
|
+
.map((t) => {
|
|
43
|
+
const icon = t.status === "in_progress" ? "●" : t.status === "completed" ? "✓" : "○";
|
|
44
|
+
return `${icon} [${priorityLabel(t.priority)}] [${t.id}] ${t.content}`;
|
|
45
|
+
})
|
|
48
46
|
.join("\n");
|
|
49
47
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -33,3 +33,9 @@ export const TodoWriteParams = Type.Object({
|
|
|
33
33
|
"both updated ones and unchanged ones.",
|
|
34
34
|
}),
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
export const TodoRemoveParams = Type.Object({
|
|
38
|
+
ids: Type.Array(Type.String(), {
|
|
39
|
+
description: "Task IDs to remove from the list permanently.",
|
|
40
|
+
}),
|
|
41
|
+
});
|
package/src/state.ts
CHANGED
|
@@ -1,58 +1,106 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Task, TaskDetails } from "./types";
|
|
3
|
-
import { PRIORITY_ORDER } from "./types";
|
|
2
|
+
import type { Task, TaskDetails, TaskPriority, TaskStatus } from "./types";
|
|
4
3
|
|
|
5
|
-
// ──
|
|
4
|
+
// ── TodoStore ──────────────────────────────────────────────────────────────────
|
|
6
5
|
|
|
7
|
-
export
|
|
8
|
-
|
|
6
|
+
export class TodoStore {
|
|
7
|
+
tasks: Task[] = [];
|
|
8
|
+
idCounter: number = 0;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
generateId(): string {
|
|
11
|
+
return `t${++this.idCounter}`;
|
|
12
|
+
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
14
|
+
activeTasks(): Task[] {
|
|
15
|
+
return this.tasks.filter((t) => t.status !== "completed");
|
|
16
|
+
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
18
|
+
setTasks(next: Task[]): void {
|
|
19
|
+
this.tasks = next;
|
|
20
|
+
}
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
syncIdCounter(): void {
|
|
23
|
+
for (const t of this.tasks) {
|
|
24
|
+
const m = t.id.match(/^t(\d+)$/);
|
|
25
|
+
if (m) this.idCounter = Math.max(this.idCounter, parseInt(m[1]!, 10));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
removeByIds(ids: string[]): string[] {
|
|
30
|
+
const removed: string[] = [];
|
|
31
|
+
this.tasks = this.tasks.filter((t) => {
|
|
32
|
+
if (ids.includes(t.id)) {
|
|
33
|
+
removed.push(t.id);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
return removed;
|
|
39
|
+
}
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
41
|
+
/** Apply status transitions and stamp timestamps accordingly. */
|
|
42
|
+
applyStatusTransitions(updated: Task[]): void {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const existing = new Map(this.tasks.map((t) => [t.id, t]));
|
|
45
|
+
|
|
46
|
+
for (const task of updated) {
|
|
47
|
+
const prev = existing.get(task.id);
|
|
48
|
+
if (!prev) {
|
|
49
|
+
// New task — set createdAt
|
|
50
|
+
task.createdAt = task.createdAt ?? now;
|
|
51
|
+
if (task.status === "in_progress") task.startedAt = now;
|
|
52
|
+
if (task.status === "completed") {
|
|
53
|
+
task.startedAt = task.startedAt ?? now;
|
|
54
|
+
task.completedAt = now;
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Existing task — carry forward timestamps, apply transitions
|
|
59
|
+
task.createdAt = prev.createdAt;
|
|
60
|
+
task.startedAt = prev.startedAt;
|
|
61
|
+
task.completedAt = prev.completedAt;
|
|
62
|
+
|
|
63
|
+
if (prev.status !== "in_progress" && task.status === "in_progress") {
|
|
64
|
+
task.startedAt = now;
|
|
65
|
+
}
|
|
66
|
+
if (prev.status !== "completed" && task.status === "completed") {
|
|
67
|
+
task.startedAt = task.startedAt ?? now;
|
|
68
|
+
task.completedAt = now;
|
|
69
|
+
}
|
|
70
|
+
// If reverted from completed back to in_progress/pending, clear completedAt
|
|
71
|
+
if (prev.status === "completed" && task.status !== "completed") {
|
|
72
|
+
task.completedAt = undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
28
75
|
}
|
|
29
76
|
}
|
|
30
77
|
|
|
78
|
+
// ── Singleton ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export const store = new TodoStore();
|
|
81
|
+
|
|
31
82
|
// ── Session reconstruction ─────────────────────────────────────────────────────
|
|
32
83
|
|
|
33
|
-
/**
|
|
34
|
-
* Reconstruct in-memory state by replaying the last todo_write on the branch.
|
|
35
|
-
* Ensures /tree navigation and forking work correctly.
|
|
36
|
-
*/
|
|
37
84
|
export function reconstructState(ctx: ExtensionContext): void {
|
|
38
|
-
tasks = [];
|
|
39
|
-
idCounter = 0;
|
|
85
|
+
store.tasks = [];
|
|
86
|
+
store.idCounter = 0;
|
|
87
|
+
|
|
40
88
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
41
89
|
if (entry.type !== "message") continue;
|
|
42
90
|
const msg = entry.message;
|
|
43
91
|
if (msg.role !== "toolResult" || msg.toolName !== "todo_write") continue;
|
|
44
92
|
const details = msg.details as TaskDetails | undefined;
|
|
45
93
|
if (details?.tasks) {
|
|
46
|
-
tasks = details.tasks;
|
|
47
|
-
syncIdCounter();
|
|
94
|
+
store.tasks = details.tasks;
|
|
95
|
+
store.syncIdCounter();
|
|
48
96
|
}
|
|
49
97
|
}
|
|
50
98
|
}
|
|
51
99
|
|
|
52
|
-
// ──
|
|
100
|
+
// ── Status bar ─────────────────────────────────────────────────────────────────
|
|
53
101
|
|
|
54
102
|
export function updateWidget(ctx: ExtensionContext): void {
|
|
55
|
-
const active = activeTasks();
|
|
103
|
+
const active = store.activeTasks();
|
|
56
104
|
|
|
57
105
|
if (active.length === 0) {
|
|
58
106
|
ctx.ui.setWidget("pi-todo", undefined);
|
|
@@ -63,33 +111,37 @@ export function updateWidget(ctx: ExtensionContext): void {
|
|
|
63
111
|
const th = ctx.ui.theme;
|
|
64
112
|
const inProg = active.filter((t) => t.status === "in_progress");
|
|
65
113
|
const pending = active.filter((t) => t.status === "pending");
|
|
66
|
-
const doneCount = tasks.filter((t) => t.status === "completed").length;
|
|
114
|
+
const doneCount = store.tasks.filter((t) => t.status === "completed").length;
|
|
67
115
|
|
|
68
|
-
// ── Footer status ──
|
|
69
116
|
const parts: string[] = [];
|
|
70
|
-
if (inProg.length > 0) parts.push(th.fg("accent",
|
|
117
|
+
if (inProg.length > 0) parts.push(th.fg("accent", `● ${inProg.length} active`));
|
|
71
118
|
if (pending.length > 0) parts.push(th.fg("muted", `○ ${pending.length} pending`));
|
|
72
119
|
if (doneCount > 0) parts.push(th.fg("success", `✓ ${doneCount} done`));
|
|
73
120
|
ctx.ui.setStatus("pi-todo", parts.join(" "));
|
|
121
|
+
}
|
|
74
122
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for (const t of displayTasks) {
|
|
83
|
-
const icon = t.status === "in_progress" ? th.fg("accent", "→") : th.fg("dim", "○");
|
|
84
|
-
const pColor = t.priority === "high" ? "error" : t.priority === "medium" ? "warning" : "dim";
|
|
85
|
-
const pLabel = th.fg(pColor, t.priority.toUpperCase().slice(0, 3));
|
|
86
|
-
const content = t.status === "in_progress" ? th.fg("text", th.bold(t.content)) : th.fg("muted", t.content);
|
|
87
|
-
widgetLines.push(` ${icon} ${pLabel} ${content}`);
|
|
88
|
-
}
|
|
89
|
-
if (active.length > 4) {
|
|
90
|
-
widgetLines.push(` ${th.fg("dim", `... ${active.length - 4} more (/todos for full list)`)}`);
|
|
91
|
-
}
|
|
92
|
-
widgetLines.push("");
|
|
123
|
+
// ── Shared rendering helpers ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export const PRIORITY_THEME_COLOR: Record<TaskPriority, "error" | "warning" | "dim"> = {
|
|
126
|
+
high: "error",
|
|
127
|
+
medium: "warning",
|
|
128
|
+
low: "dim",
|
|
129
|
+
};
|
|
93
130
|
|
|
94
|
-
|
|
131
|
+
export function priorityColor(p: TaskPriority): "error" | "warning" | "dim" {
|
|
132
|
+
return PRIORITY_THEME_COLOR[p];
|
|
95
133
|
}
|
|
134
|
+
|
|
135
|
+
export function priorityLabel(p: TaskPriority): string {
|
|
136
|
+
return p.toUpperCase().slice(0, 1) + p.slice(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function statusIcon(status: TaskStatus): string {
|
|
140
|
+
return STATUS_ICON[status];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const STATUS_ICON: Record<TaskStatus, string> = {
|
|
144
|
+
pending: "○",
|
|
145
|
+
in_progress: "●",
|
|
146
|
+
completed: "✓",
|
|
147
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface Task {
|
|
|
8
8
|
content: string;
|
|
9
9
|
status: TaskStatus;
|
|
10
10
|
priority: TaskPriority;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
startedAt?: number;
|
|
13
|
+
completedAt?: number;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export interface TaskDetails {
|
|
@@ -18,7 +21,7 @@ export interface TaskDetails {
|
|
|
18
21
|
|
|
19
22
|
export const STATUS_ICON: Record<TaskStatus, string> = {
|
|
20
23
|
pending: "○",
|
|
21
|
-
in_progress: "
|
|
24
|
+
in_progress: "●",
|
|
22
25
|
completed: "✓",
|
|
23
26
|
};
|
|
24
27
|
|