@agnishc/edb-todo 0.8.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +145 -33
- package/package.json +1 -1
- package/src/auto-clear.ts +87 -0
- package/src/component.ts +194 -57
- package/src/config.ts +25 -0
- package/src/file-store.ts +408 -0
- package/src/index.ts +554 -108
- package/src/process-tracker.ts +146 -0
- package/src/prompt.ts +15 -11
- package/src/schemas.ts +52 -27
- package/src/state.ts +224 -97
- package/src/types.ts +14 -1
|
@@ -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 {
|
|
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
|
-
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 `
|
|
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
|
-
|
|
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.
|
|
31
|
+
const doneCount = store.list().filter((t) => t.status === "completed").length;
|
|
30
32
|
if (doneCount > 0) {
|
|
31
|
-
lines.push("", `${doneCount}/${store.
|
|
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
|
-
|
|
41
|
-
return
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
"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 {
|
|
2
|
-
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";
|
|
3
5
|
|
|
4
|
-
// ──
|
|
6
|
+
// ── Spinner ───────────────────────────────────────────────────────────────────
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
idCounter: number = 0;
|
|
8
|
+
const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
|
|
9
|
+
const MAX_VISIBLE_TASKS = 10;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
40
|
+
setStore(store: FileTaskStore) {
|
|
41
|
+
this.store = store;
|
|
16
42
|
}
|
|
17
43
|
|
|
18
|
-
|
|
19
|
-
this.
|
|
44
|
+
setUICtx(ctx: ExtensionUIContext) {
|
|
45
|
+
this.uiCtx = ctx;
|
|
20
46
|
}
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
81
135
|
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
170
|
+
this.widgetFrame++;
|
|
101
171
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
227
|
+
return p.charAt(0).toUpperCase() + p.slice(1);
|
|
137
228
|
}
|
|
138
229
|
|
|
139
230
|
export function statusIcon(status: TaskStatus): string {
|
|
140
|
-
return
|
|
231
|
+
if (status === "completed") return "✓";
|
|
232
|
+
if (status === "in_progress") return "●";
|
|
233
|
+
return "○";
|
|
141
234
|
}
|
|
142
235
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|