@agnishc/edb-todo 0.8.2 → 0.10.4

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.4] - 2026-05-15
4
+
5
+ ## [0.10.3] - 2026-05-15
6
+
7
+ ## [0.9.0] - 2026-05-15
8
+
9
+ ### Added
10
+ - `todo_create` tool — create individual tasks with `content`, `description`, `priority`, `activeForm`, and `metadata`
11
+ - `todo_get` tool — retrieve full task details: description, dependencies (`blocks`/`blockedBy`), metadata
12
+ - `todo_update` tool — update individual tasks (status, content, description, priority, owner); add dependency edges (`addBlocks`/`addBlockedBy`); `status: "deleted"` permanently removes the task
13
+ - **File-backed storage** — tasks now persist to disk with file locking and atomic writes
14
+ - `memory`: in-memory only (lost on session end)
15
+ - `session` *(default)*: per-session file at `<cwd>/.pi/tasks/tasks-<sessionId>.json`
16
+ - `project`: shared across all sessions at `<cwd>/.pi/tasks/tasks.json`
17
+ - **Dependency management** — bidirectional `blocks`/`blockedBy` edges with cycle detection and warnings
18
+ - **Auto-clear completed tasks** — configurable via settings: `never` / `on_list_complete` *(default)* / `on_task_complete`; turn-based delay so completions linger briefly before disappearing
19
+ - **Settings panel** — `/todos → ⚙ Settings` opens a native TUI settings panel (taskScope + autoClearCompleted); saved to `<cwd>/.pi/tasks-config.json`
20
+ - **System-reminder injection** — periodic `<system-reminder>` nudges appended to non-task tool results after `REMINDER_INTERVAL` turns of inactivity, encouraging the model to keep tasks up to date
21
+ - **Enhanced widget** — animated star spinner (✳✴✵…) for in-progress tasks, elapsed time display (e.g. `42s`, `2m 5s`), blocked-by hints inline
22
+ - `PI_TODO` environment variable override: `off` (memory only), named list (`~/.pi/tasks/<name>.json`), or absolute/relative path
23
+ - `/todos` command now shows a select-based menu with View / Clear completed / Clear all / Settings
24
+
25
+ ### Changed
26
+ - Widget placement changed from status bar to **above editor** (persistent, always visible)
27
+ - Session state no longer reconstructed from tool-result branch entries — file-backed store is the source of truth
28
+ - `todo_write` now merges `blocks`, `blockedBy`, and `metadata` from existing tasks when a task ID is reused (non-destructive for dependency edges)
29
+ - System prompt injection now includes task IDs and blocked-by info
30
+ - `priorityLabel` now correctly outputs `High`/`Medium`/`Low`
31
+
3
32
  ## [0.8.2] - 2026-05-11
4
33
 
5
34
  ### Added
package/README.md CHANGED
@@ -2,54 +2,166 @@
2
2
 
3
3
  A Pi CLI extension that gives the agent a structured task list to prevent **goal drift** — the tendency for agents to lose track of the original plan as context grows and tool calls accumulate.
4
4
 
