@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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * process-tracker.ts — Background process management for tasks.
3
+ *
4
+ * Tracks spawned child processes, buffers their output, and supports
5
+ * blocking wait and graceful stop (SIGTERM → 5s → SIGKILL).
6
+ */
7
+
8
+ import type { ChildProcess } from "node:child_process";
9
+
10
+ export interface BackgroundProcess {
11
+ taskId: string;
12
+ pid: number;
13
+ command?: string;
14
+ output: string[];
15
+ status: "running" | "completed" | "error" | "stopped";
16
+ exitCode?: number;
17
+ startedAt: number;
18
+ completedAt?: number;
19
+ proc: ChildProcess;
20
+ waiters: Array<() => void>;
21
+ }
22
+
23
+ export interface ProcessOutput {
24
+ output: string;
25
+ status: BackgroundProcess["status"];
26
+ exitCode?: number;
27
+ startedAt: number;
28
+ completedAt?: number;
29
+ command?: string;
30
+ }
31
+
32
+ export class ProcessTracker {
33
+ private processes = new Map<string, BackgroundProcess>();
34
+
35
+ /** Register a spawned child process for a task. */
36
+ track(taskId: string, proc: ChildProcess, command?: string): void {
37
+ const bp: BackgroundProcess = {
38
+ taskId,
39
+ pid: proc.pid!,
40
+ command,
41
+ output: [],
42
+ status: "running",
43
+ startedAt: Date.now(),
44
+ proc,
45
+ waiters: [],
46
+ };
47
+
48
+ proc.stdout?.on("data", (data: Buffer) => {
49
+ bp.output.push(data.toString());
50
+ });
51
+ proc.stderr?.on("data", (data: Buffer) => {
52
+ bp.output.push(data.toString());
53
+ });
54
+
55
+ proc.on("close", (code) => {
56
+ if (bp.status === "running") bp.status = code === 0 ? "completed" : "error";
57
+ bp.exitCode = code ?? undefined;
58
+ bp.completedAt = Date.now();
59
+ for (const resolve of bp.waiters) resolve();
60
+ bp.waiters = [];
61
+ });
62
+
63
+ proc.on("error", (err) => {
64
+ if (bp.status === "running") {
65
+ bp.status = "error";
66
+ bp.output.push(`Process error: ${err.message}`);
67
+ bp.completedAt = Date.now();
68
+ for (const resolve of bp.waiters) resolve();
69
+ bp.waiters = [];
70
+ }
71
+ });
72
+
73
+ this.processes.set(taskId, bp);
74
+ }
75
+
76
+ /** Get current output and status for a task's process. */
77
+ getOutput(taskId: string): ProcessOutput | undefined {
78
+ const bp = this.processes.get(taskId);
79
+ if (!bp) return undefined;
80
+ return {
81
+ output: bp.output.join(""),
82
+ status: bp.status,
83
+ exitCode: bp.exitCode,
84
+ startedAt: bp.startedAt,
85
+ completedAt: bp.completedAt,
86
+ command: bp.command,
87
+ };
88
+ }
89
+
90
+ /** Wait for a task's process to complete, with timeout. Returns output or undefined on timeout. */
91
+ waitForCompletion(taskId: string, timeout: number, signal?: AbortSignal): Promise<ProcessOutput | undefined> {
92
+ const bp = this.processes.get(taskId);
93
+ if (!bp) return Promise.resolve(undefined);
94
+ if (bp.status !== "running") return Promise.resolve(this.getOutput(taskId));
95
+
96
+ return new Promise<ProcessOutput | undefined>((resolve) => {
97
+ let settled = false;
98
+ const self = this;
99
+
100
+ function finish() {
101
+ if (settled) return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ resolve(self.getOutput(taskId));
105
+ }
106
+
107
+ const timer = setTimeout(finish, timeout);
108
+ bp.waiters.push(finish);
109
+ signal?.addEventListener("abort", finish, { once: true });
110
+ });
111
+ }
112
+
113
+ /** Stop a task's background process gracefully. SIGTERM → wait 5s → SIGKILL. */
114
+ async stop(taskId: string): Promise<boolean> {
115
+ const bp = this.processes.get(taskId);
116
+ if (!bp || bp.status !== "running") return false;
117
+
118
+ bp.status = "stopped";
119
+ bp.proc.kill("SIGTERM");
120
+
121
+ await new Promise<void>((resolve) => {
122
+ const timer = setTimeout(() => {
123
+ try {
124
+ bp.proc.kill("SIGKILL");
125
+ } catch {
126
+ /* already dead */
127
+ }
128
+ resolve();
129
+ }, 5000);
130
+ bp.proc.on("close", () => {
131
+ clearTimeout(timer);
132
+ resolve();
133
+ });
134
+ });
135
+
136
+ bp.completedAt = Date.now();
137
+ for (const resolve of bp.waiters) resolve();
138
+ bp.waiters = [];
139
+ return true;
140
+ }
141
+
142
+ /** Check whether a task has an active tracked process. */
143
+ has(taskId: string): boolean {
144
+ return this.processes.has(taskId);
145
+ }
146
+ }
package/src/prompt.ts CHANGED
@@ -1,16 +1,17 @@
1
- import { priorityLabel, store } from "./state";
2
- import { PRIORITY_ORDER } from "./types";
1
+ import type { FileTaskStore } from "./file-store.js";
2
+ import { priorityLabel } from "./state.js";
3
+ import { PRIORITY_ORDER } from "./types.js";
3
4
 
