fix(coding-agent): surface todo progress in the collapsed panel
While the agent worked through a plan, every sub-todo rendered unchecked no matter how far along the run was: the phase header highlighted, the task rows below it looked untouched. Three separate causes, all on the collapsed path that is the default view. `selectCollapsedTodos` dropped every closed row while a phase held open work, so finishing a task only ever *removed* a line — the panel never rendered a checked box until the whole phase settled. That also made the card's completion animation dead code: `details.completedTasks` drives a 14-frame strike reveal at 65ms with a component render per tick, against a row the viewport had already discarded. The existing animation test missed it by asserting on `expanded: true`. The viewport now keeps the newest closed task as a checked lead row, additive to the open-task cap so it never evicts open work, and the strike sweep lands where users actually see it. Second, the card gave a `done/total` count to every collapsed untouched phase but not to the active one, so the phase being worked in was the single phase reporting no progress. Extracted `formatPhaseProgress` and put it on every phase header. Third, the todo auto-clear (`tasks.todoClearDelay`, default 60s) armed on any list holding a closed task and physically deleted those tasks from the HUD's copy. An in-flight phase at `3/4` silently became `0/1` sixty seconds later, fully-closed phases vanished, and stage roman numerals renumbered off the filtered index — until the next `todo` call restored the real snapshot. It now fires only once the whole list is settled, which is the case the setting exists for; the walking viewport already hides closed rows while work remains. Progress counters also count closed tasks rather than only completed ones. The viewport hides abandoned tasks too, so counting only completions left a phase reading permanently stuck.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T extends { status: TodoStatus }>(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<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T extends { status: TodoStatus }>(
|
||||
tasks: T[],
|
||||
function selectWithinCap<T extends { status: TodoStatus }>(
|
||||
base: T[],
|
||||
isMatched: (task: T) => boolean,
|
||||
cap: number,
|
||||
): CollapsedTodoSelection<T> {
|
||||
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<T extends { status: TodoStatus }>(
|
||||
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<T extends { status: TodoStatus }>(
|
||||
tasks: T[],
|
||||
isMatched: (task: T) => boolean,
|
||||
cap: number,
|
||||
): CollapsedTodoSelection<T> {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user