5
+ ## Features
6
+
7
+ - **6 LLM tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop` — matching pi-tasks behavior
8
+ - **Persistent widget** — live task list above the editor with animated spinner (✳✽), elapsed time, and blocked-by hints
9
+ - **File-backed storage** — memory / session / project scope with file locking and atomic writes
10
+ - **Dependency management** — bidirectional `blocks` / `blockedBy` edges with cycle detection
11
+ - **Auto-clear completed tasks** — configurable: never / on_list_complete / on_task_complete
12
+ - **System-reminder injection** — periodic nudges when task tools haven't been used recently
13
+ - **Settings panel** — `/todos → ⚙ Settings` (task storage + auto-clear, saved to `tasks-config.json`)
14
+ - **Priority system** — high / medium / low with color coding (Red / Yellow / Dim)
15
+
5
16
  ## How it works
6
17
 
7
- 1. The agent calls `todo_write` to plan multi-step work as an explicit task list
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
- 3. A live widget above the editor shows the current task list at all times
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
18
+ 1. The agent uses `TaskCreate` to plan multi-step work as a structured task list
19
+ 2. Before every agent turn, active tasks are injected into the system prompt
20
+ 3. A live widget above the editor shows tasks with status icons and elapsed time for active tasks
21
+ 4. Tasks persist to disk per-session (or project-wide) and survive session resume
22
+ 5. Completed tasks remain visible until auto-cleared or manually removed
12
23
 
13
24
  ## Tools
14
25
 
15
- | Tool | Description |
16
- |------|-------------|
17
- | `todo_write` | Replace the entire task list (atomic update) always pass all tasks |
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 |
26
+ ### `TaskCreate`
27
+
28
+ Create a structured task. Used proactively for complex multi-step work.
29
+
30
+ | Parameter | Type | Required | Description |
31
+ |-----------|------|----------|-------------|
32
+ | `content` | string | ✓ | Brief actionable title in imperative form |
33
+ | `description` | string | | Detailed context and acceptance criteria |
34
+ | `priority` | `high` \| `medium` \| `low` | | Default: `medium` |
35
+ | `activeForm` | string | | Spinner text when in_progress (e.g., "Running tests") |
36
+ | `metadata` | object | | Arbitrary key-value pairs |
37
+
38
+ ### `TaskList`
39
+
40
+ List all tasks sorted by status (pending first, then in_progress, then completed) and ID.
41
+
42
+ Returns each task's id, content, status, priority, and open blockedBy entries.
43
+
44
+ ### `TaskGet`
45
+
46
+ Get full details for a specific task by ID — including description, dependencies, and metadata.
47
+
48
+ | Parameter | Type | Description |
49
+ |-----------|------|-------------|
50
+ | `id` | string | The task ID |
20
51
 
21
- ## Task statuses
52
+ ### `TaskUpdate`
22
53
 
23
- | Status | Icon | Meaning |
24
- |--------|------|---------|
25
- | `pending` | `○` | Not started |
26
- | `in_progress` | `●` | Actively working — only one at a time |
27
- | `completed` | `✓` | Done — remains in the list |
54
+ Update task fields, status, and dependencies.
28
55
 
29
- ## Task priorities
56
+ | Parameter | Type | Description |
57
+ |-----------|------|-------------|
58
+ | `id` | string | Task ID (required) |
59
+ | `status` | `pending` \| `in_progress` \| `completed` \| `deleted` | New status (`deleted` permanently removes) |
60
+ | `content` | string | New title |
61
+ | `description` | string | New description |
62
+ | `priority` | `high` \| `medium` \| `low` | New priority |
63
+ | `activeForm` | string | Spinner text |
64
+ | `owner` | string | Agent/owner name |
65
+ | `metadata` | object | Shallow merge (set key to `null` to delete) |
66
+ | `addBlocks` | string[] | Task IDs this task blocks |
67
+ | `addBlockedBy` | string[] | Task IDs that block this task |
30
68
 
31
- | Priority | Label | Color |
32
- |----------|-------|-------|
33
- | `high` | High | Red |
34
- | `medium` | Medium | Yellow |
35
- | `low` | Low | Dim |
69
+ Setting `status: "deleted"` permanently removes the task and cleans up all dependency edges.
36
70
 
37
- ## Interactive viewer
71
+ Dependencies are bidirectional — `addBlocks: ["t2"]` on task `t1` also adds `blockedBy: ["t1"]` to task `t2`.
72
+
73
+ ### `TaskOutput`
74
+
75
+ Retrieve output from a running or completed background task process.
76
+
77
+ | Parameter | Type | Default | Description |
78
+ |-----------|------|---------|-------------|
79
+ | `task_id` | string | — | Task ID (required) |
80
+ | `block` | boolean | `true` | Wait for completion |
81
+ | `timeout` | number | `30000` | Max wait time in ms (max 600000) |
82
+
83
+ ### `TaskStop`
84
+
85
+ Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIGKILL. Marks the task as completed.
86
+
87
+ | Parameter | Type | Description |
88
+ |-----------|------|-------------|
89
+ | `task_id` | string | Task ID to stop |
90
+
91
+ ## Task lifecycle
38
92
 
39
93
  ```
