import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { CARD_TONE, panelHeaderRow, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts"; import { sanitizeTerminalText } from "./terminal-theme.ts"; import { paintHoverable } from "./shell-hover.ts"; // Gentle Todo: the task list the model keeps while it works. Everything here // is pure. The state lives in the session branch (every tool result carries // the full snapshot), and staleness is how many turns passed since the last // write while tasks are still open. export const TODO_STATUS = { PENDING: "pending", IN_PROGRESS: "in_progress", /** Open, but waiting on something outside the list; always carries a note naming it. */ BLOCKED: "blocked", DONE: "done", /** Will not be done, on purpose: terminal, and never counts as open. */ DROPPED: "dropped", } as const; export type TodoStatus = (typeof TODO_STATUS)[keyof typeof TODO_STATUS]; export const TODO_ACTION = { WRITE: "write", ADD: "add", UPDATE: "update", CLEAR: "clear", LIST: "list", } as const; export type TodoAction = (typeof TODO_ACTION)[keyof typeof TODO_ACTION]; export interface TodoTask { id: number; title: string; status: TodoStatus; note?: string; } export interface TodoState { tasks: TodoTask[]; nextId: number; /** Turn index of the last write, or null before the first one. */ updatedTurn: number | null; } export interface TodoTaskInput { id?: number; title?: string; status?: string; note?: string; } export interface TodoParams { action: string; tasks?: TodoTaskInput[]; id?: number; title?: string; status?: string; note?: string; } export interface TodoResult { state: TodoState; text: string; error?: string; } export interface TodoSummary { done: number; total: number; open: number; } export interface TodoTheme extends CardTheme { strikethrough(text: string): string; } export interface TodoRenderOptions { /** A scrollable host supplies the height bound instead of folding tasks. */ scrollable?: boolean; collapsed: boolean; staleTurns: number; collapseKey?: string; /** The pointer is over the clickable collapse/expand control. */ hovered?: boolean; } /** Tool results carry the snapshot under this key; the old rpiv-todo shape is read too. */ export const TODO_DETAILS_KEY = "gentleTodo"; export const TODO_TOOL_NAME = "todo"; export const TODO_GLYPH = "❀"; /** Emitted on pi.events after each successful todo write; gentle-agents publishes it as session progress. */ export const TODO_PROGRESS_EVENT = "gentle-pi:todo:progress"; export interface TodoProgress { sessionId: string; done: number; total: number; /** Title of the first in-progress task, or null when none is in progress. */ current: string | null; } export function todoProgress(state: TodoState, sessionId: string): TodoProgress { const current = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS)?.title ?? null; return { sessionId, done: countStatus(state, TODO_STATUS.DONE), total: state.tasks.length, current }; } const STATUS_ALIASES: Record = { completed: TODO_STATUS.DONE, complete: TODO_STATUS.DONE, doing: TODO_STATUS.IN_PROGRESS, todo: TODO_STATUS.PENDING, cancelled: TODO_STATUS.DROPPED, canceled: TODO_STATUS.DROPPED, abandoned: TODO_STATUS.DROPPED, }; const STATUS_LIST = "pending, in_progress, blocked, done, or dropped"; const BLOCKED_NEEDS_NOTE = "blocked needs a note naming what it waits for"; const STATUS_GLYPH: Record = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.BLOCKED]: "⊘", [TODO_STATUS.DONE]: "✓", [TODO_STATUS.DROPPED]: "✕" }; const STATUS_ROLE: Record = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "text", [TODO_STATUS.DONE]: "dim", [TODO_STATUS.DROPPED]: "dim" }; const GLYPH_ROLE: Record = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "warning", [TODO_STATUS.DONE]: "success", [TODO_STATUS.DROPPED]: "muted" }; const NOTE_ROLE = "muted"; const STALE_AFTER_TURNS = 2; /** Above this many rows the finished tasks fold into one line and the rest is capped. */ const ROW_CAP = 12; export function emptyTodo(): TodoState { return { tasks: [], nextId: 1, updatedTurn: null }; } function cleanText(value: unknown): string { return typeof value === "string" ? sanitizeTerminalText(value).replace(/\s+/g, " ").trim() : ""; } function parseStatus(value: unknown): TodoStatus | undefined { if (value === undefined || value === null || value === "") return TODO_STATUS.PENDING; if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); if ((Object.values(TODO_STATUS) as string[]).includes(normalized)) return normalized as TodoStatus; return STATUS_ALIASES[normalized]; } function countStatus(state: TodoState, status: TodoStatus): number { return state.tasks.filter((task) => task.status === status).length; } /** Dropped tasks are neither done nor open. */ export function todoSummary(state: TodoState): TodoSummary { const done = countStatus(state, TODO_STATUS.DONE); return { done, total: state.tasks.length, open: state.tasks.length - done - countStatus(state, TODO_STATUS.DROPPED) }; } // Only work the model can pick up goes stale: a list whose open tasks all // wait on something outside it has nothing to bring up to date. export function staleTurns(state: TodoState, currentTurn: number): number { const actionable = state.tasks.some((task) => task.status === TODO_STATUS.PENDING || task.status === TODO_STATUS.IN_PROGRESS); if (state.updatedTurn === null || !actionable) return 0; return Math.max(0, currentTurn - state.updatedTurn); } function summaryText(state: TodoState): string { const { done, total } = todoSummary(state); const inProgress = countStatus(state, TODO_STATUS.IN_PROGRESS); const blocked = countStatus(state, TODO_STATUS.BLOCKED); const dropped = countStatus(state, TODO_STATUS.DROPPED); return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress${blocked ? ` · ${blocked} blocked` : ""}${dropped ? ` · ${dropped} dropped` : ""}`; } function taskLine(task: TodoTask): string { return `[${task.status}] #${task.id} ${task.title}${task.note ? ` — ${task.note}` : ""}`; } function buildTask(input: TodoTaskInput, id: number): TodoTask | string { const title = cleanText(input.title); if (title.length === 0) return "title is required"; const status = parseStatus(input.status); if (status === undefined) return `unknown status "${input.status}" (use ${STATUS_LIST})`; const note = cleanText(input.note); if (status === TODO_STATUS.BLOCKED && note.length === 0) return BLOCKED_NEEDS_NOTE; return { id, title, status, ...(note ? { note } : {}) }; } function write(state: TodoState, inputs: TodoTaskInput[], turn: number): TodoResult { const tasks: TodoTask[] = []; let nextId = state.nextId; for (const input of inputs) { const keepId = typeof input.id === "number" && Number.isInteger(input.id) && input.id > 0 && !tasks.some((task) => task.id === input.id); const id = keepId ? (input.id as number) : nextId++; nextId = Math.max(nextId, id + 1); const built = buildTask(input, id); if (typeof built === "string") return { state, text: `Error: ${built}`, error: built }; tasks.push(built); } const next = { tasks, nextId, updatedTurn: turn }; return { state: next, text: `Todo list written: ${summaryText(next)}` }; } function update(state: TodoState, params: TodoParams, turn: number): TodoResult { const task = state.tasks.find((candidate) => candidate.id === params.id); if (!task) { const error = `no task #${params.id}`; return { state, text: `Error: ${error}`, error }; } if (params.status === undefined && params.title === undefined && params.note === undefined) { const error = "nothing to update: pass status, title, or note"; return { state, text: `Error: ${error}`, error }; } const built = buildTask({ id: task.id, title: params.title ?? task.title, status: params.status ?? task.status, note: params.note ?? task.note }, task.id); if (typeof built === "string") return { state, text: `Error: ${built}`, error: built }; const next = { ...state, tasks: state.tasks.map((candidate) => (candidate.id === task.id ? built : candidate)), updatedTurn: turn }; const change = task.status === built.status ? "updated" : `→ ${built.status}`; return { state: next, text: `#${built.id} ${built.title} ${change}` }; } export function applyTodo(state: TodoState, params: TodoParams, turn: number): TodoResult { switch (params.action) { case TODO_ACTION.WRITE: return write(state, Array.isArray(params.tasks) ? params.tasks : [], turn); case TODO_ACTION.ADD: { const built = buildTask({ title: params.title, status: params.status, note: params.note }, state.nextId); if (typeof built === "string") return { state, text: `Error: ${built}`, error: built }; const next = { tasks: [...state.tasks, built], nextId: state.nextId + 1, updatedTurn: turn }; return { state: next, text: `Added #${built.id} ${built.title} (${built.status})` }; } case TODO_ACTION.UPDATE: return update(state, params, turn); case TODO_ACTION.CLEAR: return { state: { ...emptyTodo(), updatedTurn: turn }, text: `Cleared ${state.tasks.length} ${state.tasks.length === 1 ? "task" : "tasks"}` }; case TODO_ACTION.LIST: return { state, text: state.tasks.length === 0 ? "No tasks." : state.tasks.map(taskLine).join("\n") }; default: { const error = `unknown action "${params.action}" (use write, add, update, clear, or list)`; return { state, text: `Error: ${error}`, error }; } } } interface ReplayEntry { type?: string; message?: { role?: string; toolName?: string; isError?: boolean; details?: unknown }; } interface RpivTask { id?: number; subject?: string; status?: string; activeForm?: string; } function fromRpiv(details: { tasks?: RpivTask[]; nextId?: number }): TodoState { const tasks: TodoTask[] = []; for (const raw of details.tasks ?? []) { if (raw.status === "deleted" || typeof raw.id !== "number") continue; const built = buildTask({ id: raw.id, title: raw.subject, status: raw.status, note: raw.status === TODO_STATUS.IN_PROGRESS ? raw.activeForm : undefined }, raw.id); if (typeof built !== "string") tasks.push(built); } return { tasks, nextId: typeof details.nextId === "number" ? details.nextId : tasks.reduce((max, task) => Math.max(max, task.id), 0) + 1, updatedTurn: null }; } // The last successful `todo` result on the branch is the state: ours carries // the snapshot under `gentleTodo`; rpiv-todo carried `tasks` + `nextId` at the top. export function replayTodo(entries: readonly unknown[]): TodoState { let state = emptyTodo(); for (const entry of entries as ReplayEntry[]) { const message = entry?.message; if (entry?.type !== "message" || message?.role !== "toolResult" || message.toolName !== TODO_TOOL_NAME || message.isError === true) continue; const details = message.details as Record | undefined; if (!details || typeof details !== "object") continue; const ours = details[TODO_DETAILS_KEY] as TodoState | undefined; if (ours && Array.isArray(ours.tasks)) state = { tasks: ours.tasks, nextId: ours.nextId, updatedTurn: ours.updatedTurn ?? null }; else if (Array.isArray(details.tasks)) state = fromRpiv(details as { tasks?: RpivTask[]; nextId?: number }); } return state; } // gentle-shell#1367, #1662: the system prompt is a cached prefix for every // provider, so the block it carries is byte-stable for the whole session: // rules only, no task content and no counters. The current list travels as a // hidden transcript message appended when it changes (todoStateMessage, // gentle-shell#2195), so requests stay append-only and a change costs only // the tail of the conversation, never the prefix. const TODO_PROMPT_BLOCK = [ "## Todo list", "A small task (Task Size) uses no todo list: do it and report. For tracked or multi-step work, keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.", "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.", "While tasks stay open, the current list arrives as a hidden conversation message whenever it changes; the latest one is current, and it is not repeated here.", ].join("\n"); /** The byte-stable rules block appended to every system prompt. */ export function todoPromptBlock(): string { return TODO_PROMPT_BLOCK; } /** Persisted once an open list closes, so an earlier snapshot never reads as current work (#2195). */ export const TODO_CLOSED_STATE = "Current todo list: no open tasks."; /** The open-task snapshot delivered as a hidden transcript message; undefined when nothing is open. */ export function todoStateMessage(state: TodoState): string | undefined { if (todoSummary(state).open === 0) return undefined; const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`); return ["Current todo list:", ...lines].join("\n"); } function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string { if (task.status === TODO_STATUS.DONE) { // Wrap before applying SGR 9: every physical title closes its own style // before renderCard adds padding and rails. return wrapTextWithAnsi(task.title, Math.max(1, inner - 2)) .map((line, index) => `${index === 0 ? theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status]) : " "} ${theme.fg(STATUS_ROLE[task.status], theme.strikethrough(line))}`) .join("\n"); } const title = task.title; const showsNote = task.status === TODO_STATUS.IN_PROGRESS || task.status === TODO_STATUS.BLOCKED; const note = showsNote && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : ""; return `${theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status])} ${theme.fg(STATUS_ROLE[task.status], title)}${note}`; } // Long lists keep the card short: done and dropped tasks fold into "✓ N done" // and "✕ N dropped", and the open ones fill the remaining rows, with a // trailing count for the rest. function bodyRows(state: TodoState, theme: TodoTheme, inner: number): string[] { if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme, inner)); const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE && task.status !== TODO_STATUS.DROPPED); const rows: string[] = []; for (const [status, label] of [[TODO_STATUS.DONE, "done"], [TODO_STATUS.DROPPED, "dropped"]] as const) { const count = countStatus(state, status); if (count > 0) rows.push(`${theme.fg(GLYPH_ROLE[status], STATUS_GLYPH[status])} ${theme.fg(NOTE_ROLE, `${count} ${label}`)}`); } const room = ROW_CAP - rows.length - (open.length > ROW_CAP - rows.length ? 1 : 0); for (const task of open.slice(0, room)) rows.push(taskRow(task, theme, inner)); if (open.length > room) rows.push(theme.fg(NOTE_ROLE, `… ${open.length - room} more`)); return rows; } function collapsedRow(state: TodoState, theme: TodoTheme, inner: number): string { const active = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS); if (active) return taskRow(active, theme, inner); const pending = state.tasks.find((task) => task.status === TODO_STATUS.PENDING); if (pending) return taskRow(pending, theme, inner); const blocked = state.tasks.find((task) => task.status === TODO_STATUS.BLOCKED); if (blocked) return taskRow(blocked, theme, inner); const { open } = todoSummary(state); return `${theme.fg(GLYPH_ROLE[TODO_STATUS.PENDING], STATUS_GLYPH[TODO_STATUS.PENDING])} ${theme.fg(NOTE_ROLE, `${open} open`)}`; } /** The tone the Todos card renders in: warning once the list went stale, info otherwise. */ export function todoCardTone(staleTurns: number): CardTone { return staleTurns >= STALE_AFTER_TURNS ? CARD_TONE.WARNING : CARD_TONE.INFO; } export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number, options: TodoRenderOptions): string[] { if (state.tasks.length === 0) return []; const { done, total } = todoSummary(state); // Dropped work is not part of the plan anymore, so it leaves the denominator. const planned = total - countStatus(state, TODO_STATUS.DROPPED); const stale = options.staleTurns >= STALE_AFTER_TURNS; const action = options.collapsed ? "expand" : "collapse"; const actionLabel = action[0]!.toUpperCase() + action.slice(1); const icon = options.collapsed ? "▸" : "▾"; const control = `${icon} ${width >= 28 ? actionLabel : ""}`.trimEnd(); const tone = todoCardTone(options.staleTurns); const hint = options.collapseKey ? panelHeaderRow(theme, width, tone) ? options.collapseKey : `${options.collapseKey} ${action}` : undefined; // Content columns of the card body in the active style (float is narrower). const inner = panelInnerWidth(theme, width, tone); const rows = options.collapsed ? [collapsedRow(state, theme, inner)] : options.scrollable ? state.tasks.map((task) => taskRow(task, theme, inner)) : bodyRows(state, theme, inner); const staleLabel = theme.fg(NOTE_ROLE, `stale · ${options.staleTurns} turns`); let body = options.collapsed ? [truncateToWidth(`${stale ? `${staleLabel} · ` : ""}${rows[0]}`, inner, "…")] : stale ? [staleLabel, ...rows] : rows; if (!options.collapsed && !options.scrollable) { // Budget physical lines after wrapping: one long task can exceed the // old task-count cap by itself. Scrollable hosts retain every line. body = body.flatMap((row) => wrapTextWithAnsi(row, Math.max(1, inner))); if (body.length > ROW_CAP) body = [...body.slice(0, ROW_CAP - 1), theme.fg(NOTE_ROLE, "… more")]; } // The clickable control paints the shared hover role while hovered -- // same treatment every other clickable surface uses -- instead of its // ordinary accent role. return renderCard( { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${planned}`, body, tone, glyph: TODO_GLYPH }, theme, width, { expanded: true, hint, panel: true }, ); }