4
5
  // ── System prompt injection ────────────────────────────────────────────────────
5
6
 
6
- export function buildSystemPromptBlock(): string {
7
+ export function buildSystemPromptBlock(store: FileTaskStore): string {
7
8
  const active = store.activeTasks();
8
9
  if (active.length === 0) return "";
9
10
 
10
11
  const lines: string[] = [
11
12
  "## Current Task List",
12
13
  "",
13
- "You have the following tasks. Update them with `todo_write` as you work:",
14
+ "You have the following tasks. Update them with `TaskCreate` / `TaskUpdate` as you work:",
14
15
  "",
15
16
  ];
16
17
 
@@ -23,12 +24,13 @@ export function buildSystemPromptBlock(): string {
23
24
  const icon = t.status === "in_progress" ? "●" : "○";
24
25
  const pLabel = `[${priorityLabel(t.priority)}]`;
25
26
  const suffix = t.status === "in_progress" ? " ← in progress" : "";
26
- lines.push(`${icon} ${pLabel} ${t.content}${suffix}`);
27
+ const depStr = t.blockedBy.length > 0 ? ` [blocked by ${t.blockedBy.map((id) => `#${id}`).join(", ")}]` : "";
28
+ lines.push(`${icon} [${t.id}] ${pLabel} ${t.content}${suffix}${depStr}`);
27
29
  }
28
30
 
29
- const doneCount = store.tasks.filter((t) => t.status === "completed").length;
31
+ const doneCount = store.list().filter((t) => t.status === "completed").length;
30
32
  if (doneCount > 0) {
31
- lines.push("", `${doneCount}/${store.tasks.length} tasks completed.`);
33
+ lines.push("", `${doneCount}/${store.list().length} tasks completed.`);
32
34
  }
33
35
 
34
36
  return lines.join("\n");
@@ -36,12 +38,14 @@ export function buildSystemPromptBlock(): string {
36
38
 
37
39
  // ── LLM text formatter ─────────────────────────────────────────────────────────
38
40
 
39
- export function formatListForLLM(): string {
40
- if (store.tasks.length === 0) return "Task list is empty.";
41
- return store.tasks
41
+ export function formatListForLLM(store: FileTaskStore): string {
42
+ const tasks = store.list();
43
+ if (tasks.length === 0) return "Task list is empty.";
44
+ return tasks
42
45
  .map((t) => {
43
46
  const icon = t.status === "in_progress" ? "●" : t.status === "completed" ? "✓" : "○";
44
- return `${icon} [${priorityLabel(t.priority)}] [${t.id}] ${t.content}`;
47
+ const dep = t.blockedBy.length > 0 ? ` [blocked by ${t.blockedBy.map((id) => `#${id}`).join(", ")}]` : "";
48
+ return `${icon} [${priorityLabel(t.priority)}] [${t.id}] ${t.content}${dep}`;
45
49
  })
46
50
  .join("\n");
47
51
  }
package/src/schemas.ts CHANGED
@@ -1,41 +1,66 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
3
 
4
- // ── Tool schemas ───────────────────────────────────────────────────────────────
4
+ // ── TaskCreate schema ──────────────────────────────────────────────────────────
5
5
 
6
- export const TaskSchema = Type.Object({
7
- id: Type.Optional(
6
+ export const TodoCreateParams = Type.Object({
7
+ content: Type.String({
8
+ description: "A brief, actionable title in imperative form (e.g., 'Fix authentication bug in login flow').",
9
+ }),
10
+ description: Type.Optional(
11
+ Type.String({
12
+ description: "Detailed description of what needs to be done, including context and acceptance criteria.",
13
+ }),
14
+ ),
15
+ priority: Type.Optional(
16
+ StringEnum(["high", "medium", "low"] as const, { description: "Task priority. Defaults to 'medium'." }),
17
+ ),
18
+ activeForm: Type.Optional(
8
19
  Type.String({
9
20
  description:
10
- "Unique task ID. Omit to auto-generate. " +
11
- "When updating existing tasks use their current ID to preserve identity.",
21
+ "Present continuous form shown in the spinner when in_progress (e.g., 'Fixing authentication bug').",
12
22
  }),
13
23
  ),
14
- content: Type.String({
15
- description: "Clear, actionable task description.",
16
- }),
17
- status: StringEnum(["pending", "in_progress", "completed"] as const, {
18
- description:
19
- "Task status. " +
20
- "Set to 'in_progress' for the task you are actively working on right now. " +
21
- "Only one task should be 'in_progress' at a time unless tasks are genuinely parallel.",
22
- }),
23
- priority: StringEnum(["high", "medium", "low"] as const, {
24
- description: "Task priority.",
25
- }),
24
+ metadata: Type.Optional(
25
+ Type.Record(Type.String(), Type.Any(), { description: "Arbitrary key-value metadata to attach to the task." }),
26
+ ),
26
27
  });
27
28
 
28
- export const TodoWriteParams = Type.Object({
29
- tasks: Type.Array(TaskSchema, {
30
- description:
31
- "The COMPLETE, updated task list. " +
32
- "This REPLACES the current list entirely — always include ALL tasks, " +
33
- "both updated ones and unchanged ones.",
34
- }),
29
+ // ── TaskGet schema ─────────────────────────────────────────────────────────────
30
+
31
+ export const TodoGetParams = Type.Object({
32
+ id: Type.String({ description: "The task ID to retrieve." }),
35
33
  });
36
34
 
35
+ // ── TaskUpdate schema ──────────────────────────────────────────────────────────
36
+
37
+ export const TodoUpdateParams = Type.Object({
38
+ id: Type.String({ description: "The ID of the task to update." }),
39
+ status: Type.Optional(
40
+ Type.Unsafe<"pending" | "in_progress" | "completed" | "deleted">({
41
+ type: "string",
42
+ enum: ["pending", "in_progress", "completed", "deleted"],
43
+ description: "New status. Use 'deleted' to permanently remove the task.",
44
+ }),
45
+ ),
46
+ content: Type.Optional(Type.String({ description: "New task title." })),
47
+ description: Type.Optional(Type.String({ description: "New task description." })),
48
+ priority: Type.Optional(StringEnum(["high", "medium", "low"] as const, { description: "New priority." })),
49
+ activeForm: Type.Optional(Type.String({ description: "Spinner text shown when in_progress." })),
50
+ owner: Type.Optional(Type.String({ description: "Owner / agent name." })),
51
+ metadata: Type.Optional(
52
+ Type.Record(Type.String(), Type.Any(), { description: "Metadata to merge. Set a key to null to delete it." }),
53
+ ),
54
+ addBlocks: Type.Optional(
55
+ Type.Array(Type.String(), { description: "Task IDs that this task blocks (bidirectional)." }),
56
+ ),
57
+ addBlockedBy: Type.Optional(
58
+ Type.Array(Type.String(), { description: "Task IDs that block this task (bidirectional)." }),
59
+ ),
60
+ });
61
+
62
+ // ── Kept for backward compat (unused internally but may be imported by tests) ──
63
+
37
64
  export const TodoRemoveParams = Type.Object({
38
- ids: Type.Array(Type.String(), {
39
- description: "Task IDs to remove from the list permanently.",
40
- }),
65
+ ids: Type.Array(Type.String(), { description: "Task IDs to remove from the list permanently." }),
41
66
  });
package/src/state.ts CHANGED
@@ -1,123 +1,214 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Task, TaskDetails, TaskPriority, TaskStatus } from "./types";
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { FileTaskStore } from "./file-store.js";
4
+ import type { Task, TaskPriority, TaskStatus } from "./types.js";
3
5
 
4
- // ── TodoStore ──────────────────────────────────────────────────────────────────
6
+ // ── Spinner ───────────────────────────────────────────────────────────────────
5
7
 
6
- export class TodoStore {
7
- tasks: Task[] = [];
8
- idCounter: number = 0;
8
+ const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
9
+ const MAX_VISIBLE_TASKS = 10;
9
10
 
10
- generateId(): string {
11
- return `t${++this.idCounter}`;
11
+ function formatDuration(ms: number): string {
12
+ const totalSec = Math.floor(ms / 1000);
13
+ if (totalSec < 60) return `${totalSec}s`;
14
+ const min = Math.floor(totalSec / 60);
15
+ const sec = totalSec % 60;
16
+ if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
17
+ const hr = Math.floor(min / 60);
18
+ const remMin = min % 60;
19
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
20
+ }
21
+
22
+ // ── TodoWidget ────────────────────────────────────────────────────────────────
23
+
24
+ export class TodoWidget {
25
+ private uiCtx: ExtensionUIContext | undefined;
26
+ private store: FileTaskStore;
27
+ private widgetFrame = 0;
28
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
29
+ /** IDs of tasks currently active (show spinner). */
30
+ private activeTaskIds = new Set<string>();
31
+ /** Per-task start time for elapsed display. */
32
+ private taskStartedAt = new Map<string, number>();
33
+ private tui: any | undefined;
34
+ private widgetRegistered = false;
35
+
36
+ constructor(store: FileTaskStore) {
37
+ this.store = store;
12
38
  }
13
39
 
14
- activeTasks(): Task[] {
15
- return this.tasks.filter((t) => t.status !== "completed");
40
+ setStore(store: FileTaskStore) {
41
+ this.store = store;
16
42
  }
17
43
 
18
- setTasks(next: Task[]): void {
19
- this.tasks = next;
44
+ setUICtx(ctx: ExtensionUIContext) {
45
+ this.uiCtx = ctx;
20
46
  }
21
47
 
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));
48
+ setActiveTask(taskId: string | undefined, active = true) {
49
+ if (taskId && active) {
50
+ this.activeTaskIds.add(taskId);
51
+ if (!this.taskStartedAt.has(taskId)) this.taskStartedAt.set(taskId, Date.now());
52
+ this.ensureTimer();
53
+ } else if (taskId) {
54
+ this.activeTaskIds.delete(taskId);
55
+ this.taskStartedAt.delete(taskId);
26
56
  }
57
+ this.update();
27
58
  }
28
59
 
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;
60
+ private ensureTimer() {
61
+ if (!this.widgetInterval) {
62
+ this.widgetInterval = setInterval(() => this.update(), 200);
63
+ }
39
64
  }
40
65
 
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;
66
+ private renderWidget(tui: any, theme: any): string[] {
67
+ const tasks = this.store.list();
68
+ const w: number = tui.terminal?.columns ?? 80;
69
+ const truncate = (line: string) => truncateToWidth(line, w);
70
+
71
+ if (tasks.length === 0) return [];
72
+
73
+ const completed = tasks.filter((t) => t.status === "completed");
74
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
75
+ const pending = tasks.filter((t) => t.status === "pending");
76
+
77
+ const parts: string[] = [];
78
+ if (completed.length > 0) parts.push(`${completed.length} done`);
79
+ if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
80
+ if (pending.length > 0) parts.push(`${pending.length} open`);
81
+ const statusText = `${tasks.length} task${tasks.length !== 1 ? "s" : ""} (${parts.join(", ")})`;
82
+
83
+ const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
84
+ const lines: string[] = [truncate(`${theme.fg("accent", "●")} ${theme.fg("accent", statusText)}`)];
62
85
 
63
- if (prev.status !== "in_progress" && task.status === "in_progress") {
64
- task.startedAt = now;
86
+ const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
87
+ for (const task of visible) {
88
+ const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
89
+
90
+ let icon: string;
91
+ if (isActive) {
92
+ icon = theme.fg("accent", spinnerChar);
93
+ } else if (task.status === "completed") {
94
+ icon = theme.fg("success", "✔");
95
+ } else if (task.status === "in_progress") {
96
+ icon = theme.fg("accent", "◼");
97
+ } else {
98
+ icon = "◻";
65
99
  }
66
- if (prev.status !== "completed" && task.status === "completed") {
67
- task.startedAt = task.startedAt ?? now;
68
- task.completedAt = now;
100
+
101
+ // Blocked-by suffix
102
+ let suffix = "";
103
+ if (task.status === "pending" && task.blockedBy.length > 0) {
104
+ const openBlockers = task.blockedBy.filter((bid) => {
105
+ const blocker = this.store.get(bid);
106
+ return blocker && blocker.status !== "completed";
107
+ });
108
+ if (openBlockers.length > 0) {
109
+ suffix = theme.fg("dim", ` › blocked by ${openBlockers.map((id) => `#${id}`).join(", ")}`);
110
+ }
69
111
  }
70
- // If reverted from completed back to in_progress/pending, clear completedAt
71
- if (prev.status === "completed" && task.status !== "completed") {
72
- task.completedAt = undefined;
112
+
113
+ let text: string;
114
+ if (isActive) {
115
+ const form = task.activeForm || task.content;
116
+ const startedAt = this.taskStartedAt.get(task.id) ?? Date.now();
117
+ const elapsed = formatDuration(Date.now() - startedAt);
118
+ const stats = theme.fg("dim", `(${elapsed})`);
119
+ text = ` ${icon} ${theme.fg("accent", `${form}…`)} ${stats}`;
120
+ } else if (task.status === "completed") {
121
+ text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.content))}`;
122
+ } else {
123
+ text = ` ${icon} ${task.content}`;
73
124
  }
125
+
126
+ lines.push(truncate(text + suffix));
74
127
  }
75
- }
76
- }
77
128
 
78
- // ── Singleton ──────────────────────────────────────────────────────────────────
129
+ if (tasks.length > MAX_VISIBLE_TASKS) {
130
+ lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
131
+ }
79
132
 
80
- export const store = new TodoStore();
133
+ return lines;
134
+ }
81
135
 
82
- // ── Session reconstruction ─────────────────────────────────────────────────────
136
+ update() {
137
+ if (!this.uiCtx) return;
138
+ const tasks = this.store.list();
139
+
140
+ if (tasks.length === 0) {
141
+ if (this.widgetRegistered) {
142
+ this.uiCtx.setWidget("pi-todo", undefined);
143
+ this.uiCtx.setStatus("pi-todo", undefined);
144
+ this.widgetRegistered = false;
145
+ }
146
+ if (this.widgetInterval) {
147
+ clearInterval(this.widgetInterval);
148
+ this.widgetInterval = undefined;
149
+ }
150
+ return;
151
+ }
83
152
 
84
- export function reconstructState(ctx: ExtensionContext): void {
85
- store.tasks = [];
86
- store.idCounter = 0;
153
+ // Prune stale active IDs
154
+ for (const id of this.activeTaskIds) {
155
+ const t = this.store.get(id);
156
+ if (!t || t.status !== "in_progress") {
157
+ this.activeTaskIds.delete(id);
158
+ this.taskStartedAt.delete(id);
159
+ }
160
+ }
87
161
 
88
- for (const entry of ctx.sessionManager.getBranch()) {
89
- if (entry.type !== "message") continue;
90
- const msg = entry.message;
91
- if (msg.role !== "toolResult" || msg.toolName !== "todo_write") continue;
92
- const details = msg.details as TaskDetails | undefined;
93
- if (details?.tasks) {
94
- store.tasks = details.tasks;
95
- store.syncIdCounter();
162
+ const hasActiveSpinner = tasks.some((t) => this.activeTaskIds.has(t.id) && t.status === "in_progress");
163
+ if (hasActiveSpinner) {
164
+ this.ensureTimer();
165
+ } else if (this.widgetInterval) {
166
+ clearInterval(this.widgetInterval);
167
+ this.widgetInterval = undefined;
96
168
  }
97
- }
98
- }
99
169
 
100
- // ── Status bar ─────────────────────────────────────────────────────────────────
170
+ this.widgetFrame++;
101
171
 
102
- export function updateWidget(ctx: ExtensionContext): void {
103
- const active = store.activeTasks();
172
+ if (!this.widgetRegistered) {
173
+ this.uiCtx.setWidget(
174
+ "pi-todo",
175
+ (tui, theme) => {
176
+ this.tui = tui;
177
+ return {
178
+ render: () => this.renderWidget(tui, theme),
179
+ invalidate: () => {},
180
+ };
181
+ },
182
+ { placement: "aboveEditor" },
183
+ );
184
+ this.widgetRegistered = true;
104
185
 
105
- if (active.length === 0) {
106
- ctx.ui.setWidget("pi-todo", undefined);
107
- ctx.ui.setStatus("pi-todo", undefined);
108
- return;
186
+ // Also set status bar
187
+ const active = tasks.filter((t) => t.status !== "completed");
188
+ const inProg = active.filter((t) => t.status === "in_progress");
189
+ const doneCount = tasks.filter((t) => t.status === "completed").length;
190
+ const th = this.uiCtx.theme;
191
+ const parts: string[] = [];
192
+ if (inProg.length > 0) parts.push(th.fg("accent", `● ${inProg.length} active`));
193
+ if (doneCount > 0) parts.push(th.fg("success", `✓ ${doneCount} done`));
194
+ this.uiCtx.setStatus("pi-todo", parts.join(" "));
195
+ } else if (this.tui) {
196
+ this.tui.requestRender();
197
+ }
109
198
  }
110
199
 
111
- const th = ctx.ui.theme;
112
- const inProg = active.filter((t) => t.status === "in_progress");
113
- const pending = active.filter((t) => t.status === "pending");
114
- const doneCount = store.tasks.filter((t) => t.status === "completed").length;
115
-
116
- const parts: string[] = [];
117
- if (inProg.length > 0) parts.push(th.fg("accent", `● ${inProg.length} active`));
118
- if (pending.length > 0) parts.push(th.fg("muted", `○ ${pending.length} pending`));
119
- if (doneCount > 0) parts.push(th.fg("success", `✓ ${doneCount} done`));
120
- ctx.ui.setStatus("pi-todo", parts.join(" "));
200
+ dispose() {
201
+ if (this.widgetInterval) {
202
+ clearInterval(this.widgetInterval);
203
+ this.widgetInterval = undefined;
204
+ }
205
+ if (this.uiCtx) {
206
+ this.uiCtx.setWidget("pi-todo", undefined);
207
+ this.uiCtx.setStatus("pi-todo", undefined);
208
+ }
209
+ this.widgetRegistered = false;
210
+ this.tui = undefined;
211
+ }
121
212
  }
122
213
 
123
214
  // ── Shared rendering helpers ───────────────────────────────────────────────────
@@ -133,15 +224,51 @@ export function priorityColor(p: TaskPriority): "error" | "warning" | "dim" {
133
224
  }
134
225
 
135
226
  export function priorityLabel(p: TaskPriority): string {
136
- return p.toUpperCase().slice(0, 1) + p.slice(1);
227
+ return p.charAt(0).toUpperCase() + p.slice(1);
137
228
  }
138
229
 
139
230
  export function statusIcon(status: TaskStatus): string {
140
- return STATUS_ICON[status];
231
+ if (status === "completed") return "✓";
232
+ if (status === "in_progress") return "●";
233
+ return "○";
141
234
  }
142
235
 
143
- const STATUS_ICON: Record<TaskStatus, string> = {
144
- pending: "○",
145
- in_progress: "●",
146
- completed: "",
147
- };
236
+ // ── Tool result rendering (inline) ─────────────────────────────────────────────
237
+
238
+ export function renderTaskListResult(tasks: Task[], expanded: boolean, theme: any): any {
239
+ if (!tasks?.length) return new Text(theme.fg("dim", "Task list cleared"), 0, 0);
240
+
241
+ const doneCount = tasks.filter((t) => t.status === "completed").length;
242
+ const inProgCount = tasks.filter((t) => t.status === "in_progress").length;
243
+ const total = tasks.length;
244
+
245
+ const parts: string[] = [];
246
+ if (inProgCount > 0) parts.push(theme.fg("accent", `● ${inProgCount} active`));
247
+ parts.push(theme.fg("success", `✓ ${doneCount}/${total} done`));
248
+ let output = parts.join(" ");
249
+
250
+ const display = expanded ? tasks : tasks.slice(0, 5);
251
+ for (const t of display) {
252
+ const icon =
253
+ t.status === "completed"
254
+ ? theme.fg("success", "✓")
255
+ : t.status === "in_progress"
256
+ ? theme.fg("accent", "●")
257
+ : theme.fg("dim", "○");
258
+ const pColor = priorityColor(t.priority);
259
+ const pLabel = theme.fg(pColor, priorityLabel(t.priority));
260
+ const content =
261
+ t.status === "completed"
262
+ ? theme.fg("dim", theme.strikethrough(t.content))
263
+ : t.status === "in_progress"
264
+ ? theme.fg("text", theme.bold(t.content))
265
+ : theme.fg("muted", t.content);
266
+ output += `\n${icon} ${pLabel} ${content}`;
267
+ }
268
+
269
+ if (!expanded && tasks.length > 5) {
270
+ output += `\n${theme.fg("dim", `... ${tasks.length - 5} more`)}`;
271
+ }
272
+
273
+ return new Text(output, 0, 0);
274
+ }