40
- /todos — open the full-screen task viewer
94
+ pending in_progress completed
95
+ → deleted (permanently removed)
41
96
  ```
42
97
 
43
- Keyboard controls in the viewer:
98
+ ## Dependency management
99
+
100
+ ```bash
101
+ # Task t2 cannot start until t1 is completed
102
+ TaskUpdate { id: "t2", addBlockedBy: ["t1"] }
103
+ ```
104
+
105
+ Edges are bidirectional. The widget and `TaskList` show open blockers inline (`› blocked by #t1`). Cycles and self-dependencies produce warnings but are stored.
106
+
107
+ ## Task storage
108
+
109
+ Configured via `/todos → ⚙ Settings` or the `PI_TODO` environment variable:
110
+
111
+ | Mode | File | Behaviour |
112
+ |------|------|-----------|
113
+ | `memory` | *(none)* | In-memory only — tasks lost when session ends |
114
+ | `session` *(default)* | `<cwd>/.pi/tasks/tasks-<sessionId>.json` | Per-session, survives resume |
115
+ | `project` | `<cwd>/.pi/tasks/tasks.json` | Shared across all sessions in the project |
116
+
117
+ Settings are saved to `<cwd>/.pi/tasks-config.json`.
118
+
119
+ ### Environment variable override
120
+
121
+ | Variable | Value | Behaviour |
122
+ |----------|-------|-----------|
123
+ | `PI_TODO` | `off` | In-memory only (CI/automation) |
124
+ | `PI_TODO` | `sprint-1` | Named shared list at `~/.pi/tasks/sprint-1.json` |
125
+ | `PI_TODO` | `/abs/path.json` | Explicit absolute file path |
126
+
127
+ ## Auto-clear completed tasks
128
+
129
+ | Mode | Behaviour |
130
+ |------|-----------|
131
+ | `never` | Completed tasks stay visible until manually cleared |
132
+ | `on_list_complete` *(default)* | Cleared after all tasks complete and a few idle turns pass |
133
+ | `on_task_complete` | Each task cleared individually a few turns after completion |
134
+
135
+ ## Widget
136
+
137
+ Persistent task list rendered above the editor:
138
+
139
+ ```
140
+ ● 4 tasks (1 done, 1 in progress, 2 open)
141
+ ✔ Design the API
142
+ ✳ Implementing auth… (42s)
143
+ ◻ Write tests › blocked by #t2
144
+ ◻ Update docs
145
+ ```
146
+
147
+ | Icon | Meaning |
148
+ |------|---------|
149
+ | `✔` | Completed (strikethrough + dim) |
150
+ | `◼` | In-progress |
151
+ | `✳`/`✽` | Animated spinner — actively executing (shows elapsed time) |
152
+ | `◻` | Pending |
153
+
154
+ ## `/todos` command
155
+
156
+ ```
157
+ /todos — open the interactive task manager
158
+ ```
44
159
 
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 |
160
+ Menu options:
161
+ - **View all tasks** — select a task to start / complete / delete it
162
+ - **Clear completed** remove all completed tasks
163
+ - **Clear all** remove all tasks
164
+ - **⚙ Settings** configure task storage and auto-clear
53
165
 
54
166
  ## Install
