import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionUIContext } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { applyPanelBackground, panelInnerWidth, renderKylinPanelBottom, renderKylinPanelRow, renderKylinPanelTop, buildExpandedParams, kylinFrameForCall, kylinFrameForResult, } from "../../../vera-theme/src/public"; import { getAgentPath } from "../../src/shared/agent-paths"; // ── Types ──────────────────────────────────────────────────────────────── type TodoAction = "create" | "set" | "replace" | "add" | "update" | "check" | "uncheck" | "clear" | "list" | "status"; type WidgetState = "shown" | "cleared" | "unavailable"; interface TodoInputItem { id?: string; text?: string; completed?: boolean; } interface TodoItem { id: string; text: string; completed: boolean; } interface TodoDetails { action: TodoAction; title: string; totalCount: number; completedCount: number; incompleteCount: number; widget: WidgetState; items: TodoItem[]; error?: string; } interface PersistedTodoState { version: 1; sessionId: string; title: string; items: TodoItem[]; savedAt: string; } // ── Schema ─────────────────────────────────────────────────────────────── const TodoActionSchema = Type.Union([ Type.Literal("create"), Type.Literal("set"), Type.Literal("replace"), Type.Literal("add"), Type.Literal("update"), Type.Literal("check"), Type.Literal("uncheck"), Type.Literal("clear"), Type.Literal("list"), Type.Literal("status"), ], { description: "create/set/replace = replace the current list; add = append items; " + "update = edit text and/or completion; check/uncheck = mark items; " + "clear = remove all todos and close the widget; list/status = read current todos.", }); const TodoItemSchema = Type.Object({ id: Type.Optional(Type.String({ description: "Stable id used later by update/check/uncheck. If omitted, an id like todo-1 is generated.", })), text: Type.String({ description: "Todo item text shown in the widget" }), completed: Type.Optional(Type.Boolean({ description: "Whether this item is already complete (default: false)" })), }); const TodoParams = Type.Object({ action: TodoActionSchema, title: Type.Optional(Type.String({ description: "Optional title shown at the top of the persistent todo widget" })), todos: Type.Optional(Type.Array(TodoItemSchema, { description: "Items for create/set/replace, or items to append for add", })), id: Type.Optional(Type.String({ description: "Single item id for update/check/uncheck" })), ids: Type.Optional(Type.Array(Type.String(), { description: "Multiple item ids for update/check/uncheck" })), text: Type.Optional(Type.String({ description: "New text for update, or a single item text for add" })), completed: Type.Optional(Type.Boolean({ description: "Completion state for update" })), }); // ── Widget rendering ───────────────────────────────────────────────────── const DEFAULT_TITLE = "Task Todo"; const WIDGET_KEY = "todo"; // ── Kylin-styled persistent widget ─────────────────────────────────────────── // // Same chrome conventions as the tool-execution frames: square corners, // side bars `│ │`, and shared panel background/status behavior. Render output // is fully deterministic for any given snapshot — no Date.now(), no animation // in the render path, so Pi's differential renderer never sees changes unless // the items themselves change. function showTodoWidget(ui: ExtensionUIContext, title: string, items: TodoItem[]): void { const snapshot = { title, items: items.map((item) => ({ ...item })), }; if (!snapshot.items.some((item) => !item.completed)) { ui.setWidget(WIDGET_KEY, undefined); return; } ui.setWidget(WIDGET_KEY, (_tui, theme) => { return { render(width: number): string[] { if (width < 8) return [snapshot.title]; const completed = snapshot.items.filter((item) => item.completed).length; const total = snapshot.items.length; const incomplete = total - completed; const ratio = `${completed}/${total}`; const dot = theme.fg(incomplete > 0 ? "accent" : "success", "●"); const titleStyled = theme.fg("text", snapshot.title); const left = `${dot} ${titleStyled}`; const ratioColor = completed === total ? "success" : completed > 0 ? "accent" : "dim"; const right = theme.fg(ratioColor, ratio) + " " + theme.fg("muted", "done"); const panelWidth = panelInnerWidth(width) + 4; const out: string[] = [renderKylinPanelTop(panelWidth, left, right, theme)]; for (const item of snapshot.items) { const mark = item.completed ? theme.fg("success", "✓") : theme.fg("dim", "○"); const id = theme.fg("muted", item.id); const sep = theme.fg("borderMuted", " › "); const text = item.completed ? theme.fg("dim", item.text) : theme.fg("text", item.text); out.push(renderKylinPanelRow(mark + " " + id + sep + text, panelWidth, theme)); } out.push(renderKylinPanelBottom(panelWidth, theme)); const status: "pending" | "success" = incomplete > 0 ? "pending" : "success"; return applyPanelBackground(out, status, theme); }, invalidate(): void {}, }; }, { placement: "aboveEditor" }); } function syncWidget(ctx: any, title: string, items: TodoItem[]): WidgetState { const hasUI = Boolean(ctx?.hasUI && ctx.ui); if (!hasUI) return "unavailable"; if (items.some((item) => !item.completed)) { showTodoWidget(ctx.ui, title, items); return "shown"; } ctx.ui.setWidget(WIDGET_KEY, undefined); return "cleared"; } // ── Persistence helpers ───────────────────────────────────────────────── const PERSISTENCE_VERSION = 1; function todoStateDir(): string { return getAgentPath("state", "vera-session-tools", "todos"); } function safeSessionFileName(sessionId: string): string { return sessionId.replace(/[^a-zA-Z0-9._-]/g, "-") + ".json"; } function todoStatePath(sessionId: string): string { return join(todoStateDir(), safeSessionFileName(sessionId)); } function readPersistedState(sessionId: string): { title: string; items: TodoItem[] } | null { try { const path = todoStatePath(sessionId); if (!existsSync(path)) return null; const parsed = JSON.parse(readFileSync(path, "utf8")) as PersistedTodoState; if (parsed.version !== PERSISTENCE_VERSION || parsed.sessionId !== sessionId) return null; if (typeof parsed.title !== "string" || !Array.isArray(parsed.items)) return null; const usedIds = new Set(); const items: TodoItem[] = []; for (const item of parsed.items) { if (!item || typeof item.id !== "string" || typeof item.text !== "string") continue; const id = normalizeId(item.id); const text = item.text.trim(); if (!id || !text || usedIds.has(id)) continue; usedIds.add(id); if (item.completed === true) continue; items.push({ id, text, completed: false }); } if (items.length === 0) { deletePersistedState(sessionId); return null; } return { title: parsed.title.trim() || DEFAULT_TITLE, items, }; } catch { return null; } } function deletePersistedState(sessionId: string): void { try { const path = todoStatePath(sessionId); if (existsSync(path)) unlinkSync(path); } catch {} } function writePersistedState(sessionId: string, title: string, items: TodoItem[]): void { const incompleteItems = items.filter((item) => !item.completed); if (incompleteItems.length === 0) { deletePersistedState(sessionId); return; } const dir = todoStateDir(); mkdirSync(dir, { recursive: true }); const path = todoStatePath(sessionId); const tmp = `${path}.${process.pid}.tmp`; const payload: PersistedTodoState = { version: PERSISTENCE_VERSION, sessionId, title, items: incompleteItems.map((item) => ({ ...item })), savedAt: new Date().toISOString(), }; writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8"); renameSync(tmp, path); } // ── State helpers ──────────────────────────────────────────────────────── function normalizeId(id: string): string { return id.trim().replace(/\s+/g, "-"); } function nextGeneratedId(used: Set): string { let i = 1; while (used.has(`todo-${i}`)) i++; return `todo-${i}`; } function makeUniqueId(base: string, used: Set): string { const normalized = normalizeId(base); let id = normalized || nextGeneratedId(used); if (!used.has(id)) return id; let i = 2; while (used.has(`${id}-${i}`)) i++; return `${id}-${i}`; } function normalizeInputItems(rawItems: TodoInputItem[], usedIds: Set): TodoItem[] { return rawItems.map((raw) => { const text = typeof raw.text === "string" ? raw.text.trim() : ""; if (!text) throw new Error("Todo item text must be a non-empty string."); const baseId = typeof raw.id === "string" && raw.id.trim() ? raw.id : nextGeneratedId(usedIds); const id = makeUniqueId(baseId, usedIds); usedIds.add(id); return { id, text, completed: raw.completed === true, }; }); } function idsFromParams(params: any): string[] { const ids = new Set(); if (typeof params.id === "string" && params.id.trim()) ids.add(normalizeId(params.id)); if (Array.isArray(params.ids)) { for (const id of params.ids) { if (typeof id === "string" && id.trim()) ids.add(normalizeId(id)); } } return [...ids]; } function snapshotDetails(action: TodoAction, title: string, items: TodoItem[], widget: WidgetState, error?: string): TodoDetails { const completedCount = items.filter((item) => item.completed).length; return { action, title, totalCount: items.length, completedCount, incompleteCount: items.length - completedCount, widget, items: items.map((item) => ({ ...item })), ...(error ? { error } : {}), }; } function formatTodoStatus(details: TodoDetails): string { if (details.error) { return `Error: ${details.error}`; } if (details.items.length === 0) { const widgetNote = details.widget === "unavailable" ? "Widget unavailable (no interactive UI)." : "Widget cleared."; return `No active todos. ${widgetNote}`; } const lines = [ `# ${details.title}`, `${details.completedCount}/${details.totalCount} completed. Widget: ${details.widget}.`, "", ...details.items.map((item) => `- [${item.completed ? "x" : " "}] ${item.id}: ${item.text}`), ]; return lines.join("\n"); } function truncate(s: string, max = 60): string { return s.length > max ? s.slice(0, max - 1) + "…" : s; } // ── Tool rendering ─────────────────────────────────────────────────────── // ── Tool rendering (Kylin chrome) ───────────────────────────────────────── function buildParamSummary(args: any, theme: any): string { const action = args.action ?? "list"; const summary = Array.isArray(args.todos) ? `${args.todos.length} item${args.todos.length === 1 ? "" : "s"}` : (args.id ?? args.ids?.join(", ") ?? args.text ?? args.title ?? "current list"); return ( theme.fg("accent", action) + theme.fg("borderMuted", " · ") + theme.fg("text", truncate(String(summary))) ); } function renderCall(args: any, theme: any, ctx: any): any { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "todo", theme, status: "pending", paramSummary: buildParamSummary(args, theme), startedAt: state.startedAt, }); } function renderResult(result: any, opts: any, theme: any, ctx: any): any { const details = result.details as TodoDetails | undefined; const state = ctx.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildParamSummary(ctx.args, theme); const todosForDisplay = Array.isArray(ctx.args?.todos) ? ctx.args.todos.map((t: any) => `${t?.completed ? "✓" : "○"} ${t?.id ?? "?"}: ${String(t?.text ?? "")}`) : undefined; const expandedParams = opts.expanded ? buildExpandedParams([ { label: "action", value: ctx.args?.action }, { label: "title", value: ctx.args?.title }, { label: "id", value: ctx.args?.id }, { label: "ids", value: ctx.args?.ids }, { label: "text", value: ctx.args?.text, multiline: true }, { label: "completed", value: ctx.args?.completed }, { label: "todos", value: todosForDisplay, multiline: true }, ], theme) : undefined; if (!details) { return kylinFrameForResult(ctx, { name: "todo", theme, status: "error", paramSummary, errorMessage: "no todo status", expanded: opts.expanded, expandedParams, startedAt, }); } if (details.error) { return kylinFrameForResult(ctx, { name: "todo", theme, status: "error", paramSummary, errorMessage: details.error, expanded: opts.expanded, expandedParams, startedAt, }); } const completionStr = `${details.completedCount}/${details.totalCount} completed`; const completionColor = details.incompleteCount === 0 ? "success" : "text"; const widgetColor = details.widget === "shown" ? "accent" : "dim"; const inlineResult = theme.fg(completionColor, completionStr) + theme.fg("borderMuted", " · ") + theme.fg(widgetColor, `widget ${details.widget}`); let expandedBody: string[] | undefined; if (opts.expanded && details.items.length > 0) { expandedBody = details.items.map((item) => { const mark = item.completed ? theme.fg("success", "x") : theme.fg("warning", " "); return ( `[${mark}] ` + theme.fg("muted", item.id + ":") + " " + theme.fg(item.completed ? "dim" : "text", item.text) ); }); } return kylinFrameForResult(ctx, { name: "todo", theme, status: "success", paramSummary, inlineResult, expanded: opts.expanded, expandedParams, expandedBody, expandedTotalLines: details.items.length, startedAt, }); } // ── Extension ──────────────────────────────────────────────────────────── export default function todo(pi: ExtensionAPI) { let title = DEFAULT_TITLE; let items: TodoItem[] = []; let activeSessionId: string | undefined; function restoreSessionState(ctx: any): void { const sessionId = ctx?.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || !sessionId) return; if (activeSessionId === sessionId) return; activeSessionId = sessionId; const restored = readPersistedState(sessionId); title = restored?.title ?? DEFAULT_TITLE; items = restored?.items ?? []; } function persistActiveState(): void { if (!activeSessionId) return; writePersistedState(activeSessionId, title, items); } pi.on("session_start", (_event, ctx) => { restoreSessionState(ctx); syncWidget(ctx, title, items); }); pi.on("session_shutdown", (_event, ctx) => { restoreSessionState(ctx); persistActiveState(); }); pi.registerTool({ name: "todo", label: "Todo", description: "Create, update, check off, clear, or inspect Vera's session todo list. " + "When any todo item is incomplete, the current list is displayed as a persistent above-editor widget. " + "The widget automatically closes when every item is complete or when the list is cleared.", promptSnippet: "Use todo to maintain a concise task checklist. Incomplete todos stay visible above the editor; " + "mark each item complete as soon as it is done.", promptGuidelines: [ "Before starting a non-trivial or multi-step task, call todo with action=list to inspect any current list, then action=create/set to publish the planned checklist.", "Keep todo items short, concrete, and verifiable; prefer 2-6 items for normal coding tasks.", "After finishing each listed item, immediately call todo with action=check and that item's id.", "Use action=update when the plan changes, action=add when a new necessary step appears, and action=clear only when abandoning the list or after the task no longer needs a visible checklist.", "Do not use todo for questions to the user; use ask_user for structured user input.", ], parameters: TodoParams, renderShell: "self", renderCall, renderResult, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { restoreSessionState(ctx); const action = params.action as TodoAction; let error: string | undefined; let mutated = false; try { const nextTitle = typeof params.title === "string" && params.title.trim() ? params.title.trim() : undefined; switch (action) { case "create": case "set": case "replace": { if (!Array.isArray(params.todos)) { throw new Error("action=create/set/replace requires a todos array."); } title = nextTitle ?? DEFAULT_TITLE; items = normalizeInputItems(params.todos, new Set()); mutated = true; break; } case "add": { const rawItems: TodoInputItem[] = Array.isArray(params.todos) ? params.todos : (typeof params.text === "string" ? [{ id: params.id, text: params.text, completed: params.completed }] : []); if (rawItems.length === 0) { throw new Error("action=add requires todos or text."); } if (nextTitle) title = nextTitle; items = items.concat(normalizeInputItems(rawItems, new Set(items.map((item) => item.id)))); mutated = true; break; } case "update": { const targetIds = idsFromParams(params); if (targetIds.length === 0) throw new Error("action=update requires id or ids."); const missing = targetIds.filter((id) => !items.some((item) => item.id === id)); if (missing.length > 0) throw new Error(`Unknown todo id(s): ${missing.join(", ")}.`); const hasText = typeof params.text === "string"; const hasCompleted = typeof params.completed === "boolean"; if (!hasText && !hasCompleted && !nextTitle) { throw new Error("action=update requires text, completed, or title."); } const newText = hasText ? params.text.trim() : undefined; if (hasText && !newText) throw new Error("Updated todo text must be non-empty."); if (nextTitle) title = nextTitle; items = items.map((item) => targetIds.includes(item.id) ? { ...item, ...(newText ? { text: newText } : {}), ...(hasCompleted ? { completed: params.completed } : {}), } : item, ); mutated = true; break; } case "check": case "uncheck": { const targetIds = idsFromParams(params); if (targetIds.length === 0) throw new Error(`action=${action} requires id or ids.`); const missing = targetIds.filter((id) => !items.some((item) => item.id === id)); if (missing.length > 0) throw new Error(`Unknown todo id(s): ${missing.join(", ")}.`); if (nextTitle) title = nextTitle; const completed = action === "check"; items = items.map((item) => targetIds.includes(item.id) ? { ...item, completed } : item); mutated = true; break; } case "clear": title = nextTitle ?? DEFAULT_TITLE; items = []; mutated = true; break; case "list": case "status": if (nextTitle) { title = nextTitle; mutated = true; } break; default: throw new Error(`Unsupported todo action: ${String(action)}.`); } if (mutated) persistActiveState(); } catch (err) { error = err instanceof Error ? err.message : String(err); } const widget = syncWidget(ctx, title, items); const details = snapshotDetails(action, title, items, widget, error); return { content: [{ type: "text" as const, text: formatTodoStatus(details) }], details, }; }, }); }