import { Type } from "@sinclair/typebox"; import type { ExtensionAPI, ExtensionContext } from "../_shared/pi-api.js"; import { errorResult, getCommandText, setTextWidget, textResult } from "../_shared/pi-api.js"; import { emitDevEvent } from "../_shared/event-bus.js"; import { sharedState } from "../_shared/state.js"; import { validateParams } from "../_shared/validation.js"; const TodoWriteParams = Type.Object({ ops: Type.Array(Type.Object({ op: Type.Union([ Type.Literal("init"), Type.Literal("start"), Type.Literal("done"), Type.Literal("drop"), Type.Literal("rm"), Type.Literal("append"), Type.Literal("note"), ]), phase: Type.Optional(Type.String()), task: Type.Optional(Type.String()), items: Type.Optional(Type.Array(Type.String(), { minItems: 1, maxItems: 20 })), text: Type.Optional(Type.String({ maxLength: 500 })), list: Type.Optional(Type.Array(Type.Object({ phase: Type.String(), items: Type.Array(Type.String(), { minItems: 1 }), }))), }), { minItems: 1, maxItems: 30 }), }); type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned"; interface TodoTask { content: string; status: TodoStatus; notes?: string[]; } interface TodoPhase { name: string; tasks: TodoTask[]; } interface TodoOp { op: string; phase?: string; task?: string; items?: string[]; text?: string; list?: Array<{ phase: string; items: string[] }>; } interface TodoCompletionTransition { phase: string; content: string; } export default function todoContext(pi: ExtensionAPI): void { pi.registerCommand("todo", { description: "Show and edit OMP-style todos from the session todo state.", handler: async (args, ctx) => { await handleTodoCommand(pi, getCommandText(args), ctx); }, }); pi.registerTool({ name: "todo_write", description: "Apply ordered OMP-compatible todo operations to visible session todo state.", parameters: TodoWriteParams, async execute(_toolCallId, params) { const valid = validateParams(TodoWriteParams, params); if (!valid.ok) return valid.result; const previousPhases = await loadTodoPhases(pi); const { phases, errors } = applyTodoOps(previousPhases, valid.value.ops as TodoOp[]); const completedTasks = getCompletionTransitions(previousPhases, phases); await commitTodoPhases(pi, phases); const details = { phases: sharedState.todos, storage: "session", activeTask: findActiveTask(sharedState.todos), ...(completedTasks.length > 0 ? { completedTasks } : {}), }; const summary = renderTodos(sharedState.todos, errors); return errors.length > 0 ? errorResult(summary, details) : textResult(summary, details); }, }); } async function handleTodoCommand(pi: ExtensionAPI, text: string, ctx: ExtensionContext): Promise { const input = text.trim(); if (!input || input === "show" || input === "list") { await showTodos(pi, ctx); return; } if (input === "help" || input === "?") { setTextWidget(ctx, "todo", TODO_HELP); return; } const [verb = "", rest = ""] = splitCommand(input); if (verb === "edit") { await editTodos(pi, ctx); return; } if (verb === "copy") { await showTodos(pi, ctx, "Copy not available here; printing Markdown instead."); return; } if (verb === "append") { await appendTodo(pi, ctx, rest); return; } if (verb === "start") { await startTodo(pi, ctx, rest); return; } if (verb === "done" || verb === "drop" || verb === "rm") { await mutateTodo(pi, ctx, verb, rest); return; } ctx.ui.notify(`Unknown /todo verb: ${verb}`, "warn"); setTextWidget(ctx, "todo", TODO_HELP); } async function loadTodoPhases(pi: ExtensionAPI): Promise { const [latest] = await pi.getEntries({ type: "todo_write", limit: 1 }); const data = latest?.data as { phases?: unknown } | undefined; if (data && Array.isArray(data.phases)) return clonePhases(data.phases as TodoPhase[]); return clonePhases(sharedState.todos); } async function commitTodoPhases(pi: ExtensionAPI, phases: TodoPhase[]): Promise { sharedState.todos = clonePhases(phases); await pi.appendEntry("todo_write", { phases: sharedState.todos }); emitDevEvent("todo:update", { phases: sharedState.todos.length }); } async function showTodos(pi: ExtensionAPI, ctx: ExtensionContext, prefix?: string): Promise { const phases = await loadTodoPhases(pi); setTextWidget(ctx, "todo", [ ...(prefix ? [prefix, ""] : []), phases.length === 0 ? "No todos. Use /todo append to start one." : phasesToMarkdown(phases).trimEnd(), ].join("\n")); } async function editTodos(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const current = await loadTodoPhases(pi); const initial = current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] Replace this with your first task\n"; const result = await ctx.ui.editor("Edit todos", initial, { language: "markdown" }); if (result.cancelled) { setTextWidget(ctx, "todo", "Todo edit cancelled."); return; } const parsed = markdownToPhases(result.value); if (parsed.errors.length > 0) { ctx.ui.notify("Todo Markdown parse failed.", "warn"); setTextWidget(ctx, "todo", `Could not parse todos:\n${parsed.errors.map((error) => `- ${error}`).join("\n")}`); return; } await commitTodoPhases(pi, parsed.phases); setTextWidget(ctx, "todo", [ `Todos updated from editor: ${parsed.phases.length} phase(s), ${parsed.phases.reduce((sum, phase) => sum + phase.tasks.length, 0)} task(s).`, "", phasesToMarkdown(parsed.phases).trimEnd(), ].join("\n")); } async function appendTodo(pi: ExtensionAPI, ctx: ExtensionContext, rest: string): Promise { const tokens = tokenize(rest); if (tokens.length === 0) { setTextWidget(ctx, "todo", "Usage: /todo append [] "); return; } const current = await loadTodoPhases(pi); const phaseName = tokens.length === 1 ? undefined : tokens[0]; const content = tokens.length === 1 ? tokens[0]! : tokens.slice(1).join(" "); const next = clonePhases(current); let target = phaseName ? findPhaseFuzzy(next, phaseName) : next[next.length - 1]; if (!target) { target = { name: phaseName ? titleCaseWords(phaseName) : "Todos", tasks: [] }; next.push(target); } target.tasks.push({ content: titleCaseSentence(content), status: "pending" }); normalizeInProgressTask(next); await commitTodoPhases(pi, next); setTextWidget(ctx, "todo", `Appended to ${target.name}: ${titleCaseSentence(content)}\n\n${phasesToMarkdown(next).trimEnd()}`); } async function startTodo(pi: ExtensionAPI, ctx: ExtensionContext, rest: string): Promise { const current = await loadTodoPhases(pi); const hit = findTaskFuzzy(current, rest); if (!hit) { setTextWidget(ctx, "todo", `No task matched "${rest}". Use /todo to list current tasks.`); return; } const { phases } = applyTodoOps(current, [{ op: "start", task: hit.task.content }]); await commitTodoPhases(pi, phases); setTextWidget(ctx, "todo", `Started: ${hit.task.content}\n\n${phasesToMarkdown(phases).trimEnd()}`); } async function mutateTodo(pi: ExtensionAPI, ctx: ExtensionContext, verb: "done" | "drop" | "rm", rest: string): Promise { const current = await loadTodoPhases(pi); const trimmed = rest.trim(); if (!trimmed) { const op = verb === "rm" ? { op: "rm" } : { op: verb }; const { phases } = applyTodoOps(current, [op]); await commitTodoPhases(pi, phases); setTextWidget(ctx, "todo", `${verb === "done" ? "Marked all tasks completed." : verb === "drop" ? "Marked all tasks abandoned." : "Cleared all todos."}\n\n${phasesToMarkdown(phases).trimEnd()}`); return; } const taskHit = findTaskFuzzy(current, trimmed); const phaseHit = taskHit ? undefined : findPhaseFuzzy(current, trimmed); if (!taskHit && !phaseHit) { setTextWidget(ctx, "todo", `No task or phase matched "${trimmed}".`); return; } const { phases } = applyTodoOps(current, [taskHit ? { op: verb, task: taskHit.task.content } : { op: verb, phase: phaseHit!.name }]); await commitTodoPhases(pi, phases); const target = taskHit?.task.content ?? phaseHit!.name; const label = verb === "done" ? "Marked completed" : verb === "drop" ? "Marked abandoned" : "Removed"; setTextWidget(ctx, "todo", `${label}: ${target}\n\n${phasesToMarkdown(phases).trimEnd()}`); } const TODO_HELP = [ "Usage: /todo [args]", " /todo Show current todos", " /todo edit Edit todos as Markdown", " /todo copy Print todos as Markdown", " /todo append [] Append a task", " /todo start Mark task in_progress", " /todo done [] Mark task/phase/all completed", " /todo drop [] Mark task/phase/all abandoned", " /todo rm [] Remove task/phase/all", ].join("\n"); function splitCommand(input: string): [verb: string, rest: string] { const space = input.search(/\s/u); return space === -1 ? [input.toLowerCase(), ""] : [input.slice(0, space).toLowerCase(), input.slice(space + 1).trim()]; } function tokenize(input: string): string[] { const tokens: string[] = []; let current = ""; let inQuote = false; for (let index = 0; index < input.length; index++) { const ch = input[index]!; if (ch === "\\" && index + 1 < input.length) { current += input[++index]; continue; } if (ch === "\"") { inQuote = !inQuote; continue; } if (!inQuote && /\s/u.test(ch)) { if (current) { tokens.push(current); current = ""; } continue; } current += ch; } if (current) tokens.push(current); return tokens; } function titleCaseWords(text: string): string { return text .split(/\s+/u) .filter(Boolean) .map((word) => word[0]!.toUpperCase() + word.slice(1)) .join(" "); } function titleCaseSentence(text: string): string { const trimmed = text.trim(); return trimmed ? trimmed[0]!.toUpperCase() + trimmed.slice(1) : trimmed; } function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined { const normalized = query.trim().toLowerCase(); if (!normalized) return undefined; const exact = phases.find((phase) => phase.name.toLowerCase() === normalized); if (exact) return exact; const prefixMatches = phases.filter((phase) => phase.name.toLowerCase().startsWith(normalized)); if (prefixMatches.length === 1) return prefixMatches[0]; const substringMatches = phases.filter((phase) => phase.name.toLowerCase().includes(normalized)); return substringMatches.length === 1 ? substringMatches[0] : undefined; } function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoTask; phase: TodoPhase } | undefined { const normalized = query.trim().toLowerCase(); if (!normalized) return undefined; for (const phase of phases) { const exact = phase.tasks.find((task) => task.content.toLowerCase() === normalized); if (exact) return { task: exact, phase }; } const matches = phases.flatMap((phase) => phase.tasks .filter((task) => task.content.toLowerCase().includes(normalized)) .map((task) => ({ task, phase }))); if (matches.length === 1) return matches[0]; const active = matches.filter(({ task }) => task.status === "pending" || task.status === "in_progress"); return active.length === 1 ? active[0] : undefined; } const STATUS_TO_MARKER: Record = { pending: " ", in_progress: "/", completed: "x", abandoned: "-", }; function phasesToMarkdown(phases: TodoPhase[]): string { if (phases.length === 0) return "# Todos\n"; const lines: string[] = []; for (const [index, phase] of phases.entries()) { if (index > 0) lines.push(""); lines.push(`# ${phase.name}`); for (const task of phase.tasks) { lines.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`); for (const [noteIndex, note] of (task.notes ?? []).entries()) { if (noteIndex > 0) lines.push(" >"); for (const noteLine of note.split("\n")) lines.push(noteLine ? ` > ${noteLine}` : " >"); } } } return `${lines.join("\n")}\n`; } const MARKER_TO_STATUS: Record = { " ": "pending", "": "pending", x: "completed", X: "completed", "/": "in_progress", ">": "in_progress", "-": "abandoned", "~": "abandoned", }; function markdownToPhases(markdown: string): { phases: TodoPhase[]; errors: string[] } { const phases: TodoPhase[] = []; const errors: string[] = []; let currentPhase: TodoPhase | undefined; let currentTask: TodoTask | undefined; let noteBuffer: string[] = []; const flushNote = () => { if (!currentTask || noteBuffer.length === 0) { noteBuffer = []; return; } while (noteBuffer[noteBuffer.length - 1] === "") noteBuffer.pop(); if (noteBuffer.length > 0) currentTask.notes = [...(currentTask.notes ?? []), noteBuffer.join("\n")]; noteBuffer = []; }; markdown.split(/\r?\n/u).forEach((raw, index) => { const note = /^\s*>\s?(.*)$/u.exec(raw); if (note && currentTask) { if (note[1] === "") flushNote(); else noteBuffer.push(note[1] ?? ""); return; } const trimmed = raw.trim(); if (!trimmed) return; const heading = /^#{1,6}\s+(.+?)\s*$/u.exec(trimmed); if (heading) { flushNote(); currentTask = undefined; currentPhase = { name: heading[1]!.trim(), tasks: [] }; phases.push(currentPhase); return; } const task = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/u.exec(trimmed); if (task) { flushNote(); currentPhase ??= { name: "Todos", tasks: [] }; if (!phases.includes(currentPhase)) phases.push(currentPhase); const status = MARKER_TO_STATUS[task[1] ?? ""]; if (!status) { errors.push(`Line ${index + 1}: unknown status marker "[${task[1]}]" (use [ ], [x], [/], [-])`); currentTask = undefined; return; } currentTask = { content: task[2]!.trim(), status }; currentPhase.tasks.push(currentTask); return; } flushNote(); currentTask = undefined; errors.push(`Line ${index + 1}: unrecognized syntax "${trimmed}"`); }); flushNote(); normalizeInProgressTask(phases); return { phases, errors }; } function clonePhases(phases: TodoPhase[]): TodoPhase[] { return phases.map((phase) => ({ name: phase.name, tasks: phase.tasks.map((task) => ({ content: task.content, status: task.status, ...(task.notes && task.notes.length > 0 ? { notes: [...task.notes] } : {}), })), })); } function applyTodoOps(currentPhases: TodoPhase[], ops: TodoOp[]): { phases: TodoPhase[]; errors: string[] } { const errors: string[] = []; let phases = clonePhases(currentPhases); for (const op of ops) { if (op.op === "init") { if (!op.list) { errors.push("Missing list for init operation"); continue; } phases = op.list.map((phase) => ({ name: phase.phase, tasks: phase.items.map((content) => ({ content, status: "pending" as const, })), })); } if (op.op === "append" && op.phase) { appendItems(phases, op, errors); } if (op.op === "append" && !op.phase) { errors.push("Missing phase name for append operation"); } if (op.op === "start") { const hit = resolveTaskOrError(phases, op.task, errors); if (hit) { for (const phase of phases) { for (const task of phase.tasks) { if (task.status === "in_progress" && task !== hit.task) task.status = "pending"; } } hit.task.status = "in_progress"; } } if (op.op === "done") setTargets(phases, op, "completed", errors); if (op.op === "drop") setTargets(phases, op, "abandoned", errors); if (op.op === "rm") removeTargets(phases, op, errors); if (op.op === "note") addNote(phases, op, errors); } normalizeInProgressTask(phases); return { phases, errors }; } function getPhase(phases: TodoPhase[], name: string) { let phase = phases.find((item) => item.name === name); if (!phase) { phase = { name, tasks: [] }; phases.push(phase); } return phase; } function findTask(phases: TodoPhase[], content: string) { for (const phase of phases) { const task = phase.tasks.find((item) => item.content === content); if (task) return { task, phase }; } return undefined; } function resolveTaskOrError(phases: TodoPhase[], content: string | undefined, errors: string[]) { if (!content) { errors.push("Missing task content"); return undefined; } const hit = findTask(phases, content); if (!hit) { if (/^task-\d+$/u.test(content)) { errors.push(`Task "${content}" not found. Tasks are referenced by content, not by IDs - pass the task's full text from the previous result.`); return undefined; } const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0); const hint = totalTasks === 0 ? " (todo list is empty - was it replaced or not yet created?)" : ""; errors.push(`Task "${content}" not found${hint}`); } return hit; } function resolvePhaseOrError(phases: TodoPhase[], name: string | undefined, errors: string[]) { if (!name) { errors.push("Missing phase name"); return undefined; } const phase = phases.find((item) => item.name === name); if (!phase) errors.push(`Phase "${name}" not found`); return phase; } function appendItems(phases: TodoPhase[], op: TodoOp, errors: string[]): void { if (!op.items || op.items.length === 0) { errors.push("Missing items for append operation"); return; } const phase = getPhase(phases, op.phase ?? ""); for (const content of op.items) { if (findTask(phases, content)) { errors.push(`Task "${content}" already exists`); return; } phase.tasks.push({ content, status: "pending" }); } } function getTargets(phases: TodoPhase[], op: TodoOp, errors: string[]): TodoTask[] { if (op.task) { const hit = resolveTaskOrError(phases, op.task, errors); return hit ? [hit.task] : []; } if (op.phase) { const phase = resolvePhaseOrError(phases, op.phase, errors); return phase ? [...phase.tasks] : []; } return phases.flatMap((phase) => phase.tasks); } function setTargets(phases: TodoPhase[], op: TodoOp, status: "completed" | "abandoned", errors: string[]): void { for (const task of getTargets(phases, op, errors)) task.status = status; } function removeTargets(phases: TodoPhase[], op: TodoOp, errors: string[]): void { if (op.task) { const hit = resolveTaskOrError(phases, op.task, errors); if (!hit) return; hit.phase.tasks = hit.phase.tasks.filter((candidate) => candidate !== hit.task); return; } if (op.phase) { const phase = resolvePhaseOrError(phases, op.phase, errors); if (phase) phase.tasks = []; return; } for (const phase of phases) phase.tasks = []; } function addNote(phases: TodoPhase[], op: TodoOp, errors: string[]): void { const hit = resolveTaskOrError(phases, op.task, errors); if (!hit) return; const text = (op.text ?? "").replace(/\s+$/u, ""); if (!text) { errors.push("Missing text for note operation"); return; } hit.task.notes = hit.task.notes ? [...hit.task.notes, text] : [text]; } function normalizeInProgressTask(phases: TodoPhase[]): void { const orderedTasks = phases.flatMap((phase) => phase.tasks); const inProgressTasks = orderedTasks.filter((task) => task.status === "in_progress"); for (const task of inProgressTasks.slice(1)) task.status = "pending"; if (inProgressTasks.length > 0) return; const firstPendingTask = orderedTasks.find((task) => task.status === "pending"); if (firstPendingTask) firstPendingTask.status = "in_progress"; } function findActiveTask(phases: TodoPhase[]) { return phases.flatMap((phase) => phase.tasks).find((task) => task.status === "in_progress")?.content; } function getCompletionTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoCompletionTransition[] { const previousStatuses = new Map(); for (const phase of previous) { for (const task of phase.tasks) previousStatuses.set(`${phase.name}\0${task.content}`, task.status); } const transitions: TodoCompletionTransition[] = []; for (const phase of updated) { for (const task of phase.tasks) { const previousStatus = previousStatuses.get(`${phase.name}\0${task.content}`); if (task.status === "completed" && previousStatus && previousStatus !== "completed") { transitions.push({ phase: phase.name, content: task.content }); } } } return transitions; } function renderTodos(phases: TodoPhase[], errors: string[]) { const tasks = phases.flatMap((phase) => phase.tasks); if (tasks.length === 0) return errors.length > 0 ? `Errors: ${errors.join("; ")}` : "Todo list cleared."; const remaining = phases .flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name }))) .filter((task) => task.status === "pending" || task.status === "in_progress"); const lines: string[] = []; if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`); if (remaining.length === 0) { lines.push("Remaining items: none."); } else { lines.push(`Remaining items (${remaining.length}):`); for (const task of remaining) lines.push(` - ${task.content} [${task.status}] (${task.phase})`); } for (const phase of phases) { lines.push(`${phase.name}:`); for (const task of phase.tasks) { const noteCount = task.notes?.length ?? 0; const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : ""; lines.push(` - [${task.status}] ${task.content}${noteMarker}`); if (task.status === "in_progress" && task.notes) { for (const note of task.notes) { for (const line of note.split("\n")) lines.push(` ${line}`); } } } } return lines.join("\n"); }