55
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-todo",
3
- "version": "0.8.2",
3
+ "version": "0.10.4",
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",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * auto-clear.ts — Turn-based auto-clearing of completed tasks.
3
+ *
4
+ * Two modes:
5
+ * - "on_task_complete": each completed task gets its own countdown, deleted individually
6
+ * - "on_list_complete": countdown starts when ALL tasks are completed, cleared as a batch
7
+ */
8
+
9
+ import type { FileTaskStore } from "./file-store.js";
10
+
11
+ export type AutoClearMode = "never" | "on_list_complete" | "on_task_complete";
12
+
13
+ export class AutoClearManager {
14
+ /** Per-task: turn when task was marked completed ("on_task_complete" mode). */
15
+ private completedAtTurn = new Map<string, number>();
16
+ /** Turn when ALL tasks became completed ("on_list_complete" mode). */
17
+ private allCompletedAtTurn: number | null = null;
18
+
19
+ constructor(
20
+ public getStore: () => FileTaskStore,
21
+ private getMode: () => AutoClearMode,
22
+ /** How many turns completed tasks linger before auto-clearing. */
23
+ private clearDelayTurns = 4,
24
+ ) {}
25
+
26
+ /** Record a task completion. Call after updating status. */
27
+ trackCompletion(taskId: string, currentTurn: number): void {
28
+ const mode = this.getMode();
29
+ if (mode === "never") return;
30
+
31
+ if (mode === "on_task_complete") {
32
+ this.completedAtTurn.set(taskId, currentTurn);
33
+ } else if (mode === "on_list_complete") {
34
+ this.checkAllCompleted(currentTurn);
35
+ }
36
+ }
37
+
38
+ private checkAllCompleted(currentTurn: number): void {
39
+ const tasks = this.getStore().list();
40
+ if (tasks.length > 0 && tasks.every((t) => t.status === "completed")) {
41
+ if (this.allCompletedAtTurn === null) this.allCompletedAtTurn = currentTurn;
42
+ } else {
43
+ this.allCompletedAtTurn = null;
44
+ }
45
+ }
46
+
47
+ /** Reset batch countdown (e.g., when a new task is created). */
48
+ resetBatchCountdown(): void {
49
+ this.allCompletedAtTurn = null;
50
+ }
51
+
52
+ /** Reset all tracking state (e.g., on new session). */
53
+ reset(): void {
54
+ this.completedAtTurn.clear();
55
+ this.allCompletedAtTurn = null;
56
+ }
57
+
58
+ /**
59
+ * Called on each turn start. Deletes tasks whose linger period has expired.
60
+ * Returns true if any tasks were cleared.
61
+ */
62
+ onTurnStart(currentTurn: number): boolean {
63
+ const mode = this.getMode();
64
+ let cleared = false;
65
+
66
+ if (mode === "on_task_complete") {
67
+ for (const [taskId, turn] of this.completedAtTurn) {
68
+ const task = this.getStore().get(taskId);
69
+ if (!task || task.status !== "completed") {
70
+ this.completedAtTurn.delete(taskId);
71
+ } else if (currentTurn - turn >= this.clearDelayTurns) {
72
+ this.getStore().delete(taskId);
73
+ this.completedAtTurn.delete(taskId);
74
+ cleared = true;
75
+ }
76
+ }
77
+ } else if (mode === "on_list_complete" && this.allCompletedAtTurn !== null) {
78
+ if (currentTurn - this.allCompletedAtTurn >= this.clearDelayTurns) {
79
+ this.getStore().clearCompleted();
80
+ this.allCompletedAtTurn = null;
81
+ cleared = true;
82
+ }
83
+ }
84
+
85
+ return cleared;
86
+ }
87
+ }
package/src/component.ts CHANGED
@@ -1,7 +1,19 @@
1
- import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
2
- import { priorityColor, priorityLabel } from "./state";
3
- import type { Task, TaskDetails } from "./types";
4
- import { PRIORITY_ORDER } from "./types";
1
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Container,
4
+ matchesKey,
5
+ type SettingItem,
6
+ SettingsList,
7
+ Spacer,
8
+ Text,
9
+ truncateToWidth,
10
+ } from "@earendil-works/pi-tui";
11
+ import type { TodoConfig } from "./config.js";
12
+ import { saveTodoConfig } from "./config.js";
13
+ import type { FileTaskStore } from "./file-store.js";
14
+ import { priorityColor, priorityLabel, renderTaskListResult } from "./state.js";
15
+ import type { Task, TaskDetails } from "./types.js";
16
+ import { PRIORITY_ORDER } from "./types.js";
5
17
 
6
18
  // ── /todos interactive viewer ──────────────────────────────────────────────────
7
19
 
@@ -28,19 +40,16 @@ export class TodoViewComponent {
28
40
  this.onClose();
29
41
  return;
30
42
  }
