@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 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` | `→` | Actively working — only one at a time |
25
- | `completed` | `✓` | Done |
26
+ | `in_progress` | `●` | Actively working — only one at a time |
27
+ | `completed` | `✓` | Done — remains in the list |
26
28
 
27
- ## Install
29
+ ## Task priorities
28
30
 
29
- ```bash
30
- pi install npm:@agnishc/edb-todo
31
- ```
31
+ | Priority | Label | Color |
32
+ |----------|-------|-------|
33
+ | `high` | High | Red |
34
+ | `medium` | Medium | Yellow |
35
+ | `low` | Low | Dim |
32
36
 
33
- ## Usage
37
+ ## Interactive viewer
34
38
 
35
39
  ```
36
- /todos — open the interactive full-screen task viewer
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-todo",
3
- "version": "0.6.1",
3
+ "version": "0.8.2",
4
4
  "description": "Pi extension: structured task list with live widget and system-prompt injection to prevent goal drift",
5
5
  "keywords": [
6
6
  "pi-package",
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, STATUS_ICON } from "./types";
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)) + th.fg("accent", titleText) + th.fg("borderMuted", "─".repeat(sideLen));
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(24, width - 20);
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
- lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${completedCount}/${total} done`)}`, width));
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
- // ── In Progress ──
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(truncateToWidth(` ${th.fg("accent", "In Progress")}`, width));
55
- for (const t of inProgress) lines.push(...this.renderTask(t, width));
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(truncateToWidth(` ${th.fg("muted", "Pending")}`, width));
65
- for (const t of pending) lines.push(...this.renderTask(t, width));
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
- // ── Completed ──
70
- const done = this.tasks.filter((t) => t.status === "completed");
71
- if (done.length > 0) {
72
- lines.push(truncateToWidth(` ${th.fg("dim", "Completed")}`, width));
73
- for (const t of done) lines.push(...this.renderTask(t, width));
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
- lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
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
- const icon =
92
- task.status === "completed"
93
- ? th.fg("success", STATUS_ICON.completed)
94
- : task.status === "in_progress"
95
- ? th.fg("accent", STATUS_ICON.in_progress)
96
- : th.fg("dim", STATUS_ICON.pending);
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
- const priorityColor = task.priority === "high" ? "error" : task.priority === "medium" ? "warning" : "dim";
99
- const pLabel = th.fg(priorityColor, task.priority.toUpperCase().slice(0, 3));
175
+ // ── Priority badge ──
176
+ const pColor = priorityColor(task.priority);
177
+ const pLabel = th.fg(pColor, priorityLabel(task.priority));
100
178
 
101
- const contentText =
102
- task.status === "completed"
103
- ? th.fg("dim", th.strikethrough(task.content))
104
- : task.status === "in_progress"
105
- ? th.fg("text", th.bold(task.content))
106
- : th.fg("muted", task.content);
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
- return [truncateToWidth(` ${icon} ${pLabel} ${contentText}${idHint}`, width)];
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
- // ── Tool result rendering ──────────────────────────────────────────────────
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
- let output = theme.fg("muted", `${doneCount}/${list.length} completed`);
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
- const icon =
127
- t.status === "completed"
128
- ? theme.fg("success", STATUS_ICON.completed)
129
- : t.status === "in_progress"
130
- ? theme.fg("accent", STATUS_ICON.in_progress)
131
- : theme.fg("dim", STATUS_ICON.pending);
132
-
133
- const pColor = t.priority === "high" ? "error" : t.priority === "medium" ? "warning" : "dim";
134
- const pLabel = theme.fg(pColor, t.priority.toUpperCase().slice(0, 3));
135
- const content =
136
- t.status === "completed"
137
- ? theme.fg("dim", theme.strikethrough(t.content))
138
- : t.status === "in_progress"
139
- ? theme.fg("text", theme.bold(t.content))
140
- : theme.fg("muted", t.content);
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
- * pi-todo
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 — replace the entire task list (atomic update)
15
- * todo_read — read the current task list
16
- * Command: /todos — open interactive full-screen task viewer
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 { generateId, reconstructState, setTasks, syncIdCounter, tasks, updateWidget } from "./state";
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
- setTasks(
78
- params.tasks.map((t) => ({
79
- id: t.id ?? generateId(),
80
- content: t.content,
81
- status: t.status as TaskStatus,
82
- priority: t.priority as TaskPriority,
83
- })),
84
- );
85
- syncIdCounter();
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", `→ ${inProg} active`)}`;
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 { activeTasks, tasks } from "./state";
2
- import { PRIORITY_ORDER, STATUS_ICON } from "./types";
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 = STATUS_ICON[t.status];
28
- const pLabel = `[${t.priority.toUpperCase().slice(0, 3)}]`;
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) => `${STATUS_ICON[t.status]} [${t.priority.toUpperCase().slice(0, 3)}] [${t.id}] ${t.content}`)
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
- // ── Module state ───────────────────────────────────────────────────────────────
4
+ // ── TodoStore ──────────────────────────────────────────────────────────────────
6
5
 
7
- export let tasks: Task[] = [];
8
- export let idCounter: number = 0;
6
+ export class TodoStore {
7
+ tasks: Task[] = [];
8
+ idCounter: number = 0;
9
9
 
10
- // ── State helpers ──────────────────────────────────────────────────────────────
10
+ generateId(): string {
11
+ return `t${++this.idCounter}`;
12
+ }
11
13
 
12
- export function generateId(): string {
13
- return `t${++idCounter}`;
14
- }
14
+ activeTasks(): Task[] {
15
+ return this.tasks.filter((t) => t.status !== "completed");
16
+ }
15
17
 
16
- export function activeTasks(): Task[] {
17
- return tasks.filter((t) => t.status !== "completed");
18
- }
18
+ setTasks(next: Task[]): void {
19
+ this.tasks = next;
20
+ }
19
21
 
20
- export function setTasks(next: Task[]): void {
21
- tasks = next;
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
- export function syncIdCounter(): void {
25
- for (const t of tasks) {
26
- const m = t.id.match(/^t(\d+)$/);
27
- if (m) idCounter = Math.max(idCounter, parseInt(m[1]!, 10));
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
- // ── Widget & status bar ────────────────────────────────────────────────────────
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", `→ ${inProg.length} active`));
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
- // ── Widget: show up to 4 active tasks ─────────────────────────────────
76
- const displayTasks = [
77
- ...inProg,
78
- ...pending.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]),
79
- ].slice(0, 4);
80
-
81
- const widgetLines: string[] = [""];
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
- ctx.ui.setWidget("pi-todo", widgetLines);
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