diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cb7c55d0..2c6ea9b51 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the todo panel showing no progress while the agent worked through a plan: every sub-todo read as unchecked no matter how far along the run was. Three causes, all in the collapsed (default) view — the walking viewport dropped *every* closed row, so a completion only ever removed a line and the card's strike-reveal animation ran against a row nobody rendered; the phase the agent was actually in was the one phase header rendered without a `done/total` count; and the 60s todo auto-clear deleted closed tasks from an unfinished plan, resetting the phase counter to `0/n` and renumbering the stages until the next `todo` call restored the real snapshot. The viewport now keeps the newest closed task as a checked lead row (additive to the open-task cap), every phase header carries its progress, counts include abandoned tasks, and auto-clear only fires once the whole list is settled. + ## [17.2.11] - 2026-08-07 ### Added diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 72fd2d5f5..e3b8e5c55 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -123,6 +123,7 @@ import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render- import { setAutoQaConsentHandler } from "../tools/report-tool-issue"; import { formatPhaseDisplayName, + isClosedTodo, selectCollapsedTodos, setActiveTodoDescriptionsProvider, todoMatchesAnyDescription, @@ -1983,35 +1984,40 @@ export class InteractiveMode implements InteractiveModeContext { this.#todoAutoClearTimer = undefined; } - #isClosedTodo(task: TodoItem): boolean { - return task.status === "completed" || task.status === "abandoned"; - } - - #hasClosedTodos(phases: TodoPhase[]): boolean { - return phases.some(phase => phase.tasks.some(task => this.#isClosedTodo(task))); - } - - #removeClosedTodos(phases: TodoPhase[]): TodoPhase[] { - const next: TodoPhase[] = []; + /** + * Whether every todo is closed, so the HUD has nothing left to track. + * + * The auto-clear only fires on a settled list. Scrubbing closed tasks while + * open work remains is destructive: the walking viewport already hides all but + * the newest closed row, and those tasks are what the phase progress counters + * and the stage roman numerals are computed from — dropping them mid-run reset + * an in-flight phase to `0/n` and renumbered the stages, so a plan the agent + * was four tasks into rendered as untouched until the next `todo` call + * restored the real snapshot. + */ + #isTodoListSettled(phases: TodoPhase[]): boolean { + let seenTask = false; for (const phase of phases) { - const tasks = phase.tasks.filter(task => !this.#isClosedTodo(task)); - if (tasks.length > 0) next.push({ name: phase.name, tasks }); + for (const task of phase.tasks) { + if (!isClosedTodo(task)) return false; + seenTask = true; + } } - return next; + return seenTask; } #syncTodoAutoClearTimer(): void { this.#cancelTodoAutoClearTimer(); const delaySeconds = this.settings.get("tasks.todoClearDelay"); - if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || !this.#hasClosedTodos(this.todoPhases)) return; + if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || !this.#isTodoListSettled(this.todoPhases)) return; if (delaySeconds === 0) { - this.todoPhases = this.#removeClosedTodos(this.todoPhases); + this.todoPhases = []; return; } this.#todoAutoClearTimer = setTimeout(() => { this.#todoAutoClearTimer = undefined; - this.todoPhases = this.#removeClosedTodos(this.todoPhases); + this.todoPhases = []; this.#renderTodoList(); this.ui.requestRender(); }, delaySeconds * 1000); @@ -2142,7 +2148,9 @@ export class InteractiveMode implements InteractiveModeContext { // brighter muted gray. The root header carries overall stage progression. const renderPhase = (phase: TodoPhase, oneBased: number, isActive: boolean): string | string[] => { const label = multiPhase ? formatPhaseDisplayName(phase.name, oneBased) : phase.name; - const done = phase.tasks.filter(t => t.status === "completed").length; + // Closed, not just completed: the collapsed task window hides abandoned + // tasks too, so counting only completions leaves the phase reading stuck. + const done = phase.tasks.filter(isClosedTodo).length; const progress = ` · ${done}/${phase.tasks.length}`; if (!isActive) { const header = theme.fg("muted", label) + theme.fg("dim", progress); diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index ab2e2192d..7afd15642 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -236,6 +236,13 @@ export function todoMatchesAnyDescription(content: string, descriptions: readonl return false; } +/** Whether a todo is settled: completed or deliberately abandoned. Shared so + * the collapsed viewport, the HUD progress counters, and the HUD's closed-todo + * auto-clear can never disagree about what "done" hides. */ +export function isClosedTodo(task: T): boolean { + return task.status === "completed" || task.status === "abandoned"; +} + /** * A todo the collapsed viewport treats as current work: the literal * `in_progress` task or a pending task a live subagent is executing. Both @@ -254,36 +261,33 @@ export interface CollapsedTodoSelection { } /** - * Walking-viewport selection for a phase's collapsed todo preview (#5873). + * Closed rows kept directly above the open window so finishing a task is + * visible as it happens. Without this the collapsed viewport only ever renders + * unchecked boxes while a phase has open work: every completion silently + * removes a row, so a plan mid-flight looks untouched, and the card's + * completion strike animation (`completedTasks` → {@link TODO_STRIKE_TOTAL_FRAMES}) + * animated a row that was never rendered. + */ +const COLLAPSED_CLOSED_CONTEXT = 1; + +/** + * Rows to show for a display base already reduced to the relevant tasks. * - * Policy, applied to `tasks` in todo order: - * 1. While the phase has open work, completed/abandoned tasks are omitted. A - * phase with no open tasks left falls back to its closed tasks so the sticky - * HUD's closed-todo persistence still has something to render. - * 2. Every active task (in-progress, or pending matched to a live subagent) is + * 1. Every active task (in-progress, or pending matched to a live subagent) is * placed at the head in stable todo order — never dropped for lying outside * an ordinary window. - * 3. Remaining rows up to `cap` are filled with the pending tasks that follow + * 2. Remaining rows up to `cap` are filled with the pending tasks that follow * the first active one, in todo order (falling back to leading pending tasks * when no active task exists), so a freshly-promoted task leads the preview. - * 4. When active tasks alone exceed `cap`, only the first `cap` active tasks are + * 3. When active tasks alone exceed `cap`, only the first `cap` active tasks are * shown and the summary counts the hidden *active* todos, never replacing * them with unrelated pending rows. - * - * The summary otherwise counts the remaining tasks in the display base. Returns - * the whole base with an empty summary when it already fits. */ -export function selectCollapsedTodos( - tasks: T[], +function selectWithinCap( + base: T[], isMatched: (task: T) => boolean, cap: number, ): CollapsedTodoSelection { - const open = tasks.filter( - task => task.status === "pending" || task.status === "in_progress" || task.status === "blocked", - ); - // No open work: fall back to the closed tasks so a settled phase still - // renders (HUD closed-todo persistence). Closed tasks are never active. - const base = open.length > 0 ? open : tasks; if (base.length <= cap) return { items: base, summary: "" }; const active = base.filter(task => isActiveTodo(task, isMatched)); @@ -312,6 +316,34 @@ export function selectCollapsedTodos( return { items, summary: hidden > 0 ? formatMoreItems(hidden, "todo") : "" }; } +/** + * Walking-viewport selection for a phase's collapsed todo preview (#5873). + * + * Applied to `tasks` in todo order: the open tasks run through + * {@link selectWithinCap}, led by the last {@link COLLAPSED_CLOSED_CONTEXT} + * closed tasks that sit above them so a checked row walks down the list as work + * lands. The lead is additive — it never costs an open row — and a phase with no + * open work left falls back to its closed tasks so the sticky HUD's closed-todo + * persistence still has something to render. + * + * `summary` counts the open tasks that did not fit; the closed lead is context, + * not part of the budget. + */ +export function selectCollapsedTodos( + tasks: T[], + isMatched: (task: T) => boolean, + cap: number, +): CollapsedTodoSelection { + const open = tasks.filter(task => !isClosedTodo(task)); + // Closed tasks are never active, so a settled phase selects over itself. + if (open.length === 0) return selectWithinCap(tasks, isMatched, cap); + // Everything before the first open task is closed by construction. + const firstOpenIdx = tasks.indexOf(open[0]); + const lead = tasks.slice(Math.max(firstOpenIdx - COLLAPSED_CLOSED_CONTEXT, 0), firstOpenIdx); + const selected = selectWithinCap(open, isMatched, cap); + return { items: [...lead, ...selected.items], summary: selected.summary }; +} + function resolveTaskOrError( phases: TodoPhase[], content: string | undefined, @@ -1055,12 +1087,20 @@ function computeTouchedPhases( return touched.size > 0 ? touched : null; } +/** + * Dim `closed/total` suffix for a phase header. Counts closed tasks, not just + * completed ones: the collapsed viewport hides both, so an abandoned task has to + * move the counter or its phase reads as permanently stuck. + */ +function formatPhaseProgress(phase: TodoPhase, uiTheme: Theme): string { + const done = phase.tasks.filter(isClosedTodo).length; + return uiTheme.fg("dim", ` ${done}/${phase.tasks.length}`); +} + /** One-line summary for a collapsed (untouched) phase: dim header + progress. */ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Theme): string { - const total = phase.tasks.length; - const done = phase.tasks.filter(task => task.status === "completed").length; const name = uiTheme.fg("dim", chalk.bold(formatPhaseDisplayName(phase.name, oneBasedIndex))); - return `${name}${uiTheme.fg("dim", ` ${done}/${total}`)}`; + return `${name}${formatPhaseProgress(phase, uiTheme)}`; } /** @@ -1178,12 +1218,17 @@ export const todoToolRenderer = { continue; } if (multiPhase) { - bodyLines.push(uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1)))); + // Progress belongs on the expanded header too: the collapsed + // viewport below hides closed rows, so without it the phase the + // agent is actually working in is the one phase with no visible + // completion signal at all. + const name = uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1))); + bodyLines.push(`${name}${formatPhaseProgress(phase, uiTheme)}`); } const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS; - // Collapsed: walking viewport — completed/abandoned omitted, active - // work (in-progress / subagent-matched) pulled to the head, then - // following pending tasks (#5873). Expanded: every task in order. + // Collapsed: walking viewport — the last closed task leads, then + // active work (in-progress / subagent-matched), then following + // pending tasks (#5873). Expanded: every task in order. const treeLines = expanded ? renderTreeList( { diff --git a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts index 407b7d2fd..6c2a5a1ac 100644 --- a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts +++ b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts @@ -92,6 +92,48 @@ describe("InteractiveMode todo HUD persistence", () => { expect(session.getTodoPhases()).toEqual(phases); }); + /** + * Auto-clear used to fire on any list holding a closed task, so a plan the + * agent was mid-way through had its finished tasks deleted from the HUD's + * copy: the phase counter reset, the checked row vanished, and the stage + * renumbered — the panel reported no progress at all until the next `todo` + * call restored the real snapshot. It may only fire on a settled list. + */ + const unfinishedPlan = (): TodoPhase[] => [ + { + name: "Implementation", + tasks: [ + { content: "done task", status: "completed" }, + { content: "abandoned task", status: "abandoned" }, + { content: "current task", status: "in_progress" }, + ], + }, + ]; + + it("keeps an unfinished plan's progress when the auto-clear delay elapses", async () => { + await createMode(1); + vi.useFakeTimers(); + + mode.setTodos(unfinishedPlan()); + vi.advanceTimersByTime(60_000); + + const rendered = renderTodos(mode); + // Progress counts every closed task, abandoned included: the walking + // viewport hides both, so the counter is the only signal they existed. + expect(rendered).toContain("2/3"); + expect(rendered).toContain("current task"); + }); + + it("keeps an unfinished plan's progress when auto-clear is instant", async () => { + await createMode(0); + + mode.setTodos(unfinishedPlan()); + + const rendered = renderTodos(mode); + expect(rendered).toContain("2/3"); + expect(rendered).toContain("current task"); + }); + it("leaves closed todos visible when auto-clear is disabled", async () => { await createMode(-1); @@ -249,13 +291,15 @@ describe("InteractiveMode todo HUD anchor", () => { const root = lines.find(line => line.includes("Todos")); expect(root).toContain("1/2"); // Active stage: highlighted header with its own task progress, expanded as a - // connector tree; the completed task slid out of the open-task window. + // connector tree; the just-completed task stays as the lead row so progress + // is visible while the stage still has open work. expect(lines.some(line => line.includes("I. Foundation") && line.includes("1/3"))).toBe(true); const secondLine = lines.find(line => line.includes("second task")); expect(secondLine).toContain(theme.tree.branch); expect(secondLine).toContain(theme.checkbox.unchecked); expect(lines.some(line => line.includes("third task"))).toBe(true); - expect(lines.some(line => line.includes("first task"))).toBe(false); + const firstLine = lines.find(line => line.includes("first task")); + expect(firstLine).toContain(theme.checkbox.checked); // Upcoming stage: header with its own progress, but collapsed (no task rows). expect(lines.some(line => line.includes("II. Verification") && line.includes("0/1"))).toBe(true); expect(lines.some(line => line.includes("run tests"))).toBe(false); diff --git a/packages/coding-agent/test/tools/todo.test.ts b/packages/coding-agent/test/tools/todo.test.ts index 964f69972..a2a9852e7 100644 --- a/packages/coding-agent/test/tools/todo.test.ts +++ b/packages/coding-agent/test/tools/todo.test.ts @@ -11,6 +11,7 @@ import { resolveTodoMarkdownPath, selectCollapsedTodos, TODO_STRIKE_HOLD_FRAMES, + TODO_STRIKE_TOTAL_FRAMES, type TodoItem, type TodoPhase, TodoTool, @@ -626,10 +627,13 @@ describe("todoToolRenderer.renderResult phase collapsing", () => { task: "a1", }); const rendered = Bun.stripANSI(component.render(100).join("\n")); - // Active phase's collapsed viewport omits the completed task and shows the - // promoted current one (#5873). - expect(rendered).not.toContain("a1"); + // Active phase's collapsed viewport keeps the just-closed task as the lead + // row and shows the promoted current one (#5873), and its header carries + // progress so the phase being worked on is not the one phase with no + // completion signal. + expect(rendered).toContain("a1"); expect(rendered).toContain("a2"); + expect(rendered).toContain("I. Alpha 1/2"); // Untouched phases collapse: headers + progress counts, no task contents. expect(rendered).toContain("II. Beta"); expect(rendered).toContain("III. Gamma"); @@ -639,6 +643,24 @@ describe("todoToolRenderer.renderResult phase collapsing", () => { expect(rendered).not.toContain("c1"); expect(rendered).not.toContain("c2"); }); + it("sweeps the just-completed row's strike in the collapsed view", async () => { + const result = await buildThreePhaseAfterDone(); + // The card's default view is collapsed, so the completion animation the + // `completedTasks` plumbing drives has to land there — while the viewport + // dropped every closed row, the animation ran against a row nobody rendered. + const strikeSpan = (spinnerFrame: number): string => { + const rendered = todoToolRenderer + .renderResult(result, { expanded: false, isPartial: false, spinnerFrame }, theme, { + op: "done", + task: "a1", + }) + .render(100) + .join("\n"); + return /\x1b\[9m(.*?)\x1b\[29m/.exec(rendered)?.[1] ?? ""; + }; + expect(strikeSpan(0)).toBe(""); + expect(strikeSpan(TODO_STRIKE_TOTAL_FRAMES)).toBe("a1"); + }); it("falls back to in_progress / completed signals when call args are unavailable", async () => { const result = await buildThreePhaseAfterDone(); // Transcript rebuilds may not carry call args; the active (Alpha) phase is @@ -696,7 +718,7 @@ describe("selectCollapsedTodos walking viewport (#5873)", () => { expect(sel.summary).toContain("6 more todos"); }); - it("omits completed and abandoned tasks in collapsed mode", () => { + it("leads with the last closed task and omits the rest in collapsed mode", () => { const tasks: TodoItem[] = [ { content: "done", status: "completed" }, { content: "dropped", status: "abandoned" }, @@ -704,7 +726,17 @@ describe("selectCollapsedTodos walking viewport (#5873)", () => { { content: "next", status: "pending" }, ]; const sel = selectCollapsedTodos(tasks, never, 5); - expect(contents(sel)).toEqual(["current", "next"]); + // One closed row survives so a completion is visible as it lands; earlier + // closed work stays hidden. + expect(contents(sel)).toEqual(["dropped", "current", "next"]); + expect(sel.summary).toBe(""); + }); + + it("keeps the closed lead row additive to the open-task cap", () => { + const tasks: TodoItem[] = [{ content: "closed", status: "completed" }, ...mk(5, [1])]; + const sel = selectCollapsedTodos(tasks, never, 5); + // All 5 open tasks fit the cap; the closed context row does not evict one. + expect(contents(sel)).toEqual(["closed", "Task 1", "Task 2", "Task 3", "Task 4", "Task 5"]); expect(sel.summary).toBe(""); });