import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { sliceByColumn, Text, visibleWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { getTaskUpdatedAt, makeTodoEventDetails, readTodoEventDetails, TodoState, type TodoEvent, type TodoTask, } from "./state.ts"; import { firstDisplayLine, TodoViewer, TodoWidget } from "./tui.ts"; const TOOL_NAME = "todo"; const WIDGET_KEY = "pi-todo-active"; const HINT_ENTRY_TYPE = "pi-todo-compaction-hint"; const POST_COMPACTION_HINT = 'There are unfinished tasks in todo. Use todo with action "list" to view them.'; const TOOL_DESCRIPTION = `Manage and plan multiple tasks and checklists to track multi-step work. Tasks live in the session and survive context compaction. Actions: - create with text (no id): create a task; the tool assigns the id and returns it in the result. Text may be a plain title or a multi-line checklist. - list: view unfinished tasks (id, first-line preview, latest progress) plus a one-line summary of completed tasks. Use list with id to inspect one task in full, including its numbered progress history. - add with id and text: append a progress entry. Progress is append-only; entries cannot be edited or removed. - complete with id: mark a task done when its work is finished. Idempotent. - cancel with id: abandon a task that is no longer needed. Idempotent; a completed task cannot be cancelled. Cancelled tasks leave the active list and completed summary but stay inspectable via list with id. The first non-blank line of a task's text serves as its preview title in list views. Previews are truncated; list with id always returns the full stored text and progress. All text, including whitespace and formatting, is stored exactly as provided.`; const TodoParams = Type.Object( { action: StringEnum(["create", "list", "add", "complete", "cancel"] as const, { description: "Operation to perform: create, list, add, complete, or cancel.", }), id: Type.Optional( Type.String({ description: "Task ID for inspecting, adding progress to, completing, or cancelling a task. Do not pass id for create; ids are assigned by the tool.", }), ), text: Type.Optional( Type.String({ minLength: 1, description: "Task or checklist text for create, or progress text for add. Stored exactly as provided.", }), ), }, { additionalProperties: false }, ); interface TodoViewDetails { version: 1; kind: "todo-view"; action: "list" | "task" | "already-completed" | "already-cancelled"; count?: number; id?: string; } /** * Defensively validate and read a TodoViewDetails from an unknown value. * Returns the view details on success, or `undefined` for any malformed input. * Never throws. */ function readTodoViewDetails(value: unknown): TodoViewDetails | undefined { if (typeof value !== "object" || value === null) return undefined; const obj = value as Record; if (obj.version !== 1 || obj.kind !== "todo-view") return undefined; const { action } = obj; if (action !== "list" && action !== "task" && action !== "already-completed" && action !== "already-cancelled") { return undefined; } const details: TodoViewDetails = { version: 1, kind: "todo-view", action }; if (typeof obj.count === "number") details.count = obj.count; if (typeof obj.id === "string") details.id = obj.id; return details; } function now(): string { return new Date().toISOString(); } function requireId(id: string | undefined, action: string): string { if (id === undefined || id === "") throw new Error(`todo ${action}: id is required`); // Display layers render ids with a leading "#"; tolerate it on input. return id.startsWith("#") ? id.slice(1) : id; } function requireText(text: string | undefined, action: string): string { if (text === undefined || text === "") throw new Error(`todo ${action}: text is required`); return text; } function assertAbsent(value: unknown, field: string, action: string, reason?: string): void { if (value !== undefined) throw new Error(`todo ${action}: ${field} is not accepted${reason !== undefined ? ` (${reason})` : ""}`); } function sortByRecentUpdate(tasks: readonly TodoTask[]): TodoTask[] { return [...tasks].sort((a, b) => getTaskUpdatedAt(b).localeCompare(getTaskUpdatedAt(a))); } /** Truncate without the SGR 0 resets that would clear a surrounding tool block background. */ function truncateText(text: string, maxWidth: number, ellipsis = "..."): string { if (maxWidth <= 0) return ""; if (visibleWidth(text) <= maxWidth) return text; const ellipsisWidth = visibleWidth(ellipsis); if (ellipsisWidth >= maxWidth) return sliceByColumn(ellipsis, 0, maxWidth, true); return `${sliceByColumn(text, 0, maxWidth - ellipsisWidth, true)}${ellipsis}`; } function formatTaskList(active: readonly TodoTask[], completed: readonly TodoTask[]): string { const sections = [ active.length === 0 ? "No unfinished tasks." : sortByRecentUpdate(active) .map((task) => { const lines = [`id: ${task.id}`, `task: ${truncateText(firstDisplayLine(task.text), 240)}`]; const latest = task.progress.at(-1); if (latest) lines.push(`latest: ${truncateText(firstDisplayLine(latest.text), 240)}`); return lines.join("\n"); }) .join("\n\n"), ]; if (completed.length > 0) { const summary = sortByRecentUpdate(completed) .map((task) => `#${task.id} ${truncateText(firstDisplayLine(task.text), 80)}`) .join(", "); sections.push(`completed (${completed.length}): ${summary}`); } return sections.join("\n\n"); } function formatTask(task: TodoTask): string { const status = task.completedAt !== undefined ? "completed" : task.cancelledAt !== undefined ? "cancelled" : "active"; const lines = [`id: ${task.id}`, `status: ${status}`, "", "task:", task.text, "", `progress (${task.progress.length}):`]; if (task.progress.length === 0) { lines.push("(none)"); } else { for (const [index, progress] of task.progress.entries()) { lines.push(`${index + 1}. ${progress.text}`); } } return lines.join("\n"); } async function truncateToolOutput(output: string): Promise { const result = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); if (!result.truncated) return output; const fullOutputPath = join(tmpdir(), `pi-todo-${randomUUID()}.txt`); await writeFile(fullOutputPath, output, "utf8"); const notice = `[Output truncated: ${result.outputLines} of ${result.totalLines} lines (${formatSize(result.outputBytes)} of ${formatSize(result.totalBytes)}). Full output saved to: ${fullOutputPath}]`; return result.content ? `${result.content}\n\n${notice}` : notice; } function newTaskId(state: TodoState): string { let id: string; do id = randomUUID().slice(0, 8); while (state.get(id)); return id; } function isHintEntry(entry: unknown): boolean { if (typeof entry !== "object" || entry === null) return false; const value = entry as { type?: unknown; customType?: unknown; data?: unknown }; if (value.type !== "custom" || value.customType !== HINT_ENTRY_TYPE) return false; if (typeof value.data !== "object" || value.data === null) return false; return (value.data as { version?: unknown }).version === 1; } function eventFromEntry(entry: unknown): TodoEvent | undefined { if (typeof entry !== "object" || entry === null) return undefined; const value = entry as { type?: unknown; message?: unknown }; if (value.type !== "message" || typeof value.message !== "object" || value.message === null) return undefined; const message = value.message as { role?: unknown; toolName?: unknown; details?: unknown }; if (message.role !== "toolResult" || message.toolName !== TOOL_NAME) return undefined; return readTodoEventDetails(message.details); } export default function todoExtension(pi: ExtensionAPI): void { const state = new TodoState(); let compactionHintActive = false; let operationTail: Promise = Promise.resolve(); let widgetMounted = false; let widget: TodoWidget | undefined; function serialize(operation: () => Promise | T): Promise { const run = operationTail.then(operation, operation); operationTail = run.then( () => undefined, () => undefined, ); return run; } function refreshWidget(ctx: ExtensionContext): void { if (ctx.mode !== "tui") return; widget?.update(state.active()); } function mountWidget(ctx: ExtensionContext): void { if (ctx.mode !== "tui" || widgetMounted) return; // Mark this before calling setWidget so even unusual synchronous harnesses // cannot trigger a duplicate mount. The factory may be captured and run later. widgetMounted = true; ctx.ui.setWidget( WIDGET_KEY, (tui, theme) => { widget = new TodoWidget(state.active(), tui, theme); return widget; }, { placement: "aboveEditor" }, ); } function notFoundError(action: string, id: string): Error { const ids = state.active().map((task) => task.id); const hint = ids.length === 0 ? "no active tasks" : `active: ${ids.join(", ")}`; return new Error(`todo ${action}: task "${id}" not found (${hint})`); } function taskTitleLabel(id: string): string | undefined { const task = state.get(id); if (task === undefined) return undefined; return truncateText(firstDisplayLine(task.text), 80); } function reconstruct(ctx: ExtensionContext): void { const events: TodoEvent[] = []; for (const entry of ctx.sessionManager.getBranch()) { const event = eventFromEntry(entry); if (event) events.push(event); } state.replay(events); compactionHintActive = ctx.sessionManager.getEntries().some(isHintEntry); refreshWidget(ctx); } pi.registerTool({ name: TOOL_NAME, label: "Todo", description: TOOL_DESCRIPTION, promptSnippet: "Manage and plan multiple tasks and checklists in todo", promptGuidelines: [ "Use todo to track multi-step work: create tasks or checklists (the first line is the preview shown in lists), add progress as steps finish, and complete or cancel tasks as outcomes change.", ], parameters: TodoParams, async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (signal?.aborted) throw new Error("todo: cancelled"); return serialize(async () => { if (signal?.aborted) throw new Error("todo: cancelled"); switch (params.action) { case "create": { assertAbsent(params.id, "id", "create", "the tool assigns the id and returns it in the result"); const text = requireText(params.text, "create"); const { event } = state.create(newTaskId(state), text, now()); refreshWidget(ctx); return { content: [{ type: "text" as const, text: `Created task ${event.id}.` }], details: makeTodoEventDetails(event), }; } case "list": { assertAbsent(params.text, "text", "list"); if (params.id !== undefined) { const id = requireId(params.id, "list"); const task = state.get(id); if (!task) throw notFoundError("list", id); return { content: [{ type: "text" as const, text: await truncateToolOutput(formatTask(task)) }], details: { version: 1, kind: "todo-view", action: "task", id: task.id } satisfies TodoViewDetails, }; } const active = state.active(); return { content: [{ type: "text" as const, text: await truncateToolOutput(formatTaskList(active, state.completed())) }], details: { version: 1, kind: "todo-view", action: "list", count: active.length } satisfies TodoViewDetails, }; } case "add": { const id = requireId(params.id, "add"); const text = requireText(params.text, "add"); if (!state.get(id)) throw notFoundError("add", id); const { event } = state.add(id, text, now()); refreshWidget(ctx); return { content: [{ type: "text" as const, text: `Added progress to task ${id}.` }], details: makeTodoEventDetails(event), }; } case "complete": { assertAbsent(params.text, "text", "complete"); const id = requireId(params.id, "complete"); if (!state.get(id)) throw notFoundError("complete", id); const result = state.complete(id, now()); refreshWidget(ctx); if (result.alreadyCompleted || !result.event) { return { content: [{ type: "text" as const, text: `Task ${id} is already completed.` }], details: { version: 1, kind: "todo-view", action: "already-completed", id } satisfies TodoViewDetails, }; } return { content: [{ type: "text" as const, text: `Completed task ${id}.` }], details: makeTodoEventDetails(result.event), }; } case "cancel": { assertAbsent(params.text, "text", "cancel"); const id = requireId(params.id, "cancel"); if (!state.get(id)) throw notFoundError("cancel", id); const result = state.cancel(id, now()); refreshWidget(ctx); if (result.alreadyCancelled || !result.event) { return { content: [{ type: "text" as const, text: `Task ${id} is already cancelled.` }], details: { version: 1, kind: "todo-view", action: "already-cancelled", id } satisfies TodoViewDetails, }; } return { content: [{ type: "text" as const, text: `Cancelled task ${id}.` }], details: makeTodoEventDetails(result.event), }; } } }); }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; if (args.text !== undefined) { const preview = truncateText(firstDisplayLine(args.text), 100); text += ` ${theme.fg("dim", preview)}`; } return new Text(text, 0, 0); }, renderResult(result, _options, theme) { const event = readTodoEventDetails(result.details); if (event) { // The call line shows the text but not the id, so the id is the news here. if (event.type === "created") { return new Text(theme.fg("success", "✓ Created ") + theme.fg("accent", `#${event.id}`), 0, 0); } // The call line already shows the id; confirm with the human-readable title instead. const title = taskTitleLabel(event.id); const suffix = title !== undefined ? theme.fg("dim", ` · ${title}`) : theme.fg("accent", ` #${event.id}`); switch (event.type) { case "progress_added": return new Text(theme.fg("success", "✓ Updated") + suffix, 0, 0); case "completed": return new Text(theme.fg("success", "✓ Completed") + suffix, 0, 0); case "cancelled": return new Text(theme.fg("warning", "✗ Cancelled") + suffix, 0, 0); } } const view = readTodoViewDetails(result.details); if (view?.action === "already-completed" || view?.action === "already-cancelled") { const title = view.id !== undefined ? taskTitleLabel(view.id) : undefined; const suffix = title !== undefined ? theme.fg("dim", ` · ${title}`) : view.id !== undefined ? theme.fg("accent", ` #${view.id}`) : ""; const text = view.action === "already-completed" ? "→ Already completed" : "→ Already cancelled"; return new Text(theme.fg("muted", text) + suffix, 0, 0); } const content = result.content.find((item) => item.type === "text"); return new Text(content?.type === "text" ? content.text : "", 0, 0); }, }); pi.registerCommand("todos", { description: "Show all todo tasks and progress in a read-only TUI view", handler: async (_args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("/todos requires interactive mode", "error"); return; } const tasks = state.all(); await ctx.ui.custom((tui, theme, _keybindings, done) => new TodoViewer(tasks, tui, theme, () => done())); }, }); pi.on("session_start", async (_event, ctx) => { reconstruct(ctx); mountWidget(ctx); }); pi.on("session_tree", async (_event, ctx) => reconstruct(ctx)); pi.on("session_compact", async (_event, _ctx) => { if (compactionHintActive || state.active().length === 0) return; compactionHintActive = true; pi.appendEntry(HINT_ENTRY_TYPE, { version: 1 }); }); pi.on("before_agent_start", async (event) => { if (!compactionHintActive) return; return { systemPrompt: `${event.systemPrompt}\n\n${POST_COMPACTION_HINT}` }; }); pi.on("session_shutdown", async (_event, ctx) => { if (ctx.mode === "tui" && widgetMounted) ctx.ui.setWidget(WIDGET_KEY, undefined); widget = undefined; widgetMounted = false; }); }