@agnishc/edb-todo 0.8.1 → 0.10.3

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/src/component.ts CHANGED
@@ -1,22 +1,71 @@
1
- import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
2
- import type { Task, TaskDetails } from "./types";
3
- import { PRIORITY_ORDER, STATUS_ICON } 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";
4
17
 
5
18
  // ── /todos interactive viewer ──────────────────────────────────────────────────
6
19
 
7
20
  export class TodoViewComponent {
21
+ private cursorIndex: number = 0;
22
+ private showCompleted: boolean = true;
8
23
  private cachedWidth?: number;
9
24
  private cachedLines?: string[];
25
+ private flatTasks: Task[] = [];
10
26
 
11
27
  constructor(
12
28
  private readonly tasks: Task[],
13
29
  private readonly theme: any,
14
30
  private readonly onClose: () => void,
15
- ) {}
31
+ ) {
32
+ this.rebuildFlatTasks();
33
+ if (this.flatTasks.length > 0) {
34
+ this.cursorIndex = Math.min(this.cursorIndex, this.flatTasks.length - 1);
35
+ }
36
+ }
16
37
 
17
38
  handleInput(data: string): void {
18
39
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
19
40
  this.onClose();
41
+ return;
42
+ }
43
+ if (matchesKey(data, "up") || data === "k") {
44
+ if (this.cursorIndex > 0) this.cursorIndex--;
45
+ this.invalidate();
46
+ return;
47
+ }
48
+ if (matchesKey(data, "down") || data === "j") {
49
+ if (this.cursorIndex < this.flatTasks.length - 1) this.cursorIndex++;
50
+ this.invalidate();
51
+ return;
52
+ }
53
+ if (data === "c") {
54
+ this.showCompleted = !this.showCompleted;
55
+ this.rebuildFlatTasks();
56
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.flatTasks.length - 1));
57
+ this.invalidate();
58
+ return;
59
+ }
60
+ if (matchesKey(data, "home") || data === "g") {
61
+ this.cursorIndex = 0;
62
+ this.invalidate();
63
+ return;
64
+ }
65
+ if (matchesKey(data, "end") || data === "G") {
66
+ this.cursorIndex = Math.max(0, this.flatTasks.length - 1);
67
+ this.invalidate();
68
+ return;
20
69
  }
21
70
  }
22
71
 
@@ -31,7 +80,9 @@ export class TodoViewComponent {
31
80
  const titleText = " Tasks ";
32
81
  const sideLen = Math.max(0, width - titleText.length - 3);
33
82
  const headerLine =
34
- th.fg("borderMuted", "─".repeat(3)) + th.fg("accent", titleText) + th.fg("borderMuted", "─".repeat(sideLen));
83
+ th.fg("borderMuted", "─".repeat(3)) +
84
+ th.fg("accent", th.bold(titleText)) +
85
+ th.fg("borderMuted", "─".repeat(sideLen));
35
86
  lines.push(truncateToWidth(headerLine, width));
36
87
  lines.push("");
37
88
 
@@ -41,43 +92,72 @@ export class TodoViewComponent {
41
92
  // ── Progress bar ──
42
93
  const completedCount = this.tasks.filter((t) => t.status === "completed").length;
43
94
  const total = this.tasks.length;
44
- const barWidth = Math.min(24, width - 20);
95
+ const barWidth = Math.min(20, width - 22);
45
96
  const filled = total > 0 ? Math.round((completedCount / total) * barWidth) : 0;
46
97
  const empty = barWidth - filled;
47
98
  const bar = `[${th.fg("success", "█".repeat(filled))}${th.fg("dim", "░".repeat(empty))}]`;
48
- lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${completedCount}/${total} done`)}`, width));
99
+ const pct = total > 0 ? Math.round((completedCount / total) * 100) : 0;
100
+ lines.push(
101
+ truncateToWidth(
102
+ ` ${bar} ${th.fg("muted", `${completedCount}/${total}`)} ${th.fg("dim", `(${pct}%)`)}`,
103
+ width,
104
+ ),
105
+ );
49
106
  lines.push("");