31
-
32
43
  if (matchesKey(data, "up") || data === "k") {
33
44
  if (this.cursorIndex > 0) this.cursorIndex--;
34
45
  this.invalidate();
35
46
  return;
36
47
  }
37
-
38
48
  if (matchesKey(data, "down") || data === "j") {
39
49
  if (this.cursorIndex < this.flatTasks.length - 1) this.cursorIndex++;
40
50
  this.invalidate();
41
51
  return;
42
52
  }
43
-
44
53
  if (data === "c") {
45
54
  this.showCompleted = !this.showCompleted;
46
55
  this.rebuildFlatTasks();
@@ -48,13 +57,11 @@ export class TodoViewComponent {
48
57
  this.invalidate();
49
58
  return;
50
59
  }
51
-
52
60
  if (matchesKey(data, "home") || data === "g") {
53
61
  this.cursorIndex = 0;
54
62
  this.invalidate();
55
63
  return;
56
64
  }
57
-
58
65
  if (matchesKey(data, "end") || data === "G") {
59
66
  this.cursorIndex = Math.max(0, this.flatTasks.length - 1);
60
67
  this.invalidate();
@@ -162,7 +169,6 @@ export class TodoViewComponent {
162
169
  const th = this.theme;
163
170
  const isFocused = flatIdx === this.cursorIndex;
164
171
 
165
- // ── Status icon ──
166
172
  let icon: string;
167
173
  if (task.status === "completed") {
168
174
  icon = th.fg("success", "✓");
@@ -172,11 +178,9 @@ export class TodoViewComponent {
172
178
  icon = th.fg("dim", "○");
173
179
  }
174
180
 
175
- // ── Priority badge ──
176
181
  const pColor = priorityColor(task.priority);
177
182
  const pLabel = th.fg(pColor, priorityLabel(task.priority));
178
183
 
179
- // ── Content ──
180
184
  let contentText: string;
181
185
  if (task.status === "completed") {
182
186
  contentText = th.fg("dim", th.strikethrough(task.content));
@@ -186,13 +190,25 @@ export class TodoViewComponent {
186
190
  contentText = th.fg("muted", task.content);
187
191
  }
188
192
 
189
- // ── ID hint ──
190
193
  const idHint = th.fg("dim", ` [${task.id}]`);
191
-
192
- // ── Cursor indicator ──
193
194
  const cursor = isFocused ? th.fg("accent", "❯") : " ";
194
195
 
195
- return [truncateToWidth(` ${cursor} ${icon} ${pLabel} ${contentText}${idHint}`, width)];
196
+ // Dependency hint
197
+ let depHint = "";
198
+ if (task.blockedBy.length > 0) {
199
+ const openBlockers = task.blockedBy.filter((_bid) => {
200
+ // We only have the flat list, use basic check
201
+ return true; // shown for visibility
202
+ });
203
+ if (openBlockers.length > 0) {
204
+ depHint = th.fg("dim", ` ← blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}`);
205
+ }
206
+ }
207
+ if (task.blocks.length > 0) {
208
+ depHint += th.fg("dim", ` → blocks ${task.blocks.map((id) => `#${id}`).join(", ")}`);
209
+ }
210
+
211
+ return [truncateToWidth(` ${cursor} ${icon} ${pLabel} ${contentText}${idHint}${depHint}`, width)];
196
212
  }
197
213
 
198
214
  private rebuildFlatTasks(): void {
@@ -201,7 +217,6 @@ export class TodoViewComponent {
201
217
  .filter((t) => t.status === "pending")
202
218
  .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
203
219
  const done = this.showCompleted ? this.tasks.filter((t) => t.status === "completed") : [];
204
-
205
220
  this.flatTasks = [...inProgress, ...pending, ...done];
206
221
  }
207
222
 
@@ -210,55 +225,177 @@ export class TodoViewComponent {
210
225
  this.cachedLines = undefined;
211
226
  }
212
227
 
213
- // ── Tool result rendering (inline, used in both tools) ─────────────────────
228
+ // ── Static tool result renderer ────────────────────────────────────────────
214
229
 
215
230
  static renderTaskResult(details: TaskDetails | undefined, expanded: boolean, theme: any): any {
216
- if (!details?.tasks?.length) {
217
- return new Text(theme.fg("dim", "Task list cleared"), 0, 0);
218
- }
231
+ return renderTaskListResult(details?.tasks ?? [], expanded, theme);
232
+ }
233
+ }
234
+
235
+ // ── Settings panel ──────────────────────────────────────────────────────────────
236
+
237
+ export async function openTodoSettings(ui: any, cfg: TodoConfig, cwd: string, clearDelayTurns: number): Promise<void> {
238
+ await ui.custom((_tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
239
+ const items: SettingItem[] = [
240
+ {
241
+ id: "taskScope",
242
+ label: "Task storage",
243
+ description:
244
+ "memory: tasks live only in memory, lost when session ends. " +
245
+ "session: persisted per session (tasks-<sessionId>.json), survives resume. " +
246
+ "project: shared across all sessions (tasks.json). " +
247
+ "Takes effect on next session start.",
248
+ currentValue: cfg.taskScope ?? "session",
249
+ values: ["memory", "session", "project"],
250
+ },
251
+ {
252
+ id: "autoClearCompleted",
253
+ label: "Auto-clear completed tasks",
254
+ description:
255
+ "never: completed tasks stay visible until manually cleared. " +
256
+ "on_list_complete: cleared automatically after all tasks are done. " +
257
+ "on_task_complete: each task cleared shortly after it completes. " +
258
+ `Clearing lags ~${clearDelayTurns} turns.`,
259
+ currentValue: cfg.autoClearCompleted ?? "on_list_complete",
260
+ values: ["never", "on_list_complete", "on_task_complete"],
261
+ },
262
+ ];
263
+
264
+ const list = new SettingsList(
265
+ items,
266
+ 10,
267
+ getSettingsListTheme(),
268
+ (id, newValue) => {
269
+ if (id === "taskScope") {
270
+ cfg.taskScope = newValue as TodoConfig["taskScope"];
271
+ saveTodoConfig(cwd, cfg);
272
+ }
273
+ if (id === "autoClearCompleted") {
274
+ cfg.autoClearCompleted = newValue as TodoConfig["autoClearCompleted"];
275
+ saveTodoConfig(cwd, cfg);
276
+ }
277
+ },
278
+ () => done(undefined),
279
+ );
219
280
 
220
- const list = details.tasks;
221
- const doneCount = list.filter((t) => t.status === "completed").length;
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(" ");
230
-
231
- // ── Task lines ──
232
- const display = expanded ? list : list.slice(0, 5);
233
- for (const t of display) {
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", "○");
281
+ class SettingsPanel extends Container {
282
+ handleInput(data: string) {
283
+ list.handleInput(data);
241
284
  }
285
+ }
242
286
 
243
- const pColor = priorityColor(t.priority);
244
- const pLabel = theme.fg(pColor, priorityLabel(t.priority));
287
+ const root = new SettingsPanel();
288
+ root.addChild(new Text(theme.bold(theme.fg("accent", "⚙ Todo Settings")), 0, 0));
289
+ root.addChild(new Spacer(1));
290
+ root.addChild(list);
291
+ return root;
292
+ });
293
+ }
245
294
 
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
- }
295
+ // ── /todos detailed task viewer (select-based) ─────────────────────────────────
296
+
297
+ export async function openTodosMenu(
298
+ ui: any,
299
+ store: FileTaskStore,
300
+ cfg: TodoConfig,
301
+ cwd: string,
302
+ onTaskUpdate: (taskId: string, status?: string) => void,
303
+ ): Promise<void> {
304
+ const AUTO_CLEAR_DELAY = 4;
305
+
306
+ const mainMenu = async (): Promise<void> => {
307
+ const tasks = store.list();
308
+ const completedCount = tasks.filter((t) => t.status === "completed").length;
309
+
310
+ const choices: string[] = [`View all tasks (${tasks.length})`];
311
+ if (completedCount > 0) choices.push(`Clear completed (${completedCount})`);
312
+ if (tasks.length > 0) choices.push(`Clear all (${tasks.length})`);
313
+ choices.push("⚙ Settings");
314
+
315
+ const choice = await ui.select("Tasks", choices);
316
+ if (!choice) return;
317
+
318
+ if (choice.startsWith("View")) {
319
+ return viewTasks();
320
+ } else if (choice.startsWith("Clear completed")) {
321
+ store.clearCompleted();
322
+ store.deleteFileIfEmpty();
323
+ onTaskUpdate("", undefined);
324
+ return mainMenu();
325
+ } else if (choice.startsWith("Clear all")) {
326
+ store.clearAll();
327
+ store.deleteFileIfEmpty();
328
+ onTaskUpdate("", undefined);
329
+ return mainMenu();
330
+ } else if (choice.startsWith("⚙")) {
331
+ await openTodoSettings(ui, cfg, cwd, AUTO_CLEAR_DELAY);
332
+ return mainMenu();
333
+ }
334
+ };
254
335
 
255
- output += `\n${icon} ${pLabel} ${content}`;
336
+ const viewTasks = async (): Promise<void> => {
337
+ const tasks = store.list();
338
+ if (tasks.length === 0) {
339
+ await ui.select("No tasks", ["← Back"]);
340
+ return mainMenu();
256
341
  }
257
342
 
258
- if (!expanded && list.length > 5) {
259
- output += `\n${theme.fg("dim", `... ${list.length - 5} more`)}`;
343
+ const icon = (status: string) => {
344
+ if (status === "completed") return "✔";
345
+ if (status === "in_progress") return "◼";
346
+ return "◻";
347
+ };
348
+
349
+ const choices = tasks.map((t) => `${icon(t.status)} #${t.id} [${t.status}] ${t.content}`);
350
+ choices.push("← Back");
351
+
352
+ const selected = await ui.select("Tasks", choices);
353
+ if (!selected || selected === "← Back") return mainMenu();
354
+
355
+ const match = selected.match(/#([a-z0-9]+)/);
356
+ if (match) return viewTaskDetail(match[1]);
357
+ return viewTasks();
358
+ };
359
+
360
+ const viewTaskDetail = async (taskId: string): Promise<void> => {
361
+ const task = store.get(taskId);
362
+ if (!task) return viewTasks();
363
+
364
+ const actions: string[] = [];
365
+ if (task.status === "pending") actions.push("▸ Start (in_progress)");
366
+ if (task.status === "in_progress") actions.push("✓ Complete");
367
+ actions.push("✗ Delete");
368
+ actions.push("← Back");
369
+
370
+ const deps: string[] = [];
371
+ if (task.blockedBy.length > 0) deps.push(`Blocked by: ${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
372
+ if (task.blocks.length > 0) deps.push(`Blocks: ${task.blocks.map((id) => `#${id}`).join(", ")}`);
373
+
374
+ const detailLines = [
375
+ `#${task.id} [${task.status}] ${task.content}`,
376
+ task.description ? `\n${task.description}` : "",
377
+ deps.length > 0 ? `\n${deps.join(" | ")}` : "",
378
+ ]
379
+ .filter(Boolean)
380
+ .join("");
381
+
382
+ const action = await ui.select(detailLines, actions);
383
+
384
+ if (action === "▸ Start (in_progress)") {
385
+ store.update(taskId, { status: "in_progress" });
386
+ onTaskUpdate(taskId, "in_progress");
387
+ return viewTasks();
388
+ } else if (action === "✓ Complete") {
389
+ store.update(taskId, { status: "completed" });
390
+ onTaskUpdate(taskId, "completed");
391
+ return viewTasks();
392
+ } else if (action === "✗ Delete") {
393
+ store.update(taskId, { status: "deleted" });
394
+ onTaskUpdate(taskId, "deleted");
395
+ return viewTasks();
260
396
  }
397
+ return viewTasks();
398
+ };
261
399
 
262
- return new Text(output, 0, 0);
263
- }
400
+ await mainMenu();
264
401
  }