import * as path from "node:path"; import type { TodoPhase } from "../../tools/todo-write"; import { applyOpsToPhases, getLatestTodoPhasesFromEntries, markdownToPhases, phasesToMarkdown, USER_TODO_EDIT_CUSTOM_TYPE, } from "../../tools/todo-write"; import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types"; import { commandConsumed, parseSubcommand, usage } from "./parse"; type TodoMutationVerb = "done" | "drop" | "rm"; interface TodoTaskMatch { task: { content: string; status: string }; phase: TodoPhase; } 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/.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+/) .filter(Boolean) .map(word => word[0].toUpperCase() + word.slice(1)) .join(" "); } function titleCaseSentence(text: string): string { const trimmed = text.trim(); if (!trimmed) return trimmed; return trimmed[0].toUpperCase() + trimmed.slice(1); } function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return undefined; const exact = phases.find(phase => phase.name.toLowerCase() === normalizedQuery); if (exact) return exact; const prefixMatches = phases.filter(phase => phase.name.toLowerCase().startsWith(normalizedQuery)); if (prefixMatches.length === 1) return prefixMatches[0]; const substringMatches = phases.filter(phase => phase.name.toLowerCase().includes(normalizedQuery)); if (substringMatches.length === 1) return substringMatches[0]; return undefined; } function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | undefined { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return undefined; for (const phase of phases) { for (const task of phase.tasks) { if (task.content.toLowerCase() === normalizedQuery) return { task, phase }; } } const matches: TodoTaskMatch[] = []; for (const phase of phases) { for (const task of phase.tasks) { if (task.content.toLowerCase().includes(normalizedQuery)) matches.push({ task, phase }); } } if (matches.length === 1) return matches[0]; const active = matches.filter(match => match.task.status === "in_progress" || match.task.status === "pending"); if (active.length === 1) return active[0]; return undefined; } function currentPhases(runtime: SlashCommandRuntime): TodoPhase[] { const fromEntries = getLatestTodoPhasesFromEntries(runtime.sessionManager.getBranch()); return fromEntries.length > 0 ? fromEntries : runtime.session.getTodoPhases(); } function commitTodos(runtime: SlashCommandRuntime, phases: TodoPhase[]): void { runtime.session.setTodoPhases(phases); runtime.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases }); } const TODO_HELP_TEXT = [ "Usage: /todo [args]", " /todo Show current todos", " /todo edit (TUI only) open in $EDITOR", " /todo copy Print todos as Markdown", " /todo export [] Write todos to file (default: TODO.md)", " /todo import [] Replace todos from file (default: TODO.md)", " /todo append [] Append a task", " /todo start Mark task in_progress (fuzzy match)", " /todo done [] Mark task/phase/all completed", " /todo drop [] Mark task/phase/all abandoned", " /todo rm [] Remove task/phase/all", ].join("\n"); async function handleTodoCopyCommand(runtime: SlashCommandRuntime): Promise { const phases = currentPhases(runtime); const markdown = phases.length === 0 ? "" : phasesToMarkdown(phases).trimEnd(); await runtime.output(`Copy not available in ACP mode; printing instead:\n\n${markdown || "No todos."}`); return commandConsumed(); } async function handleTodoExportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise { const phases = currentPhases(runtime); if (phases.length === 0) { await runtime.output("No todos to export."); return commandConsumed(); } const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md"); await Bun.write(target, phasesToMarkdown(phases)); await runtime.output(`Wrote todos to ${target}`); return commandConsumed(); } async function handleTodoImportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise { const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md"); let content: string; try { content = await Bun.file(target).text(); } catch (err) { return usage(`Failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`, runtime); } const { phases, errors } = markdownToPhases(content); if (errors.length > 0) return usage(`Could not parse ${target}:\n ${errors.join("\n ")}`, runtime); commitTodos(runtime, phases); const taskCount = phases.reduce((sum, phase) => sum + phase.tasks.length, 0); await runtime.output(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${target}.`); return commandConsumed(); } async function handleTodoAppendCommand(restArgs: string, runtime: SlashCommandRuntime): Promise { const tokens = tokenize(restArgs); if (tokens.length === 0) return usage("Usage: /todo append [] ", runtime); const current = currentPhases(runtime); const phaseName = tokens.length === 1 ? undefined : tokens[0]; const content = tokens.length === 1 ? tokens[0]! : tokens.slice(1).join(" "); const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() })); let targetPhase: TodoPhase; if (phaseName) { const existing = findPhaseFuzzy(next, phaseName); targetPhase = existing ?? { name: titleCaseWords(phaseName), tasks: [] }; if (!existing) next.push(targetPhase); } else if (next.length > 0) { targetPhase = next[next.length - 1]!; } else { targetPhase = { name: "Todos", tasks: [] }; next.push(targetPhase); } const finalContent = titleCaseSentence(content); targetPhase.tasks.push({ content: finalContent, status: "pending" }); commitTodos(runtime, next); await runtime.output(`Appended to ${targetPhase.name}: ${finalContent}`); return commandConsumed(); } async function handleTodoStartCommand(restArgs: string, runtime: SlashCommandRuntime): Promise { if (!restArgs) return usage("Usage: /todo start ", runtime); const current = currentPhases(runtime); const query = tokenize(restArgs).join(" ") || restArgs; const hit = findTaskFuzzy(current, query); if (!hit) return usage(`No task matched "${restArgs}". Use /todo to list current tasks.`, runtime); const { phases } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]); commitTodos(runtime, phases); await runtime.output(`Started: ${hit.task.content}`); return commandConsumed(); } async function handleTodoMutationCommand( verb: TodoMutationVerb, restArgs: string, runtime: SlashCommandRuntime, ): Promise { const current = currentPhases(runtime); const trimmedArg = restArgs.trim(); if (!trimmedArg) { if (verb === "rm") { commitTodos(runtime, []); await runtime.output("Cleared all todos."); return commandConsumed(); } const { phases } = applyOpsToPhases(current, [{ op: verb }]); commitTodos(runtime, phases); await runtime.output(verb === "done" ? "Marked all tasks completed." : "Marked all tasks abandoned."); return commandConsumed(); } const taskHit = findTaskFuzzy(current, trimmedArg); if (taskHit) { const { phases } = applyOpsToPhases(current, [{ op: verb, task: taskHit.task.content }]); commitTodos(runtime, phases); const label = verb === "done" ? "Marked completed" : verb === "drop" ? "Marked abandoned" : "Removed"; await runtime.output(`${label}: ${taskHit.task.content}`); return commandConsumed(); } const phaseHit = findPhaseFuzzy(current, trimmedArg); if (phaseHit) { const { phases } = applyOpsToPhases(current, [{ op: verb, phase: phaseHit.name }]); commitTodos(runtime, phases); const message = verb === "done" ? `Marked phase ${phaseHit.name} completed.` : verb === "drop" ? `Marked phase ${phaseHit.name} abandoned.` : `Removed phase: ${phaseHit.name}`; await runtime.output(message); return commandConsumed(); } return usage(`No task or phase matched "${trimmedArg}".`, runtime); } /** ACP/text-mode `/todo` handler. Shared by both dispatchers via the spec. */ export async function handleTodoAcp( command: ParsedSlashCommand, runtime: SlashCommandRuntime, ): Promise { const trimmed = command.args.trim(); if (!trimmed) { const phases = currentPhases(runtime); await runtime.output( phases.length === 0 ? "No todos. Use /todo append to start one." : phasesToMarkdown(phases).trimEnd(), ); return commandConsumed(); } const { verb, rest } = parseSubcommand(trimmed); switch (verb) { case "copy": return await handleTodoCopyCommand(runtime); case "export": return await handleTodoExportCommand(rest, runtime); case "import": return await handleTodoImportCommand(rest, runtime); case "append": return await handleTodoAppendCommand(rest, runtime); case "start": return await handleTodoStartCommand(rest, runtime); case "done": case "drop": case "rm": return await handleTodoMutationCommand(verb, rest, runtime); case "edit": return usage( "/todo edit requires the TUI editor; use /todo export then /todo import for non-interactive edits.", runtime, ); case "help": case "?": await runtime.output(TODO_HELP_TEXT); return commandConsumed(); default: return usage("Unknown /todo subcommand. Use append, start, done, drop, rm, copy, export, import.", runtime); } }