@oh-my-pi/pi-coding-agent 14.5.10 → 14.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +7 -7
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +14 -2
- package/src/internal-urls/docs-index.generated.ts +54 -54
- package/src/modes/controllers/todo-command-controller.ts +22 -74
- package/src/modes/interactive-mode.ts +9 -6
- package/src/modes/types.ts +0 -2
- package/src/prompts/system/eager-todo.md +1 -1
- package/src/prompts/tools/todo-write.md +19 -19
- package/src/session/agent-session.ts +21 -17
- package/src/tools/todo-write.ts +157 -195
- package/examples/custom-tools/todo/index.ts +0 -211
- package/examples/extensions/todo.ts +0 -295
|
@@ -21,7 +21,7 @@ const USAGE = [
|
|
|
21
21
|
" /todo export <path> Write todos as Markdown to <path>",
|
|
22
22
|
" /todo import <path> Replace todos from Markdown at <path>",
|
|
23
23
|
" /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
|
|
24
|
-
" /todo start <task> Mark task in_progress (
|
|
24
|
+
" /todo start <task> Mark task in_progress (fuzzy content match)",
|
|
25
25
|
" /todo done [<task|phase>] Mark task/phase/all completed",
|
|
26
26
|
" /todo drop [<task|phase>] Mark task/phase/all abandoned",
|
|
27
27
|
" /todo rm [<task|phase>] Remove task/phase/all",
|
|
@@ -59,44 +59,9 @@ function tokenize(input: string): string[] {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// =============================================================================
|
|
62
|
-
//
|
|
62
|
+
// Name normalization
|
|
63
63
|
// =============================================================================
|
|
64
64
|
|
|
65
|
-
const ROMAN_PAIRS: Array<[number, string]> = [
|
|
66
|
-
[1000, "M"],
|
|
67
|
-
[900, "CM"],
|
|
68
|
-
[500, "D"],
|
|
69
|
-
[400, "CD"],
|
|
70
|
-
[100, "C"],
|
|
71
|
-
[90, "XC"],
|
|
72
|
-
[50, "L"],
|
|
73
|
-
[40, "XL"],
|
|
74
|
-
[10, "X"],
|
|
75
|
-
[9, "IX"],
|
|
76
|
-
[5, "V"],
|
|
77
|
-
[4, "IV"],
|
|
78
|
-
[1, "I"],
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
function toRoman(n: number): string {
|
|
82
|
-
if (n <= 0) return "I";
|
|
83
|
-
let out = "";
|
|
84
|
-
let rem = n;
|
|
85
|
-
for (const [value, sym] of ROMAN_PAIRS) {
|
|
86
|
-
while (rem >= value) {
|
|
87
|
-
out += sym;
|
|
88
|
-
rem -= value;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return out;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i;
|
|
95
|
-
|
|
96
|
-
function stripPrefix(name: string): string {
|
|
97
|
-
return name.replace(PHASE_PREFIX_RE, "").trim();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
65
|
function titleCase(s: string): string {
|
|
101
66
|
return s
|
|
102
67
|
.split(/\s+/)
|
|
@@ -105,13 +70,6 @@ function titleCase(s: string): string {
|
|
|
105
70
|
.join(" ");
|
|
106
71
|
}
|
|
107
72
|
|
|
108
|
-
function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
|
|
109
|
-
const stripped = stripPrefix(rawName.trim());
|
|
110
|
-
if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`;
|
|
111
|
-
const titled = titleCase(stripped);
|
|
112
|
-
return `${toRoman(existingPhases.length + 1)}. ${titled}`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
73
|
// =============================================================================
|
|
116
74
|
// Fuzzy matching
|
|
117
75
|
// =============================================================================
|
|
@@ -119,20 +77,13 @@ function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
|
|
|
119
77
|
function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
|
|
120
78
|
const q = query.trim().toLowerCase();
|
|
121
79
|
if (!q) return undefined;
|
|
122
|
-
// Exact id
|
|
123
|
-
const byId = phases.find(p => p.id.toLowerCase() === q);
|
|
124
|
-
if (byId) return byId;
|
|
125
80
|
// Exact name (case-insensitive)
|
|
126
81
|
const byName = phases.find(p => p.name.toLowerCase() === q);
|
|
127
82
|
if (byName) return byName;
|
|
128
|
-
//
|
|
129
|
-
const
|
|
130
|
-
const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ);
|
|
131
|
-
if (byStripped) return byStripped;
|
|
132
|
-
// Substring (prefer prefix match on stripped name)
|
|
133
|
-
const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ));
|
|
83
|
+
// Substring (prefer prefix match)
|
|
84
|
+
const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q));
|
|
134
85
|
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
135
|
-
const subMatches = phases.filter(p =>
|
|
86
|
+
const subMatches = phases.filter(p => p.name.toLowerCase().includes(q));
|
|
136
87
|
if (subMatches.length === 1) return subMatches[0];
|
|
137
88
|
return undefined;
|
|
138
89
|
}
|
|
@@ -140,9 +91,10 @@ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefin
|
|
|
140
91
|
function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined {
|
|
141
92
|
const q = query.trim().toLowerCase();
|
|
142
93
|
if (!q) return undefined;
|
|
94
|
+
// Exact content (case-insensitive)
|
|
143
95
|
for (const phase of phases) {
|
|
144
96
|
for (const task of phase.tasks) {
|
|
145
|
-
if (task.
|
|
97
|
+
if (task.content.toLowerCase() === q) return { task, phase };
|
|
146
98
|
}
|
|
147
99
|
}
|
|
148
100
|
const matches: Array<{ task: TodoItem; phase: TodoPhase }> = [];
|
|
@@ -169,7 +121,7 @@ function buildSystemReminder(action: string, phases: TodoPhase[]): string {
|
|
|
169
121
|
return [
|
|
170
122
|
"<system-reminder>",
|
|
171
123
|
`The user manually modified the todo list (${action}).`,
|
|
172
|
-
"Current todo list
|
|
124
|
+
"Current todo list:",
|
|
173
125
|
"",
|
|
174
126
|
md,
|
|
175
127
|
"</system-reminder>",
|
|
@@ -327,28 +279,24 @@ export class TodoCommandController {
|
|
|
327
279
|
if (phaseName) {
|
|
328
280
|
targetPhase = findPhaseFuzzy(next, phaseName);
|
|
329
281
|
if (!targetPhase) {
|
|
330
|
-
|
|
331
|
-
targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] };
|
|
282
|
+
targetPhase = { name: titleCase(phaseName), tasks: [] };
|
|
332
283
|
next.push(targetPhase);
|
|
333
284
|
}
|
|
334
285
|
} else if (next.length > 0) {
|
|
335
286
|
targetPhase = next[next.length - 1];
|
|
336
287
|
} else {
|
|
337
|
-
targetPhase = {
|
|
288
|
+
targetPhase = { name: "Todos", tasks: [] };
|
|
338
289
|
next.push(targetPhase);
|
|
339
290
|
}
|
|
340
291
|
|
|
341
|
-
const
|
|
342
|
-
let n = 1;
|
|
343
|
-
while (usedTaskIds.has(`task-${n}`)) n++;
|
|
292
|
+
const finalContent = titleCaseSentence(content);
|
|
344
293
|
targetPhase.tasks.push({
|
|
345
|
-
|
|
346
|
-
content: titleCaseSentence(content),
|
|
294
|
+
content: finalContent,
|
|
347
295
|
status: "pending",
|
|
348
296
|
});
|
|
349
297
|
|
|
350
298
|
this.#commit(next, `/todo append → ${targetPhase.name}`);
|
|
351
|
-
this.ctx.showStatus(`Appended to ${targetPhase.name}: ${
|
|
299
|
+
this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`);
|
|
352
300
|
}
|
|
353
301
|
|
|
354
302
|
// ------------------------------------------------------------- start / done / drop / rm
|
|
@@ -364,12 +312,12 @@ export class TodoCommandController {
|
|
|
364
312
|
this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`);
|
|
365
313
|
return;
|
|
366
314
|
}
|
|
367
|
-
const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.
|
|
315
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
|
|
368
316
|
if (errors.length > 0) {
|
|
369
317
|
this.ctx.showError(errors.join("; "));
|
|
370
318
|
return;
|
|
371
319
|
}
|
|
372
|
-
this.#commit(phases, `/todo start ${hit.task.
|
|
320
|
+
this.#commit(phases, `/todo start ${hit.task.content}`);
|
|
373
321
|
this.ctx.showStatus(`Started: ${hit.task.content}`);
|
|
374
322
|
}
|
|
375
323
|
|
|
@@ -391,19 +339,19 @@ export class TodoCommandController {
|
|
|
391
339
|
|
|
392
340
|
const taskHit = findTaskFuzzy(current, trimmed);
|
|
393
341
|
if (taskHit) {
|
|
394
|
-
const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.
|
|
342
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]);
|
|
395
343
|
if (errors.length > 0) {
|
|
396
344
|
this.ctx.showError(errors.join("; "));
|
|
397
345
|
return;
|
|
398
346
|
}
|
|
399
|
-
this.#commit(phases, `/todo ${op} ${taskHit.task.
|
|
347
|
+
this.#commit(phases, `/todo ${op} ${taskHit.task.content}`);
|
|
400
348
|
this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`);
|
|
401
349
|
return;
|
|
402
350
|
}
|
|
403
351
|
|
|
404
352
|
const phaseHit = findPhaseFuzzy(current, trimmed);
|
|
405
353
|
if (phaseHit) {
|
|
406
|
-
const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.
|
|
354
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]);
|
|
407
355
|
if (errors.length > 0) {
|
|
408
356
|
this.ctx.showError(errors.join("; "));
|
|
409
357
|
return;
|
|
@@ -426,18 +374,18 @@ export class TodoCommandController {
|
|
|
426
374
|
}
|
|
427
375
|
const taskHit = findTaskFuzzy(current, trimmed);
|
|
428
376
|
if (taskHit) {
|
|
429
|
-
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.
|
|
377
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]);
|
|
430
378
|
if (errors.length > 0) {
|
|
431
379
|
this.ctx.showError(errors.join("; "));
|
|
432
380
|
return;
|
|
433
381
|
}
|
|
434
|
-
this.#commit(phases, `/todo rm ${taskHit.task.
|
|
382
|
+
this.#commit(phases, `/todo rm ${taskHit.task.content}`);
|
|
435
383
|
this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
|
|
436
384
|
return;
|
|
437
385
|
}
|
|
438
386
|
const phaseHit = findPhaseFuzzy(current, trimmed);
|
|
439
387
|
if (phaseHit) {
|
|
440
|
-
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.
|
|
388
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]);
|
|
441
389
|
if (errors.length > 0) {
|
|
442
390
|
this.ctx.showError(errors.join("; "));
|
|
443
391
|
return;
|
|
@@ -460,7 +408,7 @@ export class TodoCommandController {
|
|
|
460
408
|
|
|
461
409
|
const current = this.#currentPhases();
|
|
462
410
|
const initialMarkdown =
|
|
463
|
-
current.length > 0 ? phasesToMarkdown(current) : "#
|
|
411
|
+
current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
|
|
464
412
|
|
|
465
413
|
const fileHandle = await this.#openTtyHandle();
|
|
466
414
|
this.ctx.ui.stop();
|
|
@@ -48,6 +48,7 @@ import { getRecentSessions } from "../session/session-manager";
|
|
|
48
48
|
import { STTController, type SttState } from "../stt";
|
|
49
49
|
import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
|
|
50
50
|
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
51
|
+
import { formatPhaseDisplayName } from "../tools/todo-write";
|
|
51
52
|
import type { EventBus } from "../utils/event-bus";
|
|
52
53
|
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
53
54
|
import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color";
|
|
@@ -707,9 +708,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
707
708
|
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
|
708
709
|
|
|
709
710
|
if (!this.todoExpanded) {
|
|
710
|
-
const
|
|
711
|
+
const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
|
|
712
|
+
const activePhase = phases[activeIdx];
|
|
711
713
|
if (!activePhase) return;
|
|
712
|
-
lines.push(
|
|
714
|
+
lines.push(
|
|
715
|
+
`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
|
|
716
|
+
);
|
|
713
717
|
const visibleTasks = activePhase.tasks.slice(0, 5);
|
|
714
718
|
visibleTasks.forEach((todo, index) => {
|
|
715
719
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
@@ -723,13 +727,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
723
727
|
return;
|
|
724
728
|
}
|
|
725
729
|
|
|
726
|
-
|
|
727
|
-
lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`);
|
|
730
|
+
phases.forEach((phase, phaseIndex) => {
|
|
731
|
+
lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
|
|
728
732
|
phase.tasks.forEach((todo, index) => {
|
|
729
733
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
730
734
|
lines.push(this.#formatTodoLine(todo, prefix));
|
|
731
735
|
});
|
|
732
|
-
}
|
|
736
|
+
});
|
|
733
737
|
|
|
734
738
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
735
739
|
}
|
|
@@ -1712,7 +1716,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1712
1716
|
} else {
|
|
1713
1717
|
this.todoPhases = [
|
|
1714
1718
|
{
|
|
1715
|
-
id: "default",
|
|
1716
1719
|
name: "Todos",
|
|
1717
1720
|
tasks: todos as TodoItem[],
|
|
1718
1721
|
},
|
package/src/modes/types.ts
CHANGED
|
@@ -42,7 +42,6 @@ export type SubmittedUserInput = {
|
|
|
42
42
|
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
|
|
43
43
|
|
|
44
44
|
export type TodoItem = {
|
|
45
|
-
id: string;
|
|
46
45
|
content: string;
|
|
47
46
|
status: TodoStatus;
|
|
48
47
|
details?: string;
|
|
@@ -50,7 +49,6 @@ export type TodoItem = {
|
|
|
50
49
|
};
|
|
51
50
|
|
|
52
51
|
export type TodoPhase = {
|
|
53
|
-
id: string;
|
|
54
52
|
name: string;
|
|
55
53
|
tasks: TodoItem[];
|
|
56
54
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
|
|
3
3
|
|
|
4
4
|
You **MUST** call `todo_write` first in this turn.
|
|
5
|
-
You **MUST** initialize the todo list with a single `
|
|
5
|
+
You **MUST** initialize the todo list with a single `init` op.
|
|
6
6
|
You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
|
|
7
7
|
You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
|
|
8
8
|
You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`.
|
|
@@ -5,23 +5,23 @@ The next pending task is auto-promoted to `in_progress` after each completion.
|
|
|
5
5
|
|
|
6
6
|
|`op`|Required fields|Effect|
|
|
7
7
|
|---|---|---|
|
|
8
|
-
|`
|
|
9
|
-
|`start`|`task`|
|
|
10
|
-
|`done`|`task` or `phase
|
|
11
|
-
|`drop`|`task` or `phase
|
|
12
|
-
|`rm`|`task` or `phase
|
|
13
|
-
|`append`|`phase`, `items:
|
|
14
|
-
|`note`|`task`, `text`|Append a note to
|
|
8
|
+
|`init`|`list`|Initialize the full list|
|
|
9
|
+
|`start`|`task`|Mark in progress|
|
|
10
|
+
|`done`|`task` or `phase`|Mark completed|
|
|
11
|
+
|`drop`|`task` or `phase`|Mark abandoned|
|
|
12
|
+
|`rm`|`task` or `phase`|Remove|
|
|
13
|
+
|`append`|`phase`, `items: string[]`|Append tasks; lazily creates phase|
|
|
14
|
+
|`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.|
|
|
15
15
|
|
|
16
16
|
## Anatomy
|
|
17
|
-
- **Task
|
|
18
|
-
- **Phase
|
|
17
|
+
- **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique.
|
|
18
|
+
- **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Used as the phase identifier — unique. Do not add prefixes like `1.`, `A)`, `Phase 1:`, etc.
|
|
19
19
|
|
|
20
20
|
## Rules
|
|
21
|
-
- Mark tasks done immediately after finishing
|
|
21
|
+
- Mark tasks done immediately after finishing.
|
|
22
22
|
- Complete phases in order.
|
|
23
|
-
- On blockers, `append` a new task to the active phase
|
|
24
|
-
-
|
|
23
|
+
- On blockers, `append` a new task to the active phase to unblock yourself, or `drop`.
|
|
24
|
+
- `task` and `phase` fields reference content/name verbatim; keep them stable once introduced.
|
|
25
25
|
|
|
26
26
|
## When to create a list
|
|
27
27
|
- Task requires 3+ distinct steps
|
|
@@ -31,17 +31,17 @@ The next pending task is auto-promoted to `in_progress` after each completion.
|
|
|
31
31
|
|
|
32
32
|
<examples>
|
|
33
33
|
# Initial setup (multi-phase)
|
|
34
|
-
`{"ops":[{"op":"
|
|
35
|
-
# Initial setup (single phase
|
|
36
|
-
`{"ops":[{"op":"
|
|
34
|
+
`{"ops":[{"op":"init","list":[{"phase":"Foundation","items":["Scaffold crate","Wire workspace"]},{"phase":"Auth","items":["Port credential store","Wire OAuth providers"]},{"phase":"Verification","items":["Run cargo test"]}]}]}`
|
|
35
|
+
# Initial setup (single phase)
|
|
36
|
+
`{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}`
|
|
37
37
|
# Complete one task
|
|
38
|
-
`{"ops":[{"op":"done","task":"
|
|
38
|
+
`{"ops":[{"op":"done","task":"Wire workspace"}]}`
|
|
39
39
|
# Complete a whole phase
|
|
40
|
-
`{"ops":[{"op":"done","phase":"
|
|
40
|
+
`{"ops":[{"op":"done","phase":"Auth"}]}`
|
|
41
41
|
# Remove all tasks
|
|
42
42
|
`{"ops":[{"op":"rm"}]}`
|
|
43
43
|
# Drop one task
|
|
44
|
-
`{"ops":[{"op":"drop","task":"
|
|
44
|
+
`{"ops":[{"op":"drop","task":"Run cargo test"}]}`
|
|
45
45
|
# Append tasks to a phase
|
|
46
|
-
`{"ops":[{"op":"append","phase":"
|
|
46
|
+
`{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
|
|
47
47
|
</examples>
|
|
@@ -387,6 +387,11 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
|
|
|
387
387
|
return `${selector.provider}/${selector.id}`;
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/** Composite key for auto-clear timers, keyed by phase name + task content. */
|
|
391
|
+
function todoClearKey(phaseName: string, taskContent: string): string {
|
|
392
|
+
return `${phaseName}\u0000${taskContent}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
390
395
|
const noOpUIContext: ExtensionUIContext = {
|
|
391
396
|
select: async (_title, _options, _dialogOptions) => undefined,
|
|
392
397
|
confirm: async (_title, _message, _dialogOptions) => false,
|
|
@@ -3347,10 +3352,9 @@ export class AgentSession {
|
|
|
3347
3352
|
|
|
3348
3353
|
#cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
|
|
3349
3354
|
return phases.map(phase => ({
|
|
3350
|
-
id: phase.id,
|
|
3351
3355
|
name: phase.name,
|
|
3352
3356
|
tasks: phase.tasks.map(task => {
|
|
3353
|
-
const out: TodoItem = {
|
|
3357
|
+
const out: TodoItem = { content: task.content, status: task.status };
|
|
3354
3358
|
if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
|
|
3355
3359
|
return out;
|
|
3356
3360
|
}),
|
|
@@ -3362,43 +3366,43 @@ export class AgentSession {
|
|
|
3362
3366
|
const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
|
|
3363
3367
|
if (delaySec < 0) return; // "Never" — no auto-clear
|
|
3364
3368
|
const delayMs = delaySec * 1000;
|
|
3365
|
-
const
|
|
3369
|
+
const doneKeys = new Set<string>();
|
|
3366
3370
|
for (const phase of phases) {
|
|
3367
3371
|
for (const task of phase.tasks) {
|
|
3368
3372
|
if (task.status === "completed" || task.status === "abandoned") {
|
|
3369
|
-
|
|
3373
|
+
doneKeys.add(todoClearKey(phase.name, task.content));
|
|
3370
3374
|
}
|
|
3371
3375
|
}
|
|
3372
3376
|
}
|
|
3373
3377
|
|
|
3374
3378
|
// Cancel timers for tasks that are no longer done (e.g. status was reverted)
|
|
3375
|
-
for (const [
|
|
3376
|
-
if (!
|
|
3379
|
+
for (const [key, timer] of this.#todoClearTimers) {
|
|
3380
|
+
if (!doneKeys.has(key)) {
|
|
3377
3381
|
clearTimeout(timer);
|
|
3378
|
-
this.#todoClearTimers.delete(
|
|
3382
|
+
this.#todoClearTimers.delete(key);
|
|
3379
3383
|
}
|
|
3380
3384
|
}
|
|
3381
3385
|
|
|
3382
3386
|
// Schedule new timers for newly-done tasks
|
|
3383
|
-
for (const
|
|
3384
|
-
if (this.#todoClearTimers.has(
|
|
3387
|
+
for (const key of doneKeys) {
|
|
3388
|
+
if (this.#todoClearTimers.has(key)) continue;
|
|
3385
3389
|
if (delayMs === 0) {
|
|
3386
3390
|
// Instant — run synchronously on next microtask to batch removals
|
|
3387
|
-
const timer = setTimeout(() => this.#runTodoAutoClear(
|
|
3388
|
-
this.#todoClearTimers.set(
|
|
3391
|
+
const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
|
|
3392
|
+
this.#todoClearTimers.set(key, timer);
|
|
3389
3393
|
} else {
|
|
3390
|
-
const timer = setTimeout(() => this.#runTodoAutoClear(
|
|
3391
|
-
this.#todoClearTimers.set(
|
|
3394
|
+
const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
|
|
3395
|
+
this.#todoClearTimers.set(key, timer);
|
|
3392
3396
|
}
|
|
3393
3397
|
}
|
|
3394
3398
|
}
|
|
3395
3399
|
|
|
3396
3400
|
/** Remove a single completed task and notify the UI. */
|
|
3397
|
-
#runTodoAutoClear(
|
|
3398
|
-
this.#todoClearTimers.delete(
|
|
3401
|
+
#runTodoAutoClear(key: string): void {
|
|
3402
|
+
this.#todoClearTimers.delete(key);
|
|
3399
3403
|
let removed = false;
|
|
3400
3404
|
for (const phase of this.#todoPhases) {
|
|
3401
|
-
const idx = phase.tasks.findIndex(t => t.
|
|
3405
|
+
const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
|
|
3402
3406
|
if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
|
|
3403
3407
|
phase.tasks.splice(idx, 1);
|
|
3404
3408
|
removed = true;
|
|
@@ -4568,7 +4572,7 @@ export class AgentSession {
|
|
|
4568
4572
|
(task): task is TodoItem & { status: "pending" | "in_progress" } =>
|
|
4569
4573
|
task.status === "pending" || task.status === "in_progress",
|
|
4570
4574
|
)
|
|
4571
|
-
.map(task => ({
|
|
4575
|
+
.map(task => ({ content: task.content, status: task.status })),
|
|
4572
4576
|
}))
|
|
4573
4577
|
.filter(phase => phase.tasks.length > 0);
|
|
4574
4578
|
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
|