feat(coding-agent): auto-clear completed todo tasks (#435)
- Schedule auto-removal of completed/abandoned tasks after ~1 minute delay - Strip already-done tasks when restoring session from branch history - Add todo_auto_clear event to trigger UI refresh on removal - Add blank line before Todos header for visual spacing
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added automatic clearing of completed and abandoned todo tasks after ~1 minute
|
||||
|
||||
### Changed
|
||||
|
||||
- Strip already-completed tasks when restoring session from branch history
|
||||
|
||||
## [13.12.4] - 2026-03-15
|
||||
### Added
|
||||
|
||||
|
||||
@@ -1292,6 +1292,17 @@ export const SETTINGS_SCHEMA = {
|
||||
default: {} as Record<string, string>,
|
||||
},
|
||||
|
||||
"tasks.todoClearDelay": {
|
||||
type: "number",
|
||||
default: 60,
|
||||
ui: {
|
||||
tab: "tasks",
|
||||
label: "Todo auto-clear delay",
|
||||
description: "How long to wait before removing completed/abandoned tasks from the list",
|
||||
submenu: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Skills
|
||||
"skills.enabled": { type: "boolean", default: true },
|
||||
|
||||
|
||||
@@ -248,6 +248,17 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
||||
{ value: "1000", label: "1000 lines" },
|
||||
{ value: "5000", label: "5000 lines" },
|
||||
],
|
||||
// Todo auto-clear delay
|
||||
"tasks.todoClearDelay": [
|
||||
{ value: "0", label: "Instant" },
|
||||
{ value: "60", label: "1 minute", description: "Default" },
|
||||
{ value: "300", label: "5 minutes" },
|
||||
{ value: "900", label: "15 minutes" },
|
||||
{ value: "1800", label: "30 minutes" },
|
||||
{ value: "3600", label: "1 hour" },
|
||||
{ value: "-1", label: "Never" },
|
||||
],
|
||||
|
||||
// Edit fuzzy threshold
|
||||
"edit.fuzzyThreshold": [
|
||||
{ value: "0.85", label: "0.85", description: "Lenient" },
|
||||
|
||||
@@ -548,6 +548,10 @@ export class EventController {
|
||||
this.ctx.ui.requestRender();
|
||||
break;
|
||||
}
|
||||
|
||||
case "todo_auto_clear":
|
||||
await this.ctx.reloadTodos();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -542,7 +542,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
|
||||
const indent = " ";
|
||||
const hook = theme.tree.hook;
|
||||
const lines = [indent + theme.bold(theme.fg("accent", "Todos"))];
|
||||
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
||||
|
||||
if (!this.todoExpanded) {
|
||||
const activePhase = this.#getActivePhase(phases);
|
||||
|
||||
@@ -154,7 +154,8 @@ export type AgentSessionEvent =
|
||||
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||
| { type: "ttsr_triggered"; rules: Rule[] }
|
||||
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number };
|
||||
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
|
||||
| { type: "todo_auto_clear" };
|
||||
|
||||
/** Listener function for agent session events */
|
||||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||
@@ -361,6 +362,7 @@ export class AgentSession {
|
||||
// Todo completion reminder state
|
||||
#todoReminderCount = 0;
|
||||
#todoPhases: TodoPhase[] = [];
|
||||
#todoClearTimers = new Map<string, Timer>();
|
||||
#nextToolChoiceOverride: ToolChoice | undefined = undefined;
|
||||
|
||||
// Bash execution state
|
||||
@@ -1534,6 +1536,7 @@ export class AgentSession {
|
||||
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
|
||||
}
|
||||
this.#cancelPostPromptTasks();
|
||||
this.#clearTodoClearTimers();
|
||||
const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
|
||||
const deliveryState = this.#asyncJobManager?.getDeliveryState();
|
||||
if (drained === false && deliveryState) {
|
||||
@@ -2538,10 +2541,17 @@ export class AgentSession {
|
||||
|
||||
setTodoPhases(phases: TodoPhase[]): void {
|
||||
this.#todoPhases = this.#cloneTodoPhases(phases);
|
||||
this.#scheduleTodoAutoClear(phases);
|
||||
}
|
||||
|
||||
#syncTodoPhasesFromBranch(): void {
|
||||
this.setTodoPhases(getLatestTodoPhasesFromEntries(this.sessionManager.getBranch()));
|
||||
const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
|
||||
// Strip completed/abandoned tasks — they were done in a previous run,
|
||||
// so the auto-clear grace period has already elapsed.
|
||||
for (const phase of phases) {
|
||||
phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
|
||||
}
|
||||
this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
|
||||
}
|
||||
|
||||
#cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
|
||||
@@ -2557,6 +2567,68 @@ export class AgentSession {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Schedule auto-removal of completed/abandoned tasks after a delay. */
|
||||
#scheduleTodoAutoClear(phases: TodoPhase[]): void {
|
||||
const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
|
||||
if (delaySec < 0) return; // "Never" — no auto-clear
|
||||
const delayMs = delaySec * 1000;
|
||||
const doneTaskIds = new Set<string>();
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status === "completed" || task.status === "abandoned") {
|
||||
doneTaskIds.add(task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel timers for tasks that are no longer done (e.g. status was reverted)
|
||||
for (const [id, timer] of this.#todoClearTimers) {
|
||||
if (!doneTaskIds.has(id)) {
|
||||
clearTimeout(timer);
|
||||
this.#todoClearTimers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule new timers for newly-done tasks
|
||||
for (const id of doneTaskIds) {
|
||||
if (this.#todoClearTimers.has(id)) continue;
|
||||
if (delayMs === 0) {
|
||||
// Instant — run synchronously on next microtask to batch removals
|
||||
const timer = setTimeout(() => this.#runTodoAutoClear(id), 0);
|
||||
this.#todoClearTimers.set(id, timer);
|
||||
} else {
|
||||
const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs);
|
||||
this.#todoClearTimers.set(id, timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a single completed task and notify the UI. */
|
||||
#runTodoAutoClear(taskId: string): void {
|
||||
this.#todoClearTimers.delete(taskId);
|
||||
let removed = false;
|
||||
for (const phase of this.#todoPhases) {
|
||||
const idx = phase.tasks.findIndex(t => t.id === taskId);
|
||||
if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
|
||||
phase.tasks.splice(idx, 1);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!removed) return;
|
||||
|
||||
// Remove empty phases
|
||||
this.#todoPhases = this.#todoPhases.filter(p => p.tasks.length > 0);
|
||||
this.#emit({ type: "todo_auto_clear" });
|
||||
}
|
||||
|
||||
#clearTodoClearTimers(): void {
|
||||
for (const timer of this.#todoClearTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.#todoClearTimers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort current operation and wait for agent to become idle.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user