import * as fs from "node:fs/promises"; import { resolveToCwd } from "../../tools/path-utils"; import { applyOpsToPhases, getLatestTodoPhasesFromEntries, markdownToPhases, phasesToMarkdown, type TodoItem, type TodoPhase, USER_TODO_EDIT_CUSTOM_TYPE, } from "../../tools/todo-write"; import { copyToClipboard } from "../../utils/clipboard"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import type { InteractiveModeContext } from "../types"; const USAGE = [ "Usage: /todo [args]", " /todo Show current todos", " /todo edit Open todos in $EDITOR", " /todo copy Copy todos as Markdown to clipboard", " /todo export Write todos as Markdown to ", " /todo import Replace todos from Markdown at ", " /todo append [] Append a task; phase fuzzy-matched or auto-created", " /todo start Mark task in_progress (fuzzy content match)", " /todo done [] Mark task/phase/all completed", " /todo drop [] Mark task/phase/all abandoned", " /todo rm [] Remove task/phase/all", ].join("\n"); // ============================================================================= // Argument tokenizer (respects double-quoted strings) // ============================================================================= function tokenize(input: string): string[] { const tokens: string[] = []; let cur = ""; let inQuote = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === "\\" && i + 1 < input.length) { cur += input[++i]; continue; } if (ch === '"') { inQuote = !inQuote; continue; } if (!inQuote && /\s/.test(ch)) { if (cur) { tokens.push(cur); cur = ""; } continue; } cur += ch; } if (cur) tokens.push(cur); return tokens; } // ============================================================================= // Name normalization // ============================================================================= function titleCase(s: string): string { return s .split(/\s+/) .filter(Boolean) .map(word => word[0].toUpperCase() + word.slice(1)) .join(" "); } // ============================================================================= // Fuzzy matching // ============================================================================= function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined { const q = query.trim().toLowerCase(); if (!q) return undefined; // Exact name (case-insensitive) const byName = phases.find(p => p.name.toLowerCase() === q); if (byName) return byName; // Substring (prefer prefix match) const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q)); if (prefixMatches.length === 1) return prefixMatches[0]; const subMatches = phases.filter(p => p.name.toLowerCase().includes(q)); if (subMatches.length === 1) return subMatches[0]; return undefined; } function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined { const q = query.trim().toLowerCase(); if (!q) return undefined; // Exact content (case-insensitive) for (const phase of phases) { for (const task of phase.tasks) { if (task.content.toLowerCase() === q) return { task, phase }; } } const matches: Array<{ task: TodoItem; phase: TodoPhase }> = []; for (const phase of phases) { for (const task of phase.tasks) { if (task.content.toLowerCase().includes(q)) { matches.push({ task, phase }); } } } if (matches.length === 1) return matches[0]; // Prefer single in_progress/pending hit when ambiguous const active = matches.filter(m => m.task.status === "in_progress" || m.task.status === "pending"); if (active.length === 1) return active[0]; return undefined; } // ============================================================================= // Build system reminder // ============================================================================= function buildSystemReminder(action: string, phases: TodoPhase[]): string { const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd(); return [ "", `The user manually modified the todo list (${action}).`, "Current todo list:", "", md, "", ].join("\n"); } export class TodoCommandController { constructor(private readonly ctx: InteractiveModeContext) {} /** * True latest todo state for the user-facing /todo verbs. Reads from session * entries so that completed/abandoned tasks remain visible after resume * (where `session.getTodoPhases()` would have stripped them). */ #currentPhases(): TodoPhase[] { const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch()); if (fromEntries.length > 0) return fromEntries; return this.ctx.session.getTodoPhases(); } async handleTodoCommand(args: string): Promise { const trimmed = args.trim(); if (!trimmed) { this.#showCurrent(); return; } const spaceIdx = trimmed.search(/\s/); const verb = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase(); const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); switch (verb) { case "edit": await this.#editInExternalEditor(); return; case "copy": this.#copyMarkdown(); return; case "export": await this.#exportToFile(rest); return; case "import": await this.#importFromFile(rest); return; case "help": case "?": this.ctx.showStatus(USAGE); return; case "append": this.#append(rest); return; case "start": this.#start(rest); return; case "done": this.#mutateStatus(rest, "completed"); return; case "drop": this.#mutateStatus(rest, "abandoned"); return; case "rm": this.#remove(rest); return; default: this.ctx.showError(`Unknown /todo verb "${verb}".\n${USAGE}`); } } #showCurrent(): void { const phases = this.#currentPhases(); if (phases.length === 0) { this.ctx.showStatus("No todos. Use /todo append to start one."); return; } this.ctx.showStatus(phasesToMarkdown(phases).trimEnd()); } #copyMarkdown(): void { const phases = this.#currentPhases(); if (phases.length === 0) { this.ctx.showWarning("No todos to copy."); return; } try { copyToClipboard(phasesToMarkdown(phases)); this.ctx.showStatus("Copied todos as Markdown to clipboard."); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } } #resolveTodoPath(rest: string): string { const trimmed = rest.trim(); const raw = trimmed || "TODO.md"; return resolveToCwd(raw, this.ctx.sessionManager.getCwd()); } async #exportToFile(rest: string): Promise { const phases = this.#currentPhases(); if (phases.length === 0) { this.ctx.showWarning("No todos to export."); return; } const target = this.#resolveTodoPath(rest); try { await fs.writeFile(target, phasesToMarkdown(phases), "utf8"); this.ctx.showStatus(`Wrote todos to ${target}`); } catch (error) { this.ctx.showError(`Failed to write ${target}: ${error instanceof Error ? error.message : String(error)}`); } } async #importFromFile(rest: string): Promise { const source = this.#resolveTodoPath(rest); let content: string; try { content = await fs.readFile(source, "utf8"); } catch (error) { this.ctx.showError(`Failed to read ${source}: ${error instanceof Error ? error.message : String(error)}`); return; } const { phases, errors } = markdownToPhases(content); if (errors.length > 0) { this.ctx.showError(`Could not parse ${source}:\n ${errors.join("\n ")}`); return; } this.#commit(phases, `/todo import ${source}`); const taskCount = phases.reduce((sum, p) => sum + p.tasks.length, 0); this.ctx.showStatus(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${source}.`); } // ------------------------------------------------------------- append #append(rest: string): void { const tokens = tokenize(rest); if (tokens.length === 0) { this.ctx.showError("Usage: /todo append [] "); return; } const current = this.#currentPhases(); let phaseName: string | undefined; let content: string; if (tokens.length === 1) { content = tokens[0]; } else { phaseName = tokens[0]; content = tokens.slice(1).join(" "); } const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() })); let targetPhase: TodoPhase | undefined; if (phaseName) { targetPhase = findPhaseFuzzy(next, phaseName); if (!targetPhase) { targetPhase = { name: titleCase(phaseName), tasks: [] }; 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", }); this.#commit(next, `/todo append → ${targetPhase.name}`); this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`); } // ------------------------------------------------------------- start / done / drop / rm #start(rest: string): void { if (!rest) { this.ctx.showError("Usage: /todo start "); return; } const current = this.#currentPhases(); const hit = findTaskFuzzy(current, rest); if (!hit) { this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`); return; } const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo start ${hit.task.content}`); this.ctx.showStatus(`Started: ${hit.task.content}`); } #mutateStatus(rest: string, target: "completed" | "abandoned"): void { const op = target === "completed" ? "done" : "drop"; const current = this.#currentPhases(); const trimmed = rest.trim(); if (!trimmed) { // no-arg: apply to all const { phases, errors } = applyOpsToPhases(current, [{ op }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo ${op} (all)`); this.ctx.showStatus(`Marked all tasks ${target}.`); return; } const taskHit = findTaskFuzzy(current, trimmed); if (taskHit) { const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo ${op} ${taskHit.task.content}`); this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`); return; } const phaseHit = findPhaseFuzzy(current, trimmed); if (phaseHit) { const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo ${op} ${phaseHit.name}`); this.ctx.showStatus(`Marked phase ${phaseHit.name} ${target}.`); return; } this.ctx.showError(`No task or phase matched "${trimmed}".`); } #remove(rest: string): void { const current = this.#currentPhases(); const trimmed = rest.trim(); if (!trimmed) { this.#commit([], "/todo rm (all)"); this.ctx.showStatus("Cleared all todos."); return; } const taskHit = findTaskFuzzy(current, trimmed); if (taskHit) { const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo rm ${taskHit.task.content}`); this.ctx.showStatus(`Removed: ${taskHit.task.content}`); return; } const phaseHit = findPhaseFuzzy(current, trimmed); if (phaseHit) { const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } this.#commit(phases, `/todo rm ${phaseHit.name}`); this.ctx.showStatus(`Removed phase: ${phaseHit.name}`); return; } this.ctx.showError(`No task or phase matched "${trimmed}".`); } // ------------------------------------------------------------- editor async #editInExternalEditor(): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) { this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); return; } const current = this.#currentPhases(); const initialMarkdown = current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n"; const fileHandle = await this.#openTtyHandle(); this.ctx.ui.stop(); try { const stdio: [number | "inherit", number | "inherit", number | "inherit"] = fileHandle ? [fileHandle.fd, fileHandle.fd, fileHandle.fd] : ["inherit", "inherit", "inherit"]; const result = await openInEditor(editorCmd, initialMarkdown, { extension: ".todo.md", stdio, }); if (result === null) { this.ctx.showWarning("Editor exited without saving; todos unchanged."); return; } const { phases: parsed, errors } = markdownToPhases(result); if (errors.length > 0) { this.ctx.showError(`Could not parse Markdown:\n ${errors.join("\n ")}`); return; } this.#commit(parsed, "/todo edit"); const taskCount = parsed.reduce((sum, p) => sum + p.tasks.length, 0); this.ctx.showStatus(`Todos updated from editor: ${parsed.length} phase(s), ${taskCount} task(s).`); } catch (error) { this.ctx.showWarning( `Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`, ); } finally { if (fileHandle) { await fileHandle.close().catch(() => {}); } this.ctx.ui.start(); this.ctx.ui.requestRender(); } } async #openTtyHandle(): Promise { const stdinPath = (process.stdin as unknown as { path?: string }).path; const candidate = typeof stdinPath === "string" ? stdinPath : undefined; if (!candidate) return null; try { return await fs.open(candidate, "r+"); } catch { return null; } } #commit(nextPhases: TodoPhase[], action: string): void { // 1. In-memory + UI state this.ctx.session.setTodoPhases(nextPhases); this.ctx.setTodos(nextPhases); // 2. Persist for reload survival via custom session entry. this.ctx.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: nextPhases }); // 3. Inject system reminder so the agent learns about the change next turn. const reminderText = buildSystemReminder(action, nextPhases); const message = { role: "developer" as const, content: [{ type: "text" as const, text: reminderText }], attribution: "user" as const, timestamp: Date.now(), }; this.ctx.agent.appendMessage(message); this.ctx.sessionManager.appendMessage(message); } } /** Capitalize first letter only — keeps acronyms / casing in the rest of the sentence intact. */ function titleCaseSentence(s: string): string { const trimmed = s.trim(); if (!trimmed) return trimmed; return trimmed[0].toUpperCase() + trimmed.slice(1); }