50
107
 
51
- // ── In Progress ──
108
+ // ── Build sections ──
52
109
  const inProgress = this.tasks.filter((t) => t.status === "in_progress");
110
+ const pending = this.tasks
111
+ .filter((t) => t.status === "pending")
112
+ .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
113
+ const done = this.tasks.filter((t) => t.status === "completed");
114
+
115
+ let flatIdx = 0;
116
+
53
117
  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));
118
+ lines.push(
119
+ truncateToWidth(
120
+ ` ${th.fg("accent", th.bold("In Progress"))} ${th.fg("dim", `(${inProgress.length})`)}`,
121
+ width,
122
+ ),
123
+ );
124
+ for (const t of inProgress) {
125
+ lines.push(...this.renderTask(t, width, flatIdx));
126
+ flatIdx++;
127
+ }
56
128
  lines.push("");
57
129
  }
58
130
 
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
131
  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));
132
+ lines.push(
133
+ truncateToWidth(` ${th.fg("muted", th.bold("Pending"))} ${th.fg("dim", `(${pending.length})`)}`, width),
134
+ );
135
+ for (const t of pending) {
136
+ lines.push(...this.renderTask(t, width, flatIdx));
137
+ flatIdx++;
138
+ }
66
139
  lines.push("");
67
140
  }
68
141
 
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));
142
+ if (done.length > 0 && this.showCompleted) {
143
+ lines.push(
144
+ truncateToWidth(` ${th.fg("dim", th.bold("Completed"))} ${th.fg("dim", `(${done.length})`)}`, width),
145
+ );
146
+ for (const t of done) {
147
+ lines.push(...this.renderTask(t, width, flatIdx));
148
+ flatIdx++;
149
+ }
150
+ lines.push("");
151
+ } else if (done.length > 0 && !this.showCompleted) {
152
+ lines.push(truncateToWidth(` ${th.fg("dim", `${done.length} completed — press c to show`)}`, width));
74
153
  lines.push("");
75
154
  }
76
155
  }
77
156
 
78
157
  // ── Footer ──
79
158
  lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(width)), width));
80
- lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
159
+ const keys = ["↑↓ navigate", "c toggle completed", "esc close"];
160
+ lines.push(truncateToWidth(` ${th.fg("dim", keys.join(" • "))}`, width));
81
161
  lines.push("");
82
162
 
83
163
  this.cachedWidth = width;
@@ -85,72 +165,237 @@ export class TodoViewComponent {
85
165
  return lines;
86
166
  }
87
167
 
88
- private renderTask(task: Task, width: number): string[] {
168
+ private renderTask(task: Task, width: number, flatIdx: number): string[] {
89
169
  const th = this.theme;
170
+ const isFocused = flatIdx === this.cursorIndex;
90
171
 
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);
172
+ let icon: string;
173
+ if (task.status === "completed") {
174
+ icon = th.fg("success", "✓");
175
+ } else if (task.status === "in_progress") {
176
+ icon = th.fg("accent", "●");
177
+ } else {
178
+ icon = th.fg("dim", "○");
179
+ }
97
180
 
98
- const priorityColor = task.priority === "high" ? "error" : task.priority === "medium" ? "warning" : "dim";
99
- const pLabel = th.fg(priorityColor, task.priority.toUpperCase().slice(0, 3));
181
+ const pColor = priorityColor(task.priority);
182
+ const pLabel = th.fg(pColor, priorityLabel(task.priority));
100
183
 
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);
184
+ let contentText: string;
185
+ if (task.status === "completed") {
186
+ contentText = th.fg("dim", th.strikethrough(task.content));
187
+ } else if (task.status === "in_progress") {
188
+ contentText = th.fg("text", th.bold(task.content));
189
+ } else {
190
+ contentText = th.fg("muted", task.content);
191
+ }
107
192
 
