@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/CHANGELOG.md +50 -0
- package/README.md +151 -18
- package/package.json +1 -1
- package/src/auto-clear.ts +87 -0
- package/src/component.ts +314 -69
- package/src/config.ts +25 -0
- package/src/file-store.ts +408 -0
- package/src/index.ts +584 -82
- package/src/process-tracker.ts +146 -0
- package/src/prompt.ts +19 -17
- package/src/schemas.ts +55 -24
- package/src/state.ts +251 -72
- package/src/types.ts +18 -2
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 `
|
|
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 =
|
|
28
|
-
const pLabel = `[${t.priority
|
|
24
|
+
const icon = t.status === "in_progress" ? "●" : "○";
|
|
25
|
+
const pLabel = `[${priorityLabel(t.priority)}]`;
|
|
29
26
|
const suffix = t.status === "in_progress" ? " ← in progress" : "";
|
|
30
|
-
|
|
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 =
|
|
31
|
+
const doneCount = store.list().filter((t) => t.status === "completed").length;
|
|
34
32
|
if (doneCount > 0) {
|
|
35
|
-
lines.push("", `${doneCount}/${
|
|
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
|
-
|
|
44
|
-
|
|
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) =>
|
|
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
|
-
// ──
|
|
4
|
+
// ── TaskCreate schema ──────────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
-
export const
|
|
7
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
15
|
-
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
// ──
|
|
6
|
+
// ── Spinner ───────────────────────────────────────────────────────────────────
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
|
|
9
|
+
const MAX_VISIBLE_TASKS = 10;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
return tasks.filter((t) => t.status !== "completed");
|
|
18
|
-
}
|
|
22
|
+
// ── TodoWidget ────────────────────────────────────────────────────────────────
|
|
19
23
|
|
|
20
|
-
export
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
const active = activeTasks();
|
|
170
|
+
this.widgetFrame++;
|
|
56
171
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
273
|
+
return new Text(output, 0, 0);
|
|
95
274
|
}
|