feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import type { TaskState } from "./state.ts";
|
||||
|
||||
export const TODO_AUTO_CLEAR_DELAY_MS = 10_000;
|
||||
|
||||
export interface CompletedTaskAutoClear {
|
||||
sync(forceRestart?: boolean): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function createCompletedTaskAutoClear(
|
||||
state: TaskState,
|
||||
tryClear: () => boolean,
|
||||
delayMs = TODO_AUTO_CLEAR_DELAY_MS,
|
||||
): CompletedTaskAutoClear {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let scheduledKey: string | undefined;
|
||||
|
||||
const completionKey = () => {
|
||||
const tasks = state.list();
|
||||
if (tasks.length === 0 || tasks.some((task) => task.status !== "done")) return undefined;
|
||||
return tasks.map((task) => `${task.id}:${task.status}`).join("|");
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
scheduledKey = undefined;
|
||||
};
|
||||
|
||||
const sync = (forceRestart = false) => {
|
||||
const nextKey = completionKey();
|
||||
if (nextKey === undefined) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (!forceRestart && timer !== undefined && scheduledKey === nextKey) return;
|
||||
|
||||
cancel();
|
||||
scheduledKey = nextKey;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
if (completionKey() !== scheduledKey) return;
|
||||
if (!tryClear()) sync();
|
||||
}, Math.max(0, delayMs));
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
return { sync, cancel };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import net from "node:net";
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows, snapshotStats } from "./ui.ts";
|
||||
|
||||
const SOURCE = "pi-todo-herdr";
|
||||
const TASK_TOKEN = "task";
|
||||
const COUNT_TOKEN = "task_count";
|
||||
const PROGRESS_TOKEN = "task_progress";
|
||||
let reportSeq = Date.now() * 1000;
|
||||
|
||||
function nextSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
export function isHerdrEnvironment(): boolean {
|
||||
return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID;
|
||||
}
|
||||
|
||||
function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map<number, Task>): boolean {
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
if (child.status === status || hasDescendantWithStatus(child, status, byId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function currentTaskTokens(tasks: Task[]): Record<string, string | null> {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const stats = snapshotStats(tasks);
|
||||
const ordered = orderedTreeRows(tasks).map(({ task }) => task);
|
||||
const inProgress = ordered.filter(
|
||||
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
|
||||
);
|
||||
const candidates =
|
||||
inProgress.length > 0
|
||||
? inProgress
|
||||
: ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId));
|
||||
return {
|
||||
[TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null,
|
||||
[COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null,
|
||||
[PROGRESS_TOKEN]: stats.total > 0 && stats.done < stats.total ? `${stats.done}/${stats.total}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentTaskLabel(tasks: Task[]): string | null {
|
||||
const tokens = currentTaskTokens(tasks);
|
||||
const task = tokens[TASK_TOKEN];
|
||||
const count = tokens[COUNT_TOKEN];
|
||||
return task ? `${task}${count ? ` ${count}` : ""}` : null;
|
||||
}
|
||||
|
||||
function sendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!isHerdrEnvironment()) return Promise.resolve(true);
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH!;
|
||||
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const socket = net.createConnection(endpoint);
|
||||
const finish = (delivered: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function send(request: unknown): Promise<void> {
|
||||
if (await sendAttempt(request, 500)) return;
|
||||
await sendAttempt(request, 1500);
|
||||
}
|
||||
|
||||
async function reportTokens(tokens: Record<string, string | null>): Promise<void> {
|
||||
if (!isHerdrEnvironment()) return;
|
||||
await send({
|
||||
id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_metadata",
|
||||
params: {
|
||||
pane_id: process.env.HERDR_PANE_ID,
|
||||
source: SOURCE,
|
||||
tokens,
|
||||
seq: nextSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function reportCurrentTask(tasks: Task[]): Promise<void> {
|
||||
return reportTokens(currentTaskTokens(tasks));
|
||||
}
|
||||
|
||||
export function clearCurrentTask(): Promise<void> {
|
||||
return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MAX_TASKS = 200;
|
||||
export const MAX_TASKS_PER_MUTATION = 50;
|
||||
export const MAX_TASK_DEPTH = 3;
|
||||
export const MAX_TASK_NAME_LENGTH = 200;
|
||||
export const MAX_TASK_DESCRIPTION_LENGTH = 4_000;
|
||||
export const MAX_WIDGET_TASK_ROWS = 12;
|
||||
|
||||
export const TASK_NAME_PATTERN =
|
||||
"^[^\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
export const TASK_DESCRIPTION_PATTERN =
|
||||
"^[^\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
|
||||
const UNSAFE_TASK_TEXT = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/u;
|
||||
|
||||
export function hasUnsafeTaskText(value: string): boolean {
|
||||
return UNSAFE_TASK_TEXT.test(value);
|
||||
}
|
||||
|
||||
export function taskTextLength(value: string): number {
|
||||
return [...value].length;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows } from "./ui.ts";
|
||||
|
||||
export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction";
|
||||
export const COMPACTION_REMINDER =
|
||||
"Active tasks exist. Call list_task now; treat its result as authoritative.";
|
||||
|
||||
export const TASK_PROMPT_GUIDELINES = [
|
||||
"For work with 3+ distinct steps, explore enough to understand the scope, then call set_tasks before implementation; otherwise skip tracking. Start one task in-progress.",
|
||||
"Use patch_tasks when tasks start, block, or finish; keep statuses accurate before the final response.",
|
||||
];
|
||||
|
||||
export function formatCompactTaskTree(tasks: Task[]): string {
|
||||
if (tasks.length === 0) return "No tasks.";
|
||||
return orderedTreeRows(tasks)
|
||||
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function hasUnfinishedTasks(tasks: Task[]): boolean {
|
||||
return tasks.some((task) => task.status !== "done");
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
hasUnsafeTaskText,
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
taskTextLength,
|
||||
} from "./limits.ts";
|
||||
import type {
|
||||
AddTaskDraft,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskTimingSnapshot,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "patch_tasks", "rm_task"]);
|
||||
export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state";
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
const MAX_DATE_MS = 8_640_000_000_000_000;
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
startedAt?: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
return records.map((task) => ({ ...task }));
|
||||
}
|
||||
|
||||
function indexRecords(records: TaskRecord[]): Map<number, TaskRecord> {
|
||||
return new Map(records.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
...(task.description !== undefined ? { description: task.description } : {}),
|
||||
status: task.status,
|
||||
parentId: task.parentId,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
const byId = indexRecords(records);
|
||||
return snapshotTasks(records).map((task) => {
|
||||
const record = byId.get(task.id);
|
||||
if (!record || record.startedAt === undefined) return task;
|
||||
return {
|
||||
...task,
|
||||
startedAt: new Date(record.startedAt).toISOString(),
|
||||
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
|
||||
durationMs: durationMs(record, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
|
||||
const timings: TaskTimingSnapshot[] = [];
|
||||
for (const task of records) {
|
||||
if (task.startedAt === undefined) continue;
|
||||
timings.push({
|
||||
taskId: task.id,
|
||||
startedAt: task.startedAt,
|
||||
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
|
||||
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
|
||||
accumulatedMs: task.accumulatedMs,
|
||||
});
|
||||
}
|
||||
return timings;
|
||||
}
|
||||
|
||||
function startTimer(task: TaskRecord, now: number): void {
|
||||
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
|
||||
task.startedAt ??= startedAt;
|
||||
task.activeStartedAt = startedAt;
|
||||
task.stoppedAt = undefined;
|
||||
}
|
||||
|
||||
function stopTimer(task: TaskRecord, now: number): void {
|
||||
if (task.activeStartedAt === undefined) return;
|
||||
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||
task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is number {
|
||||
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
for (const childId of childIds(records, id)) {
|
||||
result.push(childId);
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
visit(rootId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateGraph(records: TaskRecord[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const byId = indexRecords(records);
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isSafeInteger(task.id) || task.id < 1) {
|
||||
errors.push(`task ${task.id}: id must be a positive safe integer`);
|
||||
}
|
||||
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
|
||||
ids.add(task.id);
|
||||
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
|
||||
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
|
||||
if (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`);
|
||||
if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (task.description !== undefined) {
|
||||
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
|
||||
if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
if (hasUnsafeTaskText(task.description)) {
|
||||
errors.push(`task ${task.id}: description contains an unsafe control character`);
|
||||
}
|
||||
}
|
||||
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||
if (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`);
|
||||
}
|
||||
if (task.startedAt === undefined) {
|
||||
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||
errors.push(`task ${task.id}: timer fields require startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
|
||||
} else {
|
||||
if (!isValidTimestamp(task.startedAt)) {
|
||||
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
|
||||
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") {
|
||||
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
|
||||
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
|
||||
} else {
|
||||
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
|
||||
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
|
||||
}
|
||||
}
|
||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<number>();
|
||||
const visited = new Set<number>();
|
||||
const visit = (id: number): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const parentId = byId.get(id)?.parentId;
|
||||
if (parentId !== null && parentId !== undefined && visit(parentId)) return true;
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const task of records) {
|
||||
if (visit(task.id)) {
|
||||
errors.push(`task ${task.id}: parentId creates a cycle`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
const depths = new Map<number, number>();
|
||||
const depthOf = (task: TaskRecord): number => {
|
||||
const cached = depths.get(task.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
|
||||
const depth = parent === undefined ? 1 : depthOf(parent) + 1;
|
||||
depths.set(task.id, depth);
|
||||
return depth;
|
||||
};
|
||||
for (const task of records) {
|
||||
if (depthOf(task) > MAX_TASK_DEPTH) {
|
||||
errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
for (const task of records) {
|
||||
if (task.status !== "done") continue;
|
||||
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
|
||||
if (unfinished.length > 0) {
|
||||
errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined {
|
||||
const byId = indexRecords(records);
|
||||
const task = byId.get(id);
|
||||
if (!task) return `task ${id}: does not exist`;
|
||||
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
if (!Number.isInteger(position) || position < 0 || position > siblings.length) {
|
||||
return `task ${id}: position must be between 0 and ${siblings.length}`;
|
||||
}
|
||||
|
||||
const recordIndex = records.findIndex((record) => record.id === id);
|
||||
const [record] = records.splice(recordIndex, 1);
|
||||
if (!record) return `task ${id}: does not exist`;
|
||||
|
||||
if (position < siblings.length) {
|
||||
const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]);
|
||||
records.splice(targetIndex, 0, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
const task = records.find((candidate) => candidate.id === id);
|
||||
if (!task) return;
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
const currentIndex = records.findIndex((candidate) => candidate.id === id);
|
||||
const [record] = records.splice(currentIndex, 1);
|
||||
if (!record) return;
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(now: () => number = Date.now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
|
||||
return now;
|
||||
}
|
||||
|
||||
list(): Task[] {
|
||||
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
|
||||
return publicTasks(this.records, now);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
const byId = new Map(this.list().map((task) => [task.id, task]));
|
||||
return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined);
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
const timings = timingSnapshots(this.records);
|
||||
return {
|
||||
tasks: snapshotTasks(this.records),
|
||||
nextId: this.nextId,
|
||||
...(timings.length > 0 ? { timings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
const tasks = this.records;
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
add(drafts: AddTaskDraft[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (drafts.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]);
|
||||
}
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
const now = this.currentTime();
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
let mutationTaskCount = 0;
|
||||
|
||||
const append = (
|
||||
draft: AddTaskDraft,
|
||||
implicitParentId: number | null,
|
||||
path: string,
|
||||
nested: boolean,
|
||||
depth: number,
|
||||
) => {
|
||||
mutationTaskCount += 1;
|
||||
if (mutationTaskCount > MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
return;
|
||||
}
|
||||
if (depth > MAX_TASK_DEPTH) {
|
||||
errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
return;
|
||||
}
|
||||
if (working.length >= MAX_TASKS) {
|
||||
errors.push(`task session is limited to ${MAX_TASKS} tasks`);
|
||||
return;
|
||||
}
|
||||
if (!draft || typeof draft !== "object") {
|
||||
errors.push(`${path}: task must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
|
||||
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (typeof draft.name === "string" && hasUnsafeTaskText(draft.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (draft.description !== undefined && draft.description !== null) {
|
||||
if (typeof draft.description !== "string" || !draft.description.trim()) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
} else {
|
||||
if (hasUnsafeTaskText(draft.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
|
||||
errors.push(`${path}.status is invalid`);
|
||||
}
|
||||
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
errors.push(`${path}.parentId is not allowed inside children`);
|
||||
}
|
||||
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
|
||||
|
||||
let parentId = implicitParentId;
|
||||
if (!nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
|
||||
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
|
||||
} else {
|
||||
parentId = draft.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId >= Number.MAX_SAFE_INTEGER) {
|
||||
errors.push("no safe task IDs remain");
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
const status = draft.status ?? "pending";
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
|
||||
});
|
||||
|
||||
const children = Array.isArray(draft.children) ? draft.children : [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(child, id, `${path}.children[${index}]`, true, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [index, draft] of drafts.entries()) {
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(draft, null, `tasks[${index}]`, false, 1);
|
||||
}
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
this.nextId = nextId;
|
||||
return this.get(addedIds);
|
||||
}
|
||||
|
||||
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
|
||||
if (mode === "append") {
|
||||
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
|
||||
return this.add(drafts);
|
||||
}
|
||||
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
|
||||
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
|
||||
|
||||
const errors: string[] = [];
|
||||
if (this.records.some((task) => task.status !== "done") && force !== true) {
|
||||
errors.push("replace requires force when unfinished tasks exist");
|
||||
}
|
||||
drafts.forEach((draft, index) => {
|
||||
if (draft?.parentId !== undefined && draft.parentId !== null) {
|
||||
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
|
||||
const replacement = new TaskState(this.now);
|
||||
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
||||
this.restore(replacement.snapshot());
|
||||
return affected;
|
||||
}
|
||||
|
||||
update(patches: TaskPatch[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (patches.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]);
|
||||
}
|
||||
|
||||
const duplicateIds = patches
|
||||
.map((patch) => patch.id)
|
||||
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const now = this.currentTime();
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
if (!task) {
|
||||
errors.push(`${path}.id ${patch.id} does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasNameChange = patch.name !== undefined && patch.name !== null;
|
||||
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
|
||||
const hasStatusChange = patch.status !== undefined && patch.status !== null;
|
||||
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
|
||||
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
|
||||
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
|
||||
errors.push(`${path} must include at least one field to update`);
|
||||
}
|
||||
if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) {
|
||||
errors.push(`${path}.name must not be empty`);
|
||||
}
|
||||
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (typeof patch.name === "string" && hasUnsafeTaskText(patch.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (typeof patch.description === "string") {
|
||||
if (hasUnsafeTaskText(patch.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
|
||||
if (
|
||||
hasParentChange &&
|
||||
patch.parentId !== null &&
|
||||
patch.parentId !== 0 &&
|
||||
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
|
||||
) {
|
||||
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
|
||||
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
|
||||
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
|
||||
task.status = patch.status;
|
||||
}
|
||||
if (
|
||||
patch.parentId !== undefined &&
|
||||
patch.parentId !== -1 &&
|
||||
(patch.parentId === null ||
|
||||
patch.parentId === 0 ||
|
||||
working.some((item) => item.id === patch.parentId))
|
||||
) {
|
||||
task.parentId = patch.parentId === 0 ? null : patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
}
|
||||
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
|
||||
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
|
||||
if (positionError) errors.push(`${path}.position: ${positionError}`);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
return this.get(patches.map((patch) => patch.id));
|
||||
}
|
||||
|
||||
remove(ids: number[]): Task[] {
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
|
||||
if (ids.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]);
|
||||
}
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
|
||||
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
|
||||
|
||||
const removedIds = new Set<number>();
|
||||
for (const id of uniqueIds) {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const now = this.currentTime();
|
||||
for (const task of this.records) {
|
||||
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
|
||||
}
|
||||
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) {
|
||||
throw new TaskValidationError(["nextId must be a positive safe integer"]);
|
||||
}
|
||||
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||
}
|
||||
if (snapshot.tasks.length > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]);
|
||||
}
|
||||
if ((snapshot.timings?.length ?? 0) > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]);
|
||||
}
|
||||
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
}));
|
||||
const byId = indexRecords(records);
|
||||
const timingErrors: string[] = [];
|
||||
const timedIds = new Set<number>();
|
||||
|
||||
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
|
||||
const path = `timings[${index}]`;
|
||||
if (!timing || typeof timing !== "object") {
|
||||
timingErrors.push(`${path} must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive safe integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
|
||||
continue;
|
||||
}
|
||||
timedIds.add(timing.taskId);
|
||||
const task = byId.get(timing.taskId);
|
||||
if (!task) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
|
||||
continue;
|
||||
}
|
||||
task.startedAt = timing.startedAt;
|
||||
task.activeStartedAt = timing.activeStartedAt;
|
||||
task.stoppedAt = timing.stoppedAt;
|
||||
task.accumulatedMs = timing.accumulatedMs;
|
||||
}
|
||||
|
||||
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
|
||||
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
|
||||
const now = this.currentTime();
|
||||
for (const task of records) {
|
||||
if (task.status === "in-progress") startTimer(task, now);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = validateGraph(records);
|
||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
this.records = cloneRecords(records);
|
||||
this.nextId = snapshot.nextId;
|
||||
}
|
||||
|
||||
loadFromSession(ctx: ExtensionContext): void {
|
||||
this.records = [];
|
||||
this.nextId = 1;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
let snapshot: TaskSnapshot | undefined;
|
||||
if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) {
|
||||
snapshot = entry.data as TaskSnapshot | undefined;
|
||||
} else if (entry.type === "message") {
|
||||
const message = entry.message;
|
||||
if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) {
|
||||
snapshot = (message.details as TaskToolDetails | undefined)?.snapshot;
|
||||
}
|
||||
}
|
||||
if (!snapshot) continue;
|
||||
try {
|
||||
this.restore(snapshot);
|
||||
} catch {
|
||||
// Ignore corrupt historical snapshots and keep the latest valid state.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type, type Static } from "typebox";
|
||||
import {
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
TASK_DESCRIPTION_PATTERN,
|
||||
TASK_NAME_PATTERN,
|
||||
} from "./limits.ts";
|
||||
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
import { renderToolResult } from "./ui.ts";
|
||||
|
||||
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
|
||||
description: "Task status.",
|
||||
});
|
||||
|
||||
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
|
||||
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic emits bare local refs ("AddTask") instead of URI
|
||||
* references ("#/$defs/AddTask"). Strict JSON Schema gateways (e.g. the
|
||||
* moonshot-flavored validator behind Console Go) reject those with a 400.
|
||||
* Rewrite every local $ref in place to the #/$defs/ form.
|
||||
*/
|
||||
function normalizeLocalRefs<T>(schema: T): T {
|
||||
if (Array.isArray(schema)) {
|
||||
for (const item of schema) normalizeLocalRefs(item);
|
||||
return schema;
|
||||
}
|
||||
if (schema && typeof schema === "object") {
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes("://")) {
|
||||
(schema as Record<string, unknown>)[key] = `#/$defs/${value}`;
|
||||
} else {
|
||||
normalizeLocalRefs(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but
|
||||
* refs like "#/$defs/AddTask" resolve from the document root. Strict gateways
|
||||
* (moonshot-flavored) reject a root-style ref when the definition is nested.
|
||||
* Hoist every $defs entry to the schema root and drop the synthetic $id, which
|
||||
* would otherwise change fragment resolution inside the definition.
|
||||
*/
|
||||
function hoistDefsToRoot<T>(schema: T): T {
|
||||
const defs = new Map<string, unknown>();
|
||||
function walk(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") return;
|
||||
const obj = node as Record<string, unknown>;
|
||||
const nested = obj["$defs"];
|
||||
if (nested && typeof nested === "object") {
|
||||
for (const [name, def] of Object.entries(nested)) {
|
||||
if (!defs.has(name)) defs.set(name, def);
|
||||
}
|
||||
delete obj["$defs"];
|
||||
}
|
||||
for (const value of Object.values(obj)) walk(value);
|
||||
}
|
||||
walk(schema);
|
||||
for (const def of defs.values()) {
|
||||
if (def && typeof def === "object") {
|
||||
delete (def as Record<string, unknown>)["$id"];
|
||||
}
|
||||
}
|
||||
if (defs.size > 0) (schema as Record<string, unknown>)["$defs"] = Object.fromEntries(defs);
|
||||
return schema;
|
||||
}
|
||||
|
||||
const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_NAME_LENGTH,
|
||||
pattern: TASK_NAME_PATTERN,
|
||||
}),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
Type.Null(),
|
||||
],
|
||||
{
|
||||
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
|
||||
},
|
||||
),
|
||||
status: TaskStatusSchema,
|
||||
parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
|
||||
}),
|
||||
children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
"AddTask",
|
||||
);
|
||||
|
||||
export const SetTaskParams = hoistDefsToRoot(
|
||||
normalizeLocalRefs(
|
||||
Type.Object(
|
||||
{
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Boolean({
|
||||
description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const TaskPatchSchema = Type.Object(
|
||||
{
|
||||
id: Type.Integer({ minimum: 1 }),
|
||||
name: Type.Union(
|
||||
[
|
||||
Type.String({ minLength: 1, maxLength: MAX_TASK_NAME_LENGTH, pattern: TASK_NAME_PATTERN }),
|
||||
Type.Null(),
|
||||
],
|
||||
{ description: "New name, or null to leave it unchanged." },
|
||||
),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
Type.Null(),
|
||||
],
|
||||
{ description: "New description, or null to leave it unchanged." },
|
||||
),
|
||||
status: Type.Union([TaskStatusSchema, Type.Null()], {
|
||||
description: "New status, or null to leave it unchanged.",
|
||||
}),
|
||||
parentId: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.",
|
||||
}),
|
||||
position: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "Zero-based position among final siblings, or -1 to leave it unchanged.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const PatchTasksParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(TaskPatchSchema, {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task patches applied atomically in array order.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task IDs to process. Duplicate IDs are deduplicated.",
|
||||
});
|
||||
|
||||
export const RemoveTaskParams = Type.Object(
|
||||
{
|
||||
ids: TaskIdsSchema,
|
||||
reason: RemovalReasonSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
interface DraftPreparationState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function prepareTaskDraft(value: unknown, depth: number, state: DraftPreparationState): unknown {
|
||||
state.count += 1;
|
||||
if (depth > MAX_TASK_DEPTH || state.count > MAX_TASKS_PER_MUTATION) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const children =
|
||||
input.children === undefined
|
||||
? []
|
||||
: Array.isArray(input.children)
|
||||
? input.children
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((child) => prepareTaskDraft(child, depth + 1, state))
|
||||
: input.children;
|
||||
return {
|
||||
...input,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? "pending" : input.status,
|
||||
parentId: input.parentId === undefined ? null : input.parentId,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof SetTaskParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
const state: DraftPreparationState = { count: 0 };
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((task) => prepareTaskDraft(task, 1, state))
|
||||
: input.tasks,
|
||||
force: input.force === undefined ? false : input.force,
|
||||
} as Static<typeof SetTaskParams>;
|
||||
}
|
||||
|
||||
function prepareTaskPatch(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
name: input.name === undefined ? null : input.name,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? null : input.status,
|
||||
parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId,
|
||||
position: input.position === undefined || input.position === null ? -1 : input.position,
|
||||
};
|
||||
}
|
||||
|
||||
function preparePatchTasksArguments(value: unknown): Static<typeof PatchTasksParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof PatchTasksParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
||||
: input.tasks,
|
||||
} as Static<typeof PatchTasksParams>;
|
||||
}
|
||||
|
||||
interface SetTaskInput {
|
||||
mode: "append" | "replace";
|
||||
tasks: AddTaskDraft[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface PatchTasksInput {
|
||||
tasks: TaskPatch[];
|
||||
}
|
||||
|
||||
interface IdsInput {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
interface RemoveTaskInput extends IdsInput {
|
||||
reason: "mistaken" | "duplicate" | "canceled";
|
||||
}
|
||||
|
||||
type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void;
|
||||
|
||||
function taskIds(tasks: Task[]): string {
|
||||
return tasks.map((task) => task.id).join(", ");
|
||||
}
|
||||
|
||||
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
|
||||
return { action, snapshot: state.snapshot(), affected };
|
||||
}
|
||||
|
||||
function mutationText(verb: string, affected: Task[], state: TaskState): string {
|
||||
const stats = state.stats();
|
||||
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
|
||||
}
|
||||
|
||||
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
|
||||
if (mode === "append") return mutationText("Added", affected, state);
|
||||
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
|
||||
const stats = state.stats();
|
||||
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
|
||||
}
|
||||
|
||||
function throwToolError(label: string, error: unknown): never {
|
||||
if (error instanceof TaskValidationError) {
|
||||
const count = error.errors.length;
|
||||
throw new Error(
|
||||
`${label} failed · ${count} validation error${count === 1 ? "" : "s"}\n${error.errors.map((item) => `- ${item}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function renderCall(title: string, count: number | undefined, theme: Theme): Text {
|
||||
const suffix = count === undefined ? "" : theme.fg("dim", ` (${count})`);
|
||||
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
|
||||
}
|
||||
|
||||
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void {
|
||||
try {
|
||||
onChange(ctx, restartAutoClear);
|
||||
} catch (error) {
|
||||
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
|
||||
pi.registerTool({
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
`Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`,
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
prepareArguments: prepareSetTaskArguments,
|
||||
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.set(params.mode, params.tasks, params.force);
|
||||
notifyChange(onChange, ctx, params.mode === "replace");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
||||
details: details(params.mode, state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Set", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: SetTaskInput, theme: Theme) {
|
||||
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "patch_tasks",
|
||||
label: "Patch Tasks",
|
||||
description:
|
||||
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged. Completed task trees may auto-clear; do not recreate cleared completed tasks.",
|
||||
parameters: PatchTasksParams,
|
||||
prepareArguments: preparePatchTasksArguments,
|
||||
async execute(_toolCallId, params: PatchTasksInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
if (state.list().length === 0) {
|
||||
throw new TaskValidationError([
|
||||
"task tree is empty and may have auto-cleared after completion; do not recreate completed tasks. Use set_tasks only for genuinely new or remaining work",
|
||||
]);
|
||||
}
|
||||
const affected = state.update(params.tasks);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Updated", affected, state) }],
|
||||
details: details("update", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Patch", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: PatchTasksInput, theme: Theme) {
|
||||
return renderCall("Patch Tasks", args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "rm_task",
|
||||
label: "Remove Tasks",
|
||||
description:
|
||||
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
|
||||
parameters: RemoveTaskParams,
|
||||
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.remove(params.ids);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
|
||||
},
|
||||
],
|
||||
details: details("remove", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Remove", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: RemoveTaskInput, theme: Theme) {
|
||||
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "list_task",
|
||||
label: "List Tasks",
|
||||
description: "List the full task tree compactly with id, name, and status.",
|
||||
parameters: ListTaskParams,
|
||||
async execute() {
|
||||
const affected = state.list();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
|
||||
details: details("list", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(_args, theme: Theme) {
|
||||
return renderCall("List Tasks", undefined, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "get_task",
|
||||
label: "Get Tasks",
|
||||
description: "Get complete details for one or more task IDs, including parent and child IDs.",
|
||||
parameters: IdsParams,
|
||||
async execute(_toolCallId, params: IdsInput) {
|
||||
const uniqueIds = [...new Set(params.ids)];
|
||||
const affected = state.get(uniqueIds);
|
||||
const found = new Set(affected.map((task) => task.id));
|
||||
const missing = uniqueIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throwToolError("Get", new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]));
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(affected) }],
|
||||
details: details("get", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(args: IdsInput, theme: Theme) {
|
||||
return renderCall("Get Tasks", args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
childrenIds: number[];
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TaskTimingSnapshot {
|
||||
taskId: number;
|
||||
startedAt: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
tasks: Task[];
|
||||
nextId: number;
|
||||
timings?: TaskTimingSnapshot[];
|
||||
}
|
||||
|
||||
export interface AddTaskDraft {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
children?: AddTaskDraft[];
|
||||
}
|
||||
|
||||
export interface TaskPatch {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface TaskToolDetails {
|
||||
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
|
||||
snapshot: TaskSnapshot;
|
||||
affected: Task[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export class TaskValidationError extends Error {
|
||||
readonly errors: string[];
|
||||
|
||||
constructor(errors: string[]) {
|
||||
super(errors.join("\n"));
|
||||
this.name = "TaskValidationError";
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { MAX_WIDGET_TASK_ROWS } from "./limits.ts";
|
||||
import type { Task, TaskStats, TaskToolDetails } from "./types.ts";
|
||||
|
||||
export const STATUS_ICONS = {
|
||||
pending: "○",
|
||||
"in-progress": "◉",
|
||||
blocked: "!",
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
||||
|
||||
interface DescendantStats {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
depth: number;
|
||||
siblingIndex: number;
|
||||
collapsed?: DescendantStats;
|
||||
}
|
||||
|
||||
function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number, DescendantStats>): DescendantStats {
|
||||
const cached = memo.get(task.id);
|
||||
if (cached) return cached;
|
||||
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
|
||||
total += 1;
|
||||
if (child.status === "done") done += 1;
|
||||
const nested = descendantStats(child, byId, memo);
|
||||
done += nested.done;
|
||||
total += nested.total;
|
||||
}
|
||||
|
||||
const stats = { done, total };
|
||||
memo.set(task.id, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const memo = new Map<number, DescendantStats>();
|
||||
const rows: TreeRow[] = [];
|
||||
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
|
||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||
const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ ";
|
||||
const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
|
||||
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
|
||||
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) });
|
||||
if (collapsed) return;
|
||||
|
||||
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
|
||||
children.forEach((child, index) => {
|
||||
const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast];
|
||||
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
|
||||
});
|
||||
};
|
||||
|
||||
const roots = tasks.filter((task) => task.parentId === null);
|
||||
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return buildTreeRows(tasks, false);
|
||||
}
|
||||
|
||||
function styleTaskName(task: Task, theme: Theme): string {
|
||||
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
|
||||
if (task.status === "in-progress") return theme.fg("warning", task.name);
|
||||
return task.name;
|
||||
}
|
||||
|
||||
function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string {
|
||||
const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame));
|
||||
const icon =
|
||||
task.status === "in-progress" && frameIndex !== undefined
|
||||
? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length]
|
||||
: STATUS_ICONS[task.status];
|
||||
if (task.status === "done") return theme.fg("success", icon);
|
||||
if (task.status === "in-progress") return theme.fg("warning", icon);
|
||||
if (task.status === "blocked") return theme.fg("error", icon);
|
||||
return theme.fg("dim", icon);
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||
}
|
||||
|
||||
function renderTaskLineWithSuffix(
|
||||
task: Task,
|
||||
prefix: string,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
suffix?: string,
|
||||
identifier = `${task.id}.`,
|
||||
identifierBeforeStatus = false,
|
||||
inProgressFrame?: number,
|
||||
): string {
|
||||
const status = styleStatusIcon(task, theme, inProgressFrame);
|
||||
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
|
||||
const identifierFits =
|
||||
!identifierBeforeStatus ||
|
||||
visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width;
|
||||
const visibleIdentifier = identifierFits ? styledIdentifier : "";
|
||||
const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier];
|
||||
const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" ");
|
||||
const styledName = styleTaskName(task, theme);
|
||||
const renderMain = (targetWidth: number): string => {
|
||||
const safeWidth = Math.max(1, targetWidth);
|
||||
if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth);
|
||||
|
||||
const availableNameWidth = safeWidth - visibleWidth(leading) - 1;
|
||||
if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth);
|
||||
return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`;
|
||||
};
|
||||
const trailing = [
|
||||
suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`),
|
||||
task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`),
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join("");
|
||||
if (trailing.length === 0) return renderMain(width);
|
||||
|
||||
const trailingWidth = visibleWidth(trailing);
|
||||
const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading));
|
||||
if (width <= trailingWidth + minimumTaskWidth) return renderMain(width);
|
||||
const taskWidth = Math.max(1, width - trailingWidth);
|
||||
return renderMain(taskWidth) + trailing;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
return renderTaskLineWithSuffix(task, prefix, theme, width);
|
||||
}
|
||||
|
||||
const STATUS_ICON_WIDTH = 1;
|
||||
const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3;
|
||||
|
||||
function indentDeepestLeaf(
|
||||
prefix: string,
|
||||
depth: number,
|
||||
maximumDepth: number,
|
||||
width: number,
|
||||
minimumContentWidth: number,
|
||||
): string {
|
||||
if (depth === 0 || depth !== maximumDepth) return prefix;
|
||||
|
||||
const indentedPrefix = `${prefix} `;
|
||||
return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix;
|
||||
}
|
||||
|
||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
const maximumDepth = maximumTaskDepth(tasks);
|
||||
return orderedTreeRows(tasks).map(({ task, prefix, depth }) =>
|
||||
renderTaskLine(
|
||||
task,
|
||||
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH),
|
||||
theme,
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toRomanNumeral(value: number): string {
|
||||
const numerals: Array<[number, string]> = [
|
||||
[100, "C"],
|
||||
[90, "XC"],
|
||||
[50, "L"],
|
||||
[40, "XL"],
|
||||
[10, "X"],
|
||||
[9, "IX"],
|
||||
[5, "V"],
|
||||
[4, "IV"],
|
||||
[1, "I"],
|
||||
];
|
||||
let remaining = value;
|
||||
let result = "";
|
||||
for (const [amount, numeral] of numerals) {
|
||||
while (remaining >= amount) {
|
||||
result += numeral;
|
||||
remaining -= amount;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function maximumTaskDepth(tasks: Task[]): number {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
let maximumDepth = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
let depth = 0;
|
||||
let parentId = task.parentId;
|
||||
while (parentId !== null) {
|
||||
depth += 1;
|
||||
parentId = byId.get(parentId)?.parentId ?? null;
|
||||
}
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
}
|
||||
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string {
|
||||
if (depth === maximumDepth) return "";
|
||||
if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`;
|
||||
return `${siblingIndex + 1}.`;
|
||||
}
|
||||
|
||||
export function renderWidgetTree(
|
||||
tasks: Task[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
padding = " ",
|
||||
inProgressFrame?: number,
|
||||
maxBodyRows?: number,
|
||||
): string[] {
|
||||
const rows = buildTreeRows(tasks, true, true);
|
||||
const maximumDepth = maximumTaskDepth(tasks);
|
||||
const outputLimit =
|
||||
maxBodyRows === undefined ? MAX_WIDGET_TASK_ROWS : Math.max(1, Math.floor(Number.isFinite(maxBodyRows) ? maxBodyRows : 1));
|
||||
const hasOverflow = rows.length > outputLimit;
|
||||
// The legacy, standalone widget allows twelve task rows plus one summary.
|
||||
// A combined panel treats maxBodyRows as a strict total body-line budget.
|
||||
const taskRowLimit = maxBodyRows === undefined ? outputLimit : hasOverflow ? Math.max(0, outputLimit - 1) : outputLimit;
|
||||
const visibleRows = rows.slice(0, taskRowLimit);
|
||||
const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth);
|
||||
return renderTaskLineWithSuffix(
|
||||
task,
|
||||
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH),
|
||||
theme,
|
||||
width,
|
||||
summary,
|
||||
identifier,
|
||||
true,
|
||||
inProgressFrame,
|
||||
);
|
||||
});
|
||||
if (!hasOverflow) return lines;
|
||||
|
||||
const hiddenTasks = rows.slice(taskRowLimit).reduce(
|
||||
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
|
||||
0,
|
||||
);
|
||||
const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`;
|
||||
return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))];
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Todos ${stats.done}/${stats.total}${blocked}`;
|
||||
}
|
||||
|
||||
export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string {
|
||||
const label = progressLabel(stats);
|
||||
const available = width - visibleWidth(label) - 2;
|
||||
if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width));
|
||||
|
||||
const barWidth = Math.min(20, available);
|
||||
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth);
|
||||
const filledColor = stats.done === stats.total ? "success" : "accent";
|
||||
const bar =
|
||||
theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width));
|
||||
}
|
||||
|
||||
function compactProgressBar(stats: TaskStats, theme: Theme): string {
|
||||
const width = 8;
|
||||
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * width);
|
||||
const filledColor = stats.done === stats.total ? "success" : "accent";
|
||||
return (
|
||||
theme.fg(filledColor, theme.bold("━".repeat(filled))) +
|
||||
theme.fg("borderMuted", "─".repeat(width - filled))
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the compact, unboxed Todo header and task rows. */
|
||||
export function renderTodoPanel(
|
||||
tasks: Task[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame?: number,
|
||||
maxBodyRows?: number,
|
||||
): string[] {
|
||||
if (!Number.isFinite(width) || width <= 0) return [];
|
||||
const safeWidth = Math.max(1, Math.floor(width));
|
||||
const stats = snapshotStats(tasks);
|
||||
const title = theme.fg("accent", "Todos");
|
||||
const count = theme.fg("muted", `${stats.done}/${stats.total}${stats.blocked > 0 ? ` · !${stats.blocked}` : ""}`);
|
||||
const fullHeader = `${title} ${count} ${compactProgressBar(stats, theme)}`;
|
||||
const compactHeader = `${title} ${count}`;
|
||||
const header = visibleWidth(fullHeader) <= safeWidth ? fullHeader : truncateToWidth(compactHeader, safeWidth);
|
||||
return [header, ...renderWidgetTree(tasks, theme, safeWidth, " ", inProgressFrame, maxBodyRows)];
|
||||
}
|
||||
|
||||
class TaskResultComponent implements Component {
|
||||
private readonly build: (width: number) => string[];
|
||||
|
||||
constructor(build: (width: number) => string[]) {
|
||||
this.build = build;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function firstText(content: Array<{ type: string; text?: string }>): string {
|
||||
return content.find((item) => item.type === "text")?.text ?? "";
|
||||
}
|
||||
|
||||
function summaryFor(details: TaskToolDetails): string {
|
||||
const stats = snapshotStats(details.snapshot.tasks);
|
||||
const progress = `${stats.done}/${stats.total} done`;
|
||||
switch (details.action) {
|
||||
case "add":
|
||||
case "append":
|
||||
return `${details.affected.length} added · ${progress}`;
|
||||
case "replace":
|
||||
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
|
||||
case "update":
|
||||
return `${details.affected.length} updated · ${progress}`;
|
||||
case "remove":
|
||||
return `${details.affected.length} removed · ${progress}`;
|
||||
case "list":
|
||||
return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`;
|
||||
case "get":
|
||||
return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotStats(tasks: Task[]): TaskStats {
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
return tasks.map((task) => renderTaskLine(task, " ", theme, width));
|
||||
}
|
||||
|
||||
function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
if (index > 0) lines.push("");
|
||||
lines.push(renderTaskLine(task, " ", theme, width));
|
||||
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
|
||||
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
|
||||
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
|
||||
if (task.description) {
|
||||
const prefix = " ";
|
||||
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
for (const sourceLine of task.description.split("\n")) {
|
||||
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
|
||||
if (wrapped.length === 0) lines.push(prefix);
|
||||
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isValidTaskToolDetails(details: unknown): details is TaskToolDetails {
|
||||
if (!details || typeof details !== "object") return false;
|
||||
const d = details as Partial<TaskToolDetails>;
|
||||
return (
|
||||
typeof d.action === "string" &&
|
||||
Array.isArray(d.snapshot?.tasks) &&
|
||||
Array.isArray(d.affected)
|
||||
);
|
||||
}
|
||||
|
||||
export function renderToolResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
||||
expanded: boolean,
|
||||
theme: Theme,
|
||||
): Component {
|
||||
const raw = firstText(result.content);
|
||||
const details = result.details as TaskToolDetails | undefined;
|
||||
|
||||
if (result.isError || !isValidTaskToolDetails(details)) {
|
||||
const rawLines = raw.split("\n").filter(Boolean);
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = expanded ? rawLines : rawLines.slice(0, 1);
|
||||
return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`));
|
||||
});
|
||||
}
|
||||
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
||||
if (!expanded || details.affected.length === 0) return lines;
|
||||
if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)];
|
||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import { renderTodoPanel } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const COMBINED_WIDGET_BREAKPOINT = 120;
|
||||
const COMBINED_BODY_ROWS = 10;
|
||||
export const TODO_ANIMATION_INTERVAL_MS = 500;
|
||||
|
||||
interface StatusWidgetTheme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
interface StatusWidget {
|
||||
readonly source: object;
|
||||
readonly hasContent: () => boolean;
|
||||
readonly getBodyRowCount: () => number;
|
||||
readonly render: (width: number, maxBodyRows?: number, theme?: StatusWidgetTheme) => string[];
|
||||
readonly claim: (onChange: () => void) => () => void;
|
||||
readonly isClaimed: (onChange: () => void) => boolean;
|
||||
}
|
||||
|
||||
interface InteractiveSubagentsGlobal {
|
||||
statusWidget?: unknown;
|
||||
}
|
||||
|
||||
class OptionalIntegrationError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = "OptionalIntegrationError";
|
||||
}
|
||||
}
|
||||
|
||||
class TuiRenderFailure extends Error {
|
||||
readonly original: unknown;
|
||||
|
||||
constructor(original: unknown) {
|
||||
super("TUI render failed", { cause: original });
|
||||
this.name = "TuiRenderFailure";
|
||||
this.original = original;
|
||||
}
|
||||
}
|
||||
|
||||
function optionalIntegrationFailure(message: string, cause?: unknown): OptionalIntegrationError {
|
||||
return new OptionalIntegrationError(message, cause);
|
||||
}
|
||||
|
||||
function callOptionalIntegration<T>(message: string, operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (error instanceof TuiRenderFailure) throw error.original;
|
||||
if (error instanceof OptionalIntegrationError) throw error;
|
||||
throw optionalIntegrationFailure(message, error);
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveClaim {
|
||||
owner: object;
|
||||
source: object;
|
||||
onChange: () => void;
|
||||
release: () => void;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// Symbols make the claim and generation survive extension reloads. A newly
|
||||
// loaded module can invalidate a stale component before installing its own.
|
||||
const ACTIVE_CLAIM_KEY = Symbol.for("pi-todo-herdr/active-subagents-claim");
|
||||
const WIDGET_GENERATION_KEY = Symbol.for("pi-todo-herdr/widget-generation");
|
||||
const ACTIVE_RUNTIME_KEY = Symbol.for("pi-todo-herdr/active-runtime");
|
||||
const RUNTIME_TOKEN = Symbol("pi-todo-herdr-runtime");
|
||||
const previousClaim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
||||
Reflect.set(globalThis, ACTIVE_RUNTIME_KEY, RUNTIME_TOKEN);
|
||||
if (previousClaim !== null && typeof previousClaim === "object") {
|
||||
const release = (previousClaim as { release?: unknown }).release;
|
||||
Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
||||
if (typeof release === "function") {
|
||||
try {
|
||||
release.call(previousClaim);
|
||||
} catch {
|
||||
// A stale claim must not prevent the new extension runtime loading.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentRuntime(): boolean {
|
||||
return Reflect.get(globalThis, ACTIVE_RUNTIME_KEY) === RUNTIME_TOKEN;
|
||||
}
|
||||
|
||||
function getActiveClaim(): ActiveClaim | undefined {
|
||||
const claim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
||||
return claim !== null && typeof claim === "object" ? (claim as ActiveClaim) : undefined;
|
||||
}
|
||||
|
||||
function setActiveClaim(claim: ActiveClaim | undefined): void {
|
||||
if (claim === undefined) Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
||||
else Reflect.set(globalThis, ACTIVE_CLAIM_KEY, claim);
|
||||
}
|
||||
|
||||
function claimBelongsTo(claim: ActiveClaim | undefined, owner: object, generation: number): claim is ActiveClaim {
|
||||
return claim?.owner === owner && claim.generation === generation;
|
||||
}
|
||||
|
||||
function nextWidgetGeneration(): number {
|
||||
const current = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
||||
const generation = typeof current === "number" && Number.isSafeInteger(current) ? current + 1 : 1;
|
||||
Reflect.set(globalThis, WIDGET_GENERATION_KEY, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
function currentWidgetGeneration(): number {
|
||||
const generation = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
||||
return typeof generation === "number" && Number.isSafeInteger(generation) ? generation : 0;
|
||||
}
|
||||
|
||||
function releaseActiveClaim(owner?: object, generation?: number): void {
|
||||
const activeClaim = getActiveClaim();
|
||||
if (
|
||||
(owner !== undefined && activeClaim?.owner !== owner) ||
|
||||
(generation !== undefined && activeClaim?.generation !== generation)
|
||||
) return;
|
||||
setActiveClaim(undefined);
|
||||
if (activeClaim === undefined) return;
|
||||
try {
|
||||
activeClaim.release();
|
||||
} catch {
|
||||
// A broken optional integration must never prevent the Todo widget clearing.
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusWidget(): StatusWidget | undefined {
|
||||
try {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
__pi_interactive_subagents?: unknown;
|
||||
};
|
||||
const integration = globals.__pi_interactive_subagents;
|
||||
if (integration === null || typeof integration !== "object") return undefined;
|
||||
const statusWidget = (integration as InteractiveSubagentsGlobal).statusWidget;
|
||||
if (statusWidget === null || typeof statusWidget !== "object") return undefined;
|
||||
|
||||
const candidate = statusWidget as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.hasContent !== "function" ||
|
||||
typeof candidate.getBodyRowCount !== "function" ||
|
||||
typeof candidate.render !== "function" ||
|
||||
typeof candidate.claim !== "function" ||
|
||||
typeof candidate.isClaimed !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = statusWidget;
|
||||
const call = <T>(methodName: string, args: unknown[] = []): T =>
|
||||
callOptionalIntegration(`Subagents ${methodName} failed`, () => {
|
||||
const method = candidate[methodName];
|
||||
if (typeof method !== "function") {
|
||||
throw optionalIntegrationFailure(`invalid Subagents ${methodName} method`);
|
||||
}
|
||||
return method.call(source, ...args) as T;
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
hasContent: () => {
|
||||
const content = call<unknown>("hasContent");
|
||||
if (typeof content !== "boolean") throw optionalIntegrationFailure("invalid Subagents content state");
|
||||
return content;
|
||||
},
|
||||
getBodyRowCount: () => {
|
||||
const count = call<unknown>("getBodyRowCount");
|
||||
if (typeof count !== "number" || !Number.isFinite(count)) {
|
||||
throw optionalIntegrationFailure("invalid Subagents body row count");
|
||||
}
|
||||
return count;
|
||||
},
|
||||
render: (width, maxBodyRows, theme) => {
|
||||
const lines = call<unknown>("render", [width, maxBodyRows, theme]);
|
||||
const hasAllEntries =
|
||||
Array.isArray(lines) &&
|
||||
lines.length > 0 &&
|
||||
Array.from({ length: lines.length }, (_, index) => {
|
||||
return Object.prototype.hasOwnProperty.call(lines, index) && typeof lines[index] === "string";
|
||||
}).every(Boolean);
|
||||
if (!hasAllEntries) throw optionalIntegrationFailure("invalid Subagents render output");
|
||||
const validLines = lines as string[];
|
||||
if (maxBodyRows !== undefined) {
|
||||
const isBordered = validLines[0]?.includes("╭") && validLines.at(-1)?.includes("╰");
|
||||
// Body rows exclude the provider's header. Legacy bordered output has
|
||||
// top and bottom chrome; the current unboxed output has one header.
|
||||
const headerAndChromeRows = isBordered ? 2 : 1;
|
||||
const maxLines = maxBodyRows + headerAndChromeRows;
|
||||
if (validLines.length > maxLines) {
|
||||
throw optionalIntegrationFailure("Subagents render output exceeded its body limit");
|
||||
}
|
||||
}
|
||||
return validLines;
|
||||
},
|
||||
claim: (onChange) => {
|
||||
const release = call<unknown>("claim", [onChange]);
|
||||
if (typeof release !== "function") throw optionalIntegrationFailure("invalid Subagents claim release");
|
||||
return release as () => void;
|
||||
},
|
||||
isClaimed: (onChange) => {
|
||||
const claimed = call<unknown>("isClaimed", [onChange]);
|
||||
if (typeof claimed !== "boolean") throw optionalIntegrationFailure("invalid Subagents claim state");
|
||||
return claimed;
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function claimStatusWidget(
|
||||
statusWidget: StatusWidget,
|
||||
owner: object,
|
||||
generation: number,
|
||||
tui: { requestRender(): void },
|
||||
): boolean {
|
||||
const activeClaim = getActiveClaim();
|
||||
if (activeClaim?.owner !== undefined && activeClaim.owner !== owner) return false;
|
||||
if (claimBelongsTo(activeClaim, owner, generation) && activeClaim.source === statusWidget.source) {
|
||||
try {
|
||||
if (statusWidget.isClaimed(activeClaim.onChange)) return true;
|
||||
} catch (error) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
if (error instanceof OptionalIntegrationError) return false;
|
||||
throw error;
|
||||
}
|
||||
// The provider may have been reset by a reload or another lifecycle event.
|
||||
// Drop the stale local handle before asking it for a fresh claim.
|
||||
}
|
||||
releaseActiveClaim(owner, generation);
|
||||
|
||||
try {
|
||||
const onChange = () => {
|
||||
if (!isCurrentRuntime()) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return;
|
||||
}
|
||||
const currentClaim = getActiveClaim();
|
||||
if (!claimBelongsTo(currentClaim, owner, generation) || currentClaim.onChange !== onChange) return;
|
||||
try {
|
||||
tui.requestRender();
|
||||
} catch (error) {
|
||||
// Let the provider restore its standalone widget when the TUI is gone.
|
||||
releaseActiveClaim(owner, generation);
|
||||
throw new TuiRenderFailure(error);
|
||||
}
|
||||
};
|
||||
const release = statusWidget.claim(onChange);
|
||||
setActiveClaim({ owner, source: statusWidget.source, onChange, release, generation });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof OptionalIntegrationError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWidth(width: number): number {
|
||||
return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
|
||||
}
|
||||
|
||||
function fitLine(line: string, width: number): string {
|
||||
const fittedWidth = normalizeWidth(width);
|
||||
const fitted = truncateToWidth(line, fittedWidth);
|
||||
return `${fitted}${" ".repeat(Math.max(0, fittedWidth - visibleWidth(fitted)))}`;
|
||||
}
|
||||
|
||||
function renderStatusPanel(
|
||||
statusWidget: StatusWidget,
|
||||
width: number,
|
||||
maxBodyRows: number,
|
||||
theme: StatusWidgetTheme,
|
||||
): string[] {
|
||||
return statusWidget.render(width, maxBodyRows, theme).map((line) => fitLine(line, width));
|
||||
}
|
||||
|
||||
function normalizePanelHeight(lines: string[], height: number, blankLine: string): string[] {
|
||||
if (lines.length >= height) return lines;
|
||||
const blanks = Array.from({ length: height - lines.length }, () => blankLine);
|
||||
const bottom = lines.at(-1);
|
||||
if (bottom?.includes("╰")) return [...lines.slice(0, -1), ...blanks, bottom];
|
||||
return [...lines, ...blanks];
|
||||
}
|
||||
|
||||
function renderSideBySide(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
statusWidget: StatusWidget,
|
||||
): string[] {
|
||||
const leftWidth = Math.floor((width - 1) / 2);
|
||||
const rightWidth = width - 1 - leftWidth;
|
||||
const todo = renderTodoPanel(tasks, theme, leftWidth, inProgressFrame, COMBINED_BODY_ROWS);
|
||||
const subagents = renderStatusPanel(statusWidget, rightWidth, COMBINED_BODY_ROWS, theme);
|
||||
const height = Math.max(todo.length, subagents.length);
|
||||
const left = normalizePanelHeight(todo, height, " ".repeat(leftWidth)).map((line) => fitLine(line, leftWidth));
|
||||
const right = normalizePanelHeight(subagents, height, " ".repeat(rightWidth));
|
||||
return left.map((line, index) => `${line} ${right[index]}`);
|
||||
}
|
||||
|
||||
function bodyRowsForTodo(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
): number {
|
||||
return Math.max(1, renderTodoPanel(tasks, theme, width, inProgressFrame, COMBINED_BODY_ROWS).length - 1);
|
||||
}
|
||||
|
||||
function bodyRowsForSubagents(statusWidget: StatusWidget): number {
|
||||
return Math.max(1, Math.min(COMBINED_BODY_ROWS, Math.floor(statusWidget.getBodyRowCount())));
|
||||
}
|
||||
|
||||
function distributeBodyRows(todoNeed: number, subagentsNeed: number): { todo: number; subagents: number } {
|
||||
let todo = Math.min(todoNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
||||
let subagents = Math.min(subagentsNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
||||
let remaining = COMBINED_BODY_ROWS - todo - subagents;
|
||||
|
||||
const todoUnused = Math.max(0, todoNeed - todo);
|
||||
const todoExtra = Math.min(remaining, todoUnused);
|
||||
todo += todoExtra;
|
||||
remaining -= todoExtra;
|
||||
|
||||
const subagentsUnused = Math.max(0, subagentsNeed - subagents);
|
||||
subagents += Math.min(remaining, subagentsUnused);
|
||||
return { todo: Math.max(1, todo), subagents: Math.max(1, subagents) };
|
||||
}
|
||||
|
||||
function renderStacked(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
statusWidget: StatusWidget,
|
||||
subagentsNeed: number,
|
||||
): string[] {
|
||||
const todoNeed = bodyRowsForTodo(tasks, theme, width, inProgressFrame);
|
||||
const rows = distributeBodyRows(todoNeed, subagentsNeed);
|
||||
return [
|
||||
...renderTodoPanel(tasks, theme, width, inProgressFrame, rows.todo),
|
||||
"",
|
||||
...renderStatusPanel(statusWidget, width, rows.subagents, theme),
|
||||
];
|
||||
}
|
||||
|
||||
function renderCombinedWidget(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
tui: { requestRender(): void },
|
||||
owner: object,
|
||||
generation: number,
|
||||
composeSubagents: boolean,
|
||||
): string[] {
|
||||
if (!Number.isFinite(width) || width <= 0 || tasks.length === 0) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return [];
|
||||
}
|
||||
|
||||
const fittedWidth = normalizeWidth(width);
|
||||
const renderTodoFallback = (): string[] => {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return renderTodoPanel(tasks, theme, fittedWidth, inProgressFrame);
|
||||
};
|
||||
if (!composeSubagents) return renderTodoFallback();
|
||||
|
||||
const statusWidget = getStatusWidget();
|
||||
if (statusWidget === undefined) return renderTodoFallback();
|
||||
|
||||
try {
|
||||
if (!statusWidget.hasContent()) return renderTodoFallback();
|
||||
if (!claimStatusWidget(statusWidget, owner, generation, tui)) return renderTodoFallback();
|
||||
|
||||
const subagentsNeed = bodyRowsForSubagents(statusWidget);
|
||||
return fittedWidth > COMBINED_WIDGET_BREAKPOINT
|
||||
? renderSideBySide(tasks, theme, fittedWidth, inProgressFrame, statusWidget)
|
||||
: renderStacked(tasks, theme, fittedWidth, inProgressFrame, statusWidget, subagentsNeed);
|
||||
} catch (error) {
|
||||
if (!(error instanceof OptionalIntegrationError)) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
throw error;
|
||||
}
|
||||
return renderTodoFallback();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
if (!isCurrentRuntime()) return;
|
||||
nextWidgetGeneration();
|
||||
releaseActiveClaim();
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
}
|
||||
|
||||
export function updateTaskWidget(
|
||||
state: TaskState,
|
||||
ctx: ExtensionContext,
|
||||
visible: boolean,
|
||||
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
||||
): void {
|
||||
if (!isCurrentRuntime()) return;
|
||||
const tasks = state.list();
|
||||
if (!visible || tasks.length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = nextWidgetGeneration();
|
||||
// Subagents owns a native widget outside this extension. Only compose it
|
||||
// while Pi is rendering the terminal UI, never in RPC/print modes.
|
||||
const composeSubagents = ctx.mode === "tui";
|
||||
|
||||
// Replacing a widget can happen before Pi calls its old dispose hook. Release
|
||||
// the optional Subagents claim eagerly so it cannot hide a standalone widget.
|
||||
releaseActiveClaim();
|
||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||
let inProgressFrame = 0;
|
||||
let disposed = false;
|
||||
const owner = {};
|
||||
const isActive = (): boolean =>
|
||||
!disposed && isCurrentRuntime() && currentWidgetGeneration() === generation;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
if (hasActiveTimer) {
|
||||
timer = setInterval(() => {
|
||||
if (!isActive()) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
inProgressFrame += 1;
|
||||
try {
|
||||
tui.requestRender();
|
||||
} catch {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
releaseActiveClaim(owner, generation);
|
||||
}
|
||||
}, Math.max(1, refreshMs));
|
||||
}
|
||||
timer?.unref();
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
if (!isActive()) return [];
|
||||
const currentTasks = state.list();
|
||||
return renderCombinedWidget(
|
||||
currentTasks,
|
||||
theme,
|
||||
width,
|
||||
inProgressFrame,
|
||||
tui,
|
||||
owner,
|
||||
generation,
|
||||
composeSubagents,
|
||||
);
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
releaseActiveClaim(owner, generation);
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user