108
193
  const idHint = th.fg("dim", ` [${task.id}]`);
194
+ const cursor = isFocused ? th.fg("accent", "❯") : " ";
109
195
 
110
- return [truncateToWidth(` ${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)];
111
212
  }
112
213
 
113
- // ── Tool result rendering ──────────────────────────────────────────────────
214
+ private rebuildFlatTasks(): void {
215
+ const inProgress = this.tasks.filter((t) => t.status === "in_progress");
216
+ const pending = this.tasks
217
+ .filter((t) => t.status === "pending")
218
+ .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
219
+ const done = this.showCompleted ? this.tasks.filter((t) => t.status === "completed") : [];
220
+ this.flatTasks = [...inProgress, ...pending, ...done];
221
+ }
222
+
223
+ invalidate(): void {
224
+ this.cachedWidth = undefined;
225
+ this.cachedLines = undefined;
226
+ }
227
+
228
+ // ── Static tool result renderer ────────────────────────────────────────────
114
229
 
115
230
  static renderTaskResult(details: TaskDetails | undefined, expanded: boolean, theme: any): any {
116
- if (!details?.tasks?.length) {
117
- return new Text(theme.fg("dim", "Task list cleared"), 0, 0);
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
+ );
280
+
281
+ class SettingsPanel extends Container {
282
+ handleInput(data: string) {
283
+ list.handleInput(data);
284
+ }
118
285
  }
119
286
 
120
- const list = details.tasks;
121
- const doneCount = list.filter((t) => t.status === "completed").length;
122
- let output = theme.fg("muted", `${doneCount}/${list.length} completed`);
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
+ }
294
+
295
+ // ── /todos detailed task viewer (select-based) ─────────────────────────────────
123
296
 
124
- const display = expanded ? list : list.slice(0, 5);
125
- 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);
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;
132
305
 
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);
306
+ const mainMenu = async (): Promise<void> => {
307
+ const tasks = store.list();
308
+ const completedCount = tasks.filter((t) => t.status === "completed").length;
141
309
 
142
- output += `\n${icon} ${pLabel} ${content}`;
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();
143
333
  }
334
+ };
144
335
 
145
- if (!expanded && list.length > 5) {
146
- output += `\n${theme.fg("dim", `... ${list.length - 5} more`)}`;
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();
147
341
  }
148
342
 
149
- return new Text(output, 0, 0);
150
- }
343
+ const icon = (status: string) => {
344
+ if (status === "completed") return "✔";
345
+ if (status === "in_progress") return "◼";
346
+ return "◻";
347
+ };
151
348
 
152
- invalidate(): void {
153
- this.cachedWidth = undefined;
154
- this.cachedLines = undefined;
155
- }
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();
396
+ }
397
+ return viewTasks();
398
+ };
399
+
400
+ await mainMenu();
156
401
  }
package/src/config.ts ADDED
@@ -0,0 +1,25 @@
1
+ // <cwd>/.pi/tasks-config.json — persists extension settings across sessions
2
+
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+
6
+ export interface TodoConfig {
7
+ /** Where tasks are stored. Default: "session" */
8
+ taskScope?: "memory" | "session" | "project";
9
+ /** Auto-clear completed tasks. Default: "on_list_complete" */
10
+ autoClearCompleted?: "never" | "on_list_complete" | "on_task_complete";
11
+ }
12
+
13
+ export function loadTodoConfig(cwd: string): TodoConfig {
14
+ try {
15
+ return JSON.parse(readFileSync(join(cwd, ".pi", "tasks-config.json"), "utf-8"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function saveTodoConfig(cwd: string, config: TodoConfig): void {
22
+ const path = join(cwd, ".pi", "tasks-config.json");
23
+ mkdirSync(dirname(path), { recursive: true });
24
+ writeFileSync(path, JSON.stringify(config, null, 2));
25
+ }