@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.
@@ -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,20 +1,17 @@
1
- import { activeTasks, tasks } from "./state";
2
- import { PRIORITY_ORDER, STATUS_ICON } 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
- /**
7
- * Build a plain-text task block for system-prompt injection.
8
- * Only injected when there are active (non-completed) tasks.
9
- */
10
- export function buildSystemPromptBlock(): string {
11
- const active = activeTasks();
7
+ export function buildSystemPromptBlock(store: FileTaskStore): string {
8
+ const active = store.activeTasks();
12
9
  if (active.length === 0) return "";
13
10
 
14
11
  const lines: string[] = [
15
12
  "## Current Task List",
16
13
  "",
17
- "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:",
18
15
  "",
19
16
  ];
20
17
 
@@ -24,15 +21,16 @@ export function buildSystemPromptBlock(): string {
24
21
  .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
25
22
 
26
23
  for (const t of [...inProg, ...pending]) {
27
- const icon = STATUS_ICON[t.status];
28
- const pLabel = `[${t.priority.toUpperCase().slice(0, 3)}]`;
24
+ const icon = t.status === "in_progress" ? "●" : "○";
25
+ const pLabel = `[${priorityLabel(t.priority)}]`;
29
26
  const suffix = t.status === "in_progress" ? " ← in progress" : "";
30
- 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}`);
31
29
  }
32
30
 
33
- const doneCount = tasks.filter((t) => t.status === "completed").length;
31
+ const doneCount = store.list().filter((t) => t.status === "completed").length;
34
32
  if (doneCount > 0) {
35
- lines.push("", `${doneCount}/${tasks.length} tasks completed.`);
33
+ lines.push("", `${doneCount}/${store.list().length} tasks completed.`);
36
34
  }
37
35
 
38
36
  return lines.join("\n");
@@ -40,10 +38,14 @@ export function buildSystemPromptBlock(): string {
40
38
 
41
39
  // ── LLM text formatter ─────────────────────────────────────────────────────────
42
40
 
43
- /** Plain text list returned inside tool results (visible to the LLM). */
44
- export function formatListForLLM(): string {
41
+ export function formatListForLLM(store: FileTaskStore): string {
42
+ const tasks = store.list();
45
43
  if (tasks.length === 0) return "Task list is empty.";
46
44
  return tasks
47
- .map((t) => `${STATUS_ICON[t.status]} [${t.priority.toUpperCase().slice(0, 3)}] [${t.id}] ${t.content}`)
45
+ .map((t) => {
46
+ const icon = t.status === "in_progress" ? "●" : t.status === "completed" ? "✓" : "○";
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}`;
49
+ })
48
50
  .join("\n");
49
51
  }
package/src/schemas.ts CHANGED
@@ -1,35 +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." }),
33
+ });
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
+
64
+ export const TodoRemoveParams = Type.Object({
65
+ ids: Type.Array(Type.String(), { description: "Task IDs to remove from the list permanently." }),
35
66
  });
package/src/state.ts CHANGED
@@ -1,95 +1,274 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Task, TaskDetails } from "./types";
3
- import { PRIORITY_ORDER } 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";
4
5
 
5
- // ── Module state ───────────────────────────────────────────────────────────────
6
+ // ── Spinner ───────────────────────────────────────────────────────────────────
6
7
 
7
- export let tasks: Task[] = [];
8
- export let idCounter: number = 0;
8
+ const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
9
+ const MAX_VISIBLE_TASKS = 10;
9
10
 
10
- // ── State helpers ──────────────────────────────────────────────────────────────
11
-
12
- export function generateId(): string {
13
- return `t${++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`;
14
20
  }
15
21
 
16
- export function activeTasks(): Task[] {
17
- return tasks.filter((t) => t.status !== "completed");
18
- }
22
+ // ── TodoWidget ────────────────────────────────────────────────────────────────
19
23
 
20
- export function setTasks(next: Task[]): void {
21
- tasks = next;
22
- }
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;
23
35
 
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));
36
+ constructor(store: FileTaskStore) {
37
+ this.store = store;
28
38
  }
29
- }
30
39
 
31
- // ── Session reconstruction ─────────────────────────────────────────────────────
32
-
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
- export function reconstructState(ctx: ExtensionContext): void {
38
- tasks = [];
39
- idCounter = 0;
40
- for (const entry of ctx.sessionManager.getBranch()) {
41
- if (entry.type !== "message") continue;
42
- const msg = entry.message;
43
- if (msg.role !== "toolResult" || msg.toolName !== "todo_write") continue;
44
- const details = msg.details as TaskDetails | undefined;
45
- if (details?.tasks) {
46
- tasks = details.tasks;
47
- syncIdCounter();
40
+ setStore(store: FileTaskStore) {
41
+ this.store = store;
42
+ }
43
+
44
+ setUICtx(ctx: ExtensionUIContext) {
45
+ this.uiCtx = ctx;
46
+ }
47
+
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);
48
56
  }
57
+ this.update();
49
58
  }
50
- }
51
59
 
52
- // ── Widget & status bar ────────────────────────────────────────────────────────
60
+ private ensureTimer() {
61
+ if (!this.widgetInterval) {
62
+ this.widgetInterval = setInterval(() => this.update(), 200);
63
+ }
64
+ }
65
+
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)}`)];
85
+
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 = "◻";
99
+ }
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
+ }
111
+ }
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}`;
124
+ }
125
+
126
+ lines.push(truncate(text + suffix));
127
+ }
128
+
129
+ if (tasks.length > MAX_VISIBLE_TASKS) {
130
+ lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
131
+ }
132
+
133
+ return lines;
134
+ }
135
+
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
+ }
152
+
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
+ }
161
+
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;
168
+ }
53
169
 
54
- export function updateWidget(ctx: ExtensionContext): void {
55
- const active = activeTasks();
170
+ this.widgetFrame++;
56
171
 
57
- if (active.length === 0) {
58
- ctx.ui.setWidget("pi-todo", undefined);
59
- ctx.ui.setStatus("pi-todo", undefined);
60
- return;
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;
185
+
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
+ }
198
+ }
199
+
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;
61
211
  }
212
+ }
213
+
214
+ // ── Shared rendering helpers ───────────────────────────────────────────────────
215
+
216
+ export const PRIORITY_THEME_COLOR: Record<TaskPriority, "error" | "warning" | "dim"> = {
217
+ high: "error",
218
+ medium: "warning",
219
+ low: "dim",
220
+ };
221
+
222
+ export function priorityColor(p: TaskPriority): "error" | "warning" | "dim" {
223
+ return PRIORITY_THEME_COLOR[p];
224
+ }
225
+
226
+ export function priorityLabel(p: TaskPriority): string {
227
+ return p.charAt(0).toUpperCase() + p.slice(1);
228
+ }
229
+
230
+ export function statusIcon(status: TaskStatus): string {
231
+ if (status === "completed") return "✓";
232
+ if (status === "in_progress") return "●";
233
+ return "○";
234
+ }
235
+
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);
62
240
 
63
- const th = ctx.ui.theme;
64
- const inProg = active.filter((t) => t.status === "in_progress");
65
- const pending = active.filter((t) => t.status === "pending");
66
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;
67
244
 
68
- // ── Footer status ──
69
245
  const parts: string[] = [];
70
- if (inProg.length > 0) parts.push(th.fg("accent", `→ ${inProg.length} active`));
71
- if (pending.length > 0) parts.push(th.fg("muted", `○ ${pending.length} pending`));
72
- if (doneCount > 0) parts.push(th.fg("success", `✓ ${doneCount} done`));
73
- ctx.ui.setStatus("pi-todo", parts.join(" "));
74
-
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}`);
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}`;
88
267
  }
89
- if (active.length > 4) {
90
- widgetLines.push(` ${th.fg("dim", `... ${active.length - 4} more (/todos for full list)`)}`);
268
+
269
+ if (!expanded && tasks.length > 5) {
270
+ output += `\n${theme.fg("dim", `... ${tasks.length - 5} more`)}`;
91
271
  }
92
- widgetLines.push("");
93
272
 
94
- ctx.ui.setWidget("pi-todo", widgetLines);
273
+ return new Text(output, 0, 0);
95
274
  }