@pi9/todo 0.2.0 → 0.3.0
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/README.md +13 -15
- package/media/todo-plan.png +0 -0
- package/package.json +2 -1
- package/src/format.ts +14 -11
- package/src/reminder.ts +5 -5
- package/src/renderer.ts +3 -13
- package/src/schema.ts +16 -10
- package/src/state.ts +59 -39
- package/src/tool.ts +10 -11
- package/src/types.ts +19 -2
- package/src/widget-component.ts +4 -13
- package/src/widget-layout.ts +37 -27
- package/src/widget.ts +66 -14
package/README.md
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
# @pi9/todo
|
|
2
2
|
|
|
3
|
-
A phased, session-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
|
|
3
|
+
A phased, session-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono), with adaptive system reminders and a persistent widget that keeps the current plan visible while you work.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+

|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Destructive plan replacement and non-destructive task addition
|
|
9
|
-
- Atomic status transitions addressed by exact phase and task names
|
|
10
|
-
- Explicit `pending`, `in_progress`, `completed`, and `cancelled` statuses
|
|
11
|
-
- State restored from the active Pi session branch
|
|
12
|
-
- A persistent, configurable todo widget above or below the editor
|
|
13
|
-
- Rich tool rendering with active-task summaries and expandable phase progress
|
|
14
|
-
- Native-style self-rendered tool shells with no extra spacing for hidden activity
|
|
7
|
+
## Features
|
|
15
8
|
|
|
16
|
-
|
|
9
|
+
- **Phased planning.** Organize work into concise phases with immutable task names, detailed descriptions, and explicit `pending`, `in_progress`, `completed`, or `cancelled` statuses.
|
|
10
|
+
- **Context-efficient tool.** A compact, purpose-built tool description gives the model clear planning controls while consuming minimal context-window space.
|
|
11
|
+
- **Session-aware state.** Todo snapshots travel with Pi session branches, so `/tree` navigation restores the plan associated with each branch.
|
|
12
|
+
- **Persistent widget.** Keep active work visible above or below the editor, with configurable placement, task limits, and status glyphs.
|
|
13
|
+
- **Adaptive reminders.** System reminders refresh the model's awareness of stale plans during long runs and restore the full plan after context compaction.
|
|
14
|
+
- **Native tool rendering.** Compact tool output summarizes active work and expands into phase progress while respecting configurable visibility settings.
|
|
17
15
|
|
|
18
16
|
## Install
|
|
19
17
|
|
|
@@ -53,13 +51,13 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
|
|
|
53
51
|
- `"set-only"` shows only `set` operations.
|
|
54
52
|
- `"none"` hides normal Todo activity.
|
|
55
53
|
|
|
56
|
-
Errors are always shown
|
|
54
|
+
Errors are always shown, while hidden successful operations take up no terminal space. Expanded output stays synchronized with the latest plan on the active branch.
|
|
57
55
|
|
|
58
|
-
Dynamic reminders
|
|
56
|
+
Dynamic reminders keep the model aware of the plan during longer runs without adding messages to session history. The turn, output-token, and per-run settings control their cadence; set `dynamicReminders` to `false` to disable them.
|
|
59
57
|
|
|
60
|
-
After
|
|
58
|
+
After Pi compacts the context window, the extension supplies the full phased plan once on the next turn so work can continue without losing task state. This does not alter Pi's compaction summary or session history.
|
|
61
59
|
|
|
62
|
-
Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Set `widgetPlacement` to `"off"` to disable
|
|
60
|
+
Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Active tasks keep their normal status glyph; when work is active, a separate indented current-work line below the plan uses Pi's standard spinner and dim text. After all tasks become terminal, the widget shows the final phase summary for five seconds before clearing. Set `widgetPlacement` to `"off"` to disable the widget.
|
|
63
61
|
|
|
64
62
|
## Development
|
|
65
63
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi9/todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Phased, session-aware todo planning for Pi agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Chase Cummings <chaseecummings@gmail.com>",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"homepage": "https://github.com/Chase-C/pi9/tree/main/packages/todo#readme",
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
|
+
"media",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|
|
23
24
|
],
|
package/src/format.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { todoGlyph } from "./glyphs.js";
|
|
1
2
|
import type { Todo, TodoState, TodoStatus } from "./types.js";
|
|
2
3
|
|
|
3
4
|
export interface TodoCounts {
|
|
@@ -9,7 +10,7 @@ export interface TodoCounts {
|
|
|
9
10
|
export type TodoStatusCounts = Record<TodoStatus, number>;
|
|
10
11
|
|
|
11
12
|
/** A small, plain-text representation used in tool results and model context. */
|
|
12
|
-
export function formatTodoSummary(state: TodoState | undefined): string {
|
|
13
|
+
export function formatTodoSummary(state: TodoState | undefined, includeDescriptions = false): string {
|
|
13
14
|
const tasks = todoTasks(state);
|
|
14
15
|
if (tasks.length === 0) return "No todo tasks.";
|
|
15
16
|
|
|
@@ -20,17 +21,23 @@ export function formatTodoSummary(state: TodoState | undefined): string {
|
|
|
20
21
|
...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
|
|
21
22
|
].join(" · ");
|
|
22
23
|
|
|
23
|
-
return [
|
|
24
|
+
return [
|
|
25
|
+
`Todo: ${summary}`,
|
|
26
|
+
...(includeDescriptions && state?.workingOn ? [`Working on: ${state.workingOn}`] : []),
|
|
27
|
+
...formatTodoTaskLines(state, includeDescriptions),
|
|
28
|
+
].join("\n");
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
export function formatTodoTaskLines(state: TodoState | undefined): string[] {
|
|
31
|
+
export function formatTodoTaskLines(state: TodoState | undefined, includeDescriptions = false): string[] {
|
|
27
32
|
if (!state) return [];
|
|
28
33
|
|
|
29
34
|
const lines: string[] = [];
|
|
30
35
|
for (const phase of state.phases) {
|
|
31
36
|
if (phase.tasks.length === 0) continue;
|
|
32
37
|
lines.push(`${phase.name}:`);
|
|
33
|
-
lines.push(...phase.tasks.map((task) =>
|
|
38
|
+
lines.push(...phase.tasks.map((task) =>
|
|
39
|
+
` ${taskMarker(task)} ${task.name}${includeDescriptions ? ` — ${task.description}` : ""}`,
|
|
40
|
+
));
|
|
34
41
|
}
|
|
35
42
|
return lines;
|
|
36
43
|
}
|
|
@@ -43,11 +50,12 @@ export function formatTodoCompactionContext(state: TodoState): string | undefine
|
|
|
43
50
|
`${phase.name}:`,
|
|
44
51
|
...(phase.tasks.length === 0
|
|
45
52
|
? [" (no tasks)"]
|
|
46
|
-
: phase.tasks.map((task) => ` [${task.status}] ${task.name}`)),
|
|
53
|
+
: phase.tasks.map((task) => ` [${task.status}] ${task.name}: ${task.description}`)),
|
|
47
54
|
]);
|
|
48
55
|
return [
|
|
49
56
|
"<system-reminder source=\"todo-post-compaction\">",
|
|
50
57
|
"Todo plan after compaction:",
|
|
58
|
+
...(state.workingOn ? [`Current work: ${state.workingOn}`] : []),
|
|
51
59
|
...plan,
|
|
52
60
|
"Continue using this plan and keep task statuses current.",
|
|
53
61
|
"Do not mention this reminder to the user.",
|
|
@@ -82,12 +90,7 @@ export function formatTodoProgress(label: string, tasks: readonly Todo[]): strin
|
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export function taskMarker(task: Pick<Todo, "status">): string {
|
|
85
|
-
|
|
86
|
-
case "completed": return "✓";
|
|
87
|
-
case "in_progress": return "▶";
|
|
88
|
-
case "cancelled": return "×";
|
|
89
|
-
default: return "○";
|
|
90
|
-
}
|
|
93
|
+
return todoGlyph(task.status, true);
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export function todoTasks(state: TodoState | undefined): readonly Todo[] {
|
package/src/reminder.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { countTodoStatuses, todoTasks } from "./format.js";
|
|
2
2
|
import { currentTodoPhaseIndex } from "./state.js";
|
|
3
|
-
import type
|
|
3
|
+
import { isTerminalTodo, type TodoState } from "./types.js";
|
|
4
4
|
|
|
5
5
|
/** Formats the transient model-context reminder for an unfinished todo plan. */
|
|
6
6
|
export function formatTodoReminder(state: TodoState): string | undefined {
|
|
7
7
|
const activePhase = state.phases[currentTodoPhaseIndex(state.phases)];
|
|
8
8
|
if (!activePhase) return undefined;
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
.filter((task) => task.status === "in_progress")
|
|
12
|
-
.map((task) => task.name);
|
|
10
|
+
const openTasks = activePhase.tasks.filter((task) => !isTerminalTodo(task));
|
|
13
11
|
const counts = countTodoStatuses(todoTasks(state));
|
|
14
12
|
|
|
15
13
|
return [
|
|
16
14
|
"<system-reminder>",
|
|
17
15
|
`Active phase: ${activePhase.name}`,
|
|
18
|
-
|
|
16
|
+
...(state.workingOn ? [`Current work: ${state.workingOn}`] : []),
|
|
17
|
+
"Open tasks in this phase:",
|
|
18
|
+
...openTasks.map((task) => `- [${task.status}] ${task.name}: ${task.description}`),
|
|
19
19
|
`Counts: ${counts.in_progress} in_progress, ${counts.pending} pending, ${counts.completed} completed, ${counts.cancelled} cancelled.`,
|
|
20
20
|
"Review and update the todo if task status has changed.",
|
|
21
21
|
"Do not mention this reminder to the user.",
|
package/src/renderer.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { countTodos, formatTodoProgress, todoTasks } from "./format.js";
|
|
5
5
|
import { todoGlyph } from "./glyphs.js";
|
|
6
6
|
import { currentTodoPhaseIndex, todoAddressKey } from "./state.js";
|
|
7
|
-
import type
|
|
7
|
+
import { isTerminalTodo, todoTaskPriority, type Todo, type TodoAddress, type TodoToolDetails } from "./types.js";
|
|
8
8
|
|
|
9
9
|
type ThemeLike = Partial<Pick<Theme, "fg" | "bold" | "strikethrough">>;
|
|
10
10
|
type ThemeColor = Parameters<Theme["fg"]>[0];
|
|
@@ -56,27 +56,17 @@ function collapsedText(header: string, tasks: readonly Todo[], theme: ThemeLike
|
|
|
56
56
|
function orderedTasks(tasks: readonly Todo[]): Todo[] {
|
|
57
57
|
return tasks
|
|
58
58
|
.map((task, index) => ({ task, index }))
|
|
59
|
-
.sort((left, right) =>
|
|
59
|
+
.sort((left, right) => todoTaskPriority(left.task) - todoTaskPriority(right.task) || left.index - right.index)
|
|
60
60
|
.map(({ task }) => task);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function taskPriority(task: Todo): number {
|
|
64
|
-
if (task.status === "in_progress") return 0;
|
|
65
|
-
if (task.status === "pending") return 1;
|
|
66
|
-
return 2;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
63
|
function renderTask(phase: string, task: Todo, changed: Set<string>, theme: ThemeLike | undefined, options: TodoRendererOptions): string {
|
|
70
|
-
const text =
|
|
64
|
+
const text = isTerminalTodo(task) && theme?.strikethrough ? theme.strikethrough(task.name) : task.name;
|
|
71
65
|
let line = ` ${todoGlyph(task.status, options.fallbackGlyphs)} ${text}`;
|
|
72
66
|
if ((task.status === "in_progress" || changed.has(todoAddressKey(phase, task.name))) && theme?.bold) line = theme.bold(line);
|
|
73
67
|
return paint(theme, statusColor(task.status), line);
|
|
74
68
|
}
|
|
75
69
|
|
|
76
|
-
function isTerminal(task: Todo): boolean {
|
|
77
|
-
return task.status === "completed" || task.status === "cancelled";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
70
|
function addressKey(address: TodoAddress): string {
|
|
81
71
|
return todoAddressKey(address.phase, address.task);
|
|
82
72
|
}
|
package/src/schema.ts
CHANGED
|
@@ -2,24 +2,30 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { TODO_ACTIONS, TODO_STATUSES } from "./types.js";
|
|
4
4
|
|
|
5
|
+
export const TodoTaskSchema = Type.Object({
|
|
6
|
+
name: Type.String({
|
|
7
|
+
description: "Unique immutable task name within the phase; ~5–10 words describing what, not how.",
|
|
8
|
+
}),
|
|
9
|
+
description: Type.String({
|
|
10
|
+
description: "1–3 sentences expanding on the name with relevant context, constraints, or expected outcome.",
|
|
11
|
+
}),
|
|
12
|
+
}, { additionalProperties: false });
|
|
13
|
+
|
|
5
14
|
export const TodoPhaseSchema = Type.Object({
|
|
6
|
-
name: Type.String({
|
|
7
|
-
tasks: Type.Array(
|
|
8
|
-
minLength: 1,
|
|
9
|
-
description: "Unique immutable task name within its phase; ideally 5–10 words describing what, not how.",
|
|
10
|
-
})),
|
|
15
|
+
name: Type.String({ description: "Unique immutable phase name; 1–2 words." }),
|
|
16
|
+
tasks: Type.Array(TodoTaskSchema, { minItems: 1 }),
|
|
11
17
|
}, { additionalProperties: false });
|
|
12
18
|
|
|
13
19
|
export const TodoTransitionSchema = Type.Object({
|
|
14
|
-
phase: Type.String({
|
|
15
|
-
task: Type.String({
|
|
16
|
-
status: StringEnum(TODO_STATUSES, { description: "
|
|
20
|
+
phase: Type.String({ description: "Exact name of an existing phase." }),
|
|
21
|
+
task: Type.String({ description: "Exact name of an existing task within the phase." }),
|
|
22
|
+
status: StringEnum(TODO_STATUSES, { description: "Status to assign to the task." }),
|
|
17
23
|
}, { additionalProperties: false });
|
|
18
24
|
|
|
19
25
|
/** Flat provider-facing schema. Action-specific requirements are enforced by the transition. */
|
|
20
26
|
export const TodoParamsSchema = Type.Object({
|
|
21
27
|
action: StringEnum(TODO_ACTIONS),
|
|
22
|
-
phases: Type.Optional(Type.Array(TodoPhaseSchema)),
|
|
28
|
+
phases: Type.Optional(Type.Array(TodoPhaseSchema, { minItems: 1 })),
|
|
23
29
|
transitions: Type.Optional(Type.Array(TodoTransitionSchema, { minItems: 1 })),
|
|
24
|
-
|
|
30
|
+
workingOn: Type.Optional(Type.String({ description: "Concise summary of the current work." })),
|
|
25
31
|
}, { additionalProperties: false });
|
package/src/state.ts
CHANGED
|
@@ -22,6 +22,7 @@ export function cloneTodoState(state: TodoState): TodoState {
|
|
|
22
22
|
name: phase.name,
|
|
23
23
|
tasks: phase.tasks.map((task) => ({ ...task })),
|
|
24
24
|
})),
|
|
25
|
+
...(state.workingOn === undefined ? {} : { workingOn: state.workingOn }),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -39,12 +40,10 @@ export function transitionTodoState(state: TodoState, value: unknown): TodoState
|
|
|
39
40
|
next = addPhases(state, action.phases);
|
|
40
41
|
break;
|
|
41
42
|
case "transition":
|
|
42
|
-
next = applyTransitions(state, action.transitions);
|
|
43
|
+
next = applyTransitions(state, action.transitions, action.workingOn);
|
|
43
44
|
break;
|
|
44
45
|
case "view":
|
|
45
|
-
next =
|
|
46
|
-
? state
|
|
47
|
-
: { phases: [findPhase(state.phases, action.phase)] };
|
|
46
|
+
next = state;
|
|
48
47
|
break;
|
|
49
48
|
}
|
|
50
49
|
|
|
@@ -55,15 +54,11 @@ export function transitionTodoState(state: TodoState, value: unknown): TodoState
|
|
|
55
54
|
function newPhase(input: TodoPhaseInput): TodoPhase {
|
|
56
55
|
return {
|
|
57
56
|
name: input.name,
|
|
58
|
-
tasks: input.tasks.map((task) => ({
|
|
57
|
+
tasks: input.tasks.map((task) => ({ ...task, status: "pending" })),
|
|
59
58
|
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function addPhases(state: TodoState, inputs: readonly TodoPhaseInput[]): TodoState {
|
|
63
|
-
if (!inputs.some((phase) => phase.tasks.length > 0)) {
|
|
64
|
-
throw new Error("add requires at least one task.");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
62
|
const existingPhases = new Map(state.phases.map((phase) => [phase.name, phase]));
|
|
68
63
|
for (const input of inputs) {
|
|
69
64
|
const phase = existingPhases.get(input.name);
|
|
@@ -71,26 +66,30 @@ function addPhases(state: TodoState, inputs: readonly TodoPhaseInput[]): TodoSta
|
|
|
71
66
|
|
|
72
67
|
const taskNames = new Set(phase.tasks.map((task) => task.name));
|
|
73
68
|
for (const task of input.tasks) {
|
|
74
|
-
if (taskNames.has(task)) throw new Error(`Duplicate task name in phase ${input.name}: ${task}.`);
|
|
75
|
-
taskNames.add(task);
|
|
69
|
+
if (taskNames.has(task.name)) throw new Error(`Duplicate task name in phase ${input.name}: ${task.name}.`);
|
|
70
|
+
taskNames.add(task.name);
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
|
|
79
74
|
const additions = new Map(inputs.map((phase) => [phase.name, phase.tasks]));
|
|
80
75
|
const phases = state.phases.map((phase) => {
|
|
81
76
|
const tasks = additions.get(phase.name);
|
|
82
|
-
return !tasks
|
|
77
|
+
return !tasks
|
|
83
78
|
? phase
|
|
84
|
-
: { ...phase, tasks: [...phase.tasks, ...tasks.map((
|
|
79
|
+
: { ...phase, tasks: [...phase.tasks, ...tasks.map((task) => ({ ...task, status: "pending" as const }))] };
|
|
85
80
|
});
|
|
86
81
|
|
|
87
82
|
for (const input of inputs) {
|
|
88
83
|
if (!existingPhases.has(input.name)) phases.push(newPhase(input));
|
|
89
84
|
}
|
|
90
|
-
return { phases };
|
|
85
|
+
return { ...state, phases };
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
function applyTransitions(
|
|
88
|
+
function applyTransitions(
|
|
89
|
+
state: TodoState,
|
|
90
|
+
transitions: readonly TodoTransitionInput[],
|
|
91
|
+
workingOn: string | undefined,
|
|
92
|
+
): TodoState {
|
|
94
93
|
if (transitions.length === 0) throw new Error("transition requires at least one status change.");
|
|
95
94
|
|
|
96
95
|
const statuses = new Map<string, TodoStatus>();
|
|
@@ -108,15 +107,19 @@ function applyTransitions(state: TodoState, transitions: readonly TodoTransition
|
|
|
108
107
|
statuses.set(key, transition.status);
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
};
|
|
110
|
+
const phases = state.phases.map((phase) => ({
|
|
111
|
+
...phase,
|
|
112
|
+
tasks: phase.tasks.map((task) => {
|
|
113
|
+
const status = statuses.get(todoAddressKey(phase.name, task.name));
|
|
114
|
+
return status === undefined ? task : { ...task, status };
|
|
115
|
+
}),
|
|
116
|
+
}));
|
|
117
|
+
const hasActiveTasks = phases.some((phase) => phase.tasks.some((task) => task.status === "in_progress"));
|
|
118
|
+
if (!hasActiveTasks) return { phases };
|
|
119
|
+
if (workingOn === undefined) {
|
|
120
|
+
throw new Error("transition requires workingOn when tasks remain in_progress.");
|
|
121
|
+
}
|
|
122
|
+
return { phases, workingOn };
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function parseTodoAction(value: unknown): TodoAction {
|
|
@@ -129,26 +132,39 @@ function parseTodoAction(value: unknown): TodoAction {
|
|
|
129
132
|
assertOnlyFields(input, ["action", "phases"], action);
|
|
130
133
|
return { action, phases: parsePhases(input.phases) };
|
|
131
134
|
case "transition":
|
|
132
|
-
assertOnlyFields(input, ["action", "transitions"], action);
|
|
133
|
-
return { action, transitions: parseTransitions(input.transitions) };
|
|
134
|
-
case "view":
|
|
135
|
-
assertOnlyFields(input, ["action", "phase"], action);
|
|
135
|
+
assertOnlyFields(input, ["action", "transitions", "workingOn"], action);
|
|
136
136
|
return {
|
|
137
137
|
action,
|
|
138
|
-
|
|
138
|
+
transitions: parseTransitions(input.transitions),
|
|
139
|
+
...(input.workingOn === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { workingOn: name(input.workingOn, "transition workingOn") }),
|
|
139
142
|
};
|
|
143
|
+
case "view":
|
|
144
|
+
assertOnlyFields(input, ["action"], action);
|
|
145
|
+
return { action };
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
function parsePhases(value: unknown): TodoPhaseInput[] {
|
|
144
150
|
if (!Array.isArray(value)) throw new Error("phases must be an array.");
|
|
151
|
+
if (value.length === 0) throw new Error("phases must contain at least one phase.");
|
|
145
152
|
const phases = value.map((item, phaseIndex) => {
|
|
146
153
|
const input = record(item, `phases[${phaseIndex}]`);
|
|
147
154
|
assertOnlyFields(input, ["name", "tasks"], `phases[${phaseIndex}]`);
|
|
148
155
|
const phaseName = name(input.name, `phases[${phaseIndex}].name`);
|
|
149
156
|
if (!Array.isArray(input.tasks)) throw new Error(`phases[${phaseIndex}].tasks must be an array.`);
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
if (input.tasks.length === 0) throw new Error(`phases[${phaseIndex}].tasks must contain at least one task.`);
|
|
158
|
+
const tasks = input.tasks.map((task, taskIndex) => {
|
|
159
|
+
const label = `phases[${phaseIndex}].tasks[${taskIndex}]`;
|
|
160
|
+
const taskInput = record(task, label);
|
|
161
|
+
assertOnlyFields(taskInput, ["name", "description"], label);
|
|
162
|
+
return {
|
|
163
|
+
name: name(taskInput.name, `${label}.name`),
|
|
164
|
+
description: name(taskInput.description, `${label}.description`),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
assertUnique(tasks.map((task) => task.name), (task) => `Duplicate task name in phase ${phaseName}: ${task}.`);
|
|
152
168
|
return { name: phaseName, tasks };
|
|
153
169
|
});
|
|
154
170
|
assertUnique(phases.map((phase) => phase.name), (phase) => `Duplicate phase name: ${phase}.`);
|
|
@@ -202,12 +218,6 @@ function assertUnique(values: readonly string[], message: (value: string) => str
|
|
|
202
218
|
}
|
|
203
219
|
}
|
|
204
220
|
|
|
205
|
-
function findPhase(phases: readonly TodoPhase[], phaseName: string): TodoPhase {
|
|
206
|
-
const phase = phases.find((candidate) => candidate.name === phaseName);
|
|
207
|
-
if (!phase) throw new Error(phaseNotFoundMessage(phases, phaseName));
|
|
208
|
-
return phase;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
221
|
function phaseNotFoundMessage(phases: readonly TodoPhase[], phaseName: string): string {
|
|
212
222
|
const names = phases.map((phase) => `- ${phase.name}`).join("\n");
|
|
213
223
|
return names ? `Phase not found: ${phaseName}.\n\nCurrent phases:\n${names}` : `Phase not found: ${phaseName}. The todo plan is empty.`;
|
|
@@ -245,7 +255,8 @@ function assertTodoState(value: unknown): asserts value is TodoState {
|
|
|
245
255
|
throw new Error("Invalid todo state.");
|
|
246
256
|
}
|
|
247
257
|
|
|
248
|
-
const state = value as { phases: unknown[] };
|
|
258
|
+
const state = value as { phases: unknown[]; workingOn?: unknown };
|
|
259
|
+
const workingOn = state.workingOn === undefined ? undefined : name(state.workingOn, "workingOn");
|
|
249
260
|
const phaseNames = new Set<string>();
|
|
250
261
|
let activePhase: string | undefined;
|
|
251
262
|
|
|
@@ -254,6 +265,7 @@ function assertTodoState(value: unknown): asserts value is TodoState {
|
|
|
254
265
|
throw new Error("Invalid todo state.");
|
|
255
266
|
}
|
|
256
267
|
const phase = value as { name?: unknown; tasks: unknown[] };
|
|
268
|
+
if (phase.tasks.length === 0) throw new Error("Invalid todo state: phases must contain at least one task.");
|
|
257
269
|
const phaseName = name(phase.name, "phase name");
|
|
258
270
|
if (phaseNames.has(phaseName)) throw new Error("Invalid todo state: duplicate phase name.");
|
|
259
271
|
phaseNames.add(phaseName);
|
|
@@ -261,8 +273,9 @@ function assertTodoState(value: unknown): asserts value is TodoState {
|
|
|
261
273
|
const taskNames = new Set<string>();
|
|
262
274
|
for (const value of phase.tasks) {
|
|
263
275
|
if (!value || typeof value !== "object") throw new Error("Invalid todo state.");
|
|
264
|
-
const task = value as { name?: unknown; status?: unknown };
|
|
276
|
+
const task = value as { name?: unknown; description?: unknown; status?: unknown };
|
|
265
277
|
const taskName = name(task.name, "task name");
|
|
278
|
+
name(task.description, "task description");
|
|
266
279
|
if (taskNames.has(taskName)) throw new Error("Invalid todo state: duplicate task name.");
|
|
267
280
|
if (!isTodoStatus(task.status)) throw new Error("Invalid todo state.");
|
|
268
281
|
if (task.status === "in_progress") {
|
|
@@ -274,4 +287,11 @@ function assertTodoState(value: unknown): asserts value is TodoState {
|
|
|
274
287
|
taskNames.add(taskName);
|
|
275
288
|
}
|
|
276
289
|
}
|
|
290
|
+
|
|
291
|
+
if (activePhase !== undefined && workingOn === undefined) {
|
|
292
|
+
throw new Error("Invalid todo state: workingOn is required while tasks are in_progress.");
|
|
293
|
+
}
|
|
294
|
+
if (activePhase === undefined && workingOn !== undefined) {
|
|
295
|
+
throw new Error("Invalid todo state: workingOn requires an in_progress task.");
|
|
296
|
+
}
|
|
277
297
|
}
|
package/src/tool.ts
CHANGED
|
@@ -172,20 +172,19 @@ export function registerTodoTool(pi: ExtensionAPI): void {
|
|
|
172
172
|
name: "todo",
|
|
173
173
|
label: "Todo",
|
|
174
174
|
description: [
|
|
175
|
-
"Maintain a phased task plan.",
|
|
175
|
+
"Maintain a phased task plan for complex work with 3+ distinct steps.",
|
|
176
176
|
"Actions:",
|
|
177
|
-
" set(phases): Replace the plan;
|
|
178
|
-
" add(phases): Add
|
|
179
|
-
" transition(transitions): Set statuses by exact phase and task names.",
|
|
180
|
-
" view(
|
|
177
|
+
" set(phases): Replace the entire plan; all tasks start `pending`.",
|
|
178
|
+
" add(phases): Add described tasks; preserve existing tasks and statuses.",
|
|
179
|
+
" transition(transitions, workingOn?): Set statuses and current work by exact phase and task names.",
|
|
180
|
+
" view(): Return the full plan with task descriptions.",
|
|
181
181
|
].join("\n"),
|
|
182
182
|
promptSnippet: "Track multi-step work in a phased task plan",
|
|
183
183
|
promptGuidelines: [
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"Keep `in_progress` todo tasks in one phase; complete or cancel them before starting another phase.",
|
|
184
|
+
"Update the todo plan immediately as work starts, finishes, or is abandoned; never defer status transitions to the end.",
|
|
185
|
+
"Mark todo tasks `completed` only after verification and `cancelled` when abandoned or obsolete; keep all `in_progress` tasks confined to one phase.",
|
|
186
|
+
"Include an accurate `workingOn` summary in every todo transition that leaves one or more tasks `in_progress`.",
|
|
187
|
+
"Use todo `add` for material new work rather than expanding the scope of existing tasks; use the destructive `set` action only for planning or replanning.",
|
|
189
188
|
],
|
|
190
189
|
parameters: TodoParamsSchema,
|
|
191
190
|
renderShell: "self",
|
|
@@ -210,7 +209,7 @@ export function registerTodoTool(pi: ExtensionAPI): void {
|
|
|
210
209
|
updateTodoWidget(ctx, state, settings);
|
|
211
210
|
interactedWithTodoThisTurn = true;
|
|
212
211
|
return {
|
|
213
|
-
content: [{ type: "text" as const, text: formatTodoSummary(next) }],
|
|
212
|
+
content: [{ type: "text" as const, text: formatTodoSummary(next, params.action === "view") }],
|
|
214
213
|
details,
|
|
215
214
|
};
|
|
216
215
|
});
|
package/src/types.ts
CHANGED
|
@@ -14,9 +14,20 @@ export function isTodoActionName(value: unknown): value is TodoActionName {
|
|
|
14
14
|
|
|
15
15
|
export type Todo = {
|
|
16
16
|
readonly name: string;
|
|
17
|
+
readonly description: string;
|
|
17
18
|
readonly status: TodoStatus;
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
export function isTerminalTodo(todo: Pick<Todo, "status">): boolean {
|
|
22
|
+
return todo.status === "completed" || todo.status === "cancelled";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function todoTaskPriority(todo: Pick<Todo, "status">): number {
|
|
26
|
+
if (todo.status === "in_progress") return 0;
|
|
27
|
+
if (todo.status === "pending") return 1;
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export type TodoPhase = {
|
|
21
32
|
readonly name: string;
|
|
22
33
|
readonly tasks: readonly Todo[];
|
|
@@ -24,6 +35,7 @@ export type TodoPhase = {
|
|
|
24
35
|
|
|
25
36
|
export type TodoState = {
|
|
26
37
|
readonly phases: readonly TodoPhase[];
|
|
38
|
+
readonly workingOn?: string;
|
|
27
39
|
};
|
|
28
40
|
|
|
29
41
|
export type TodoAddress = {
|
|
@@ -38,9 +50,14 @@ export type TodoToolDetails = {
|
|
|
38
50
|
readonly changedTasks: readonly TodoAddress[];
|
|
39
51
|
};
|
|
40
52
|
|
|
53
|
+
export type TodoTaskInput = {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly description: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
41
58
|
export type TodoPhaseInput = {
|
|
42
59
|
readonly name: string;
|
|
43
|
-
readonly tasks: readonly
|
|
60
|
+
readonly tasks: readonly TodoTaskInput[];
|
|
44
61
|
};
|
|
45
62
|
|
|
46
63
|
export type TodoTransitionInput = TodoAddress & {
|
|
@@ -60,11 +77,11 @@ export type AddTodoAction = {
|
|
|
60
77
|
export type TransitionTodoAction = {
|
|
61
78
|
readonly action: "transition";
|
|
62
79
|
readonly transitions: readonly TodoTransitionInput[];
|
|
80
|
+
readonly workingOn?: string;
|
|
63
81
|
};
|
|
64
82
|
|
|
65
83
|
export type ViewTodoAction = {
|
|
66
84
|
readonly action: "view";
|
|
67
|
-
readonly phase?: string;
|
|
68
85
|
};
|
|
69
86
|
|
|
70
87
|
export type TodoAction = SetTodoAction | AddTodoAction | TransitionTodoAction | ViewTodoAction;
|
package/src/widget-component.ts
CHANGED
|
@@ -4,8 +4,6 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import type { TodoState } from "./types.js";
|
|
5
5
|
import { renderTodoWidgetLines, type TodoWidgetLayoutOptions } from "./widget-layout.js";
|
|
6
6
|
|
|
7
|
-
const DROPLET_FRAMES = ["", "", "", "", ""] as const;
|
|
8
|
-
const DROPLET_DURATIONS_MS = [220, 110, 80, 100, 420] as const;
|
|
9
7
|
const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
10
8
|
const PI_SPINNER_INTERVAL_MS = 80;
|
|
11
9
|
|
|
@@ -24,7 +22,7 @@ export class TodoWidgetComponent implements Component {
|
|
|
24
22
|
private readonly options: TodoWidgetComponentOptions = {},
|
|
25
23
|
private readonly tui?: Pick<TUI, "requestRender">,
|
|
26
24
|
) {
|
|
27
|
-
if (tui && state.
|
|
25
|
+
if (tui && state.workingOn) {
|
|
28
26
|
this.scheduleNextFrame();
|
|
29
27
|
}
|
|
30
28
|
}
|
|
@@ -36,7 +34,7 @@ export class TodoWidgetComponent implements Component {
|
|
|
36
34
|
const { blankLineBelow, ...layoutOptions } = this.options;
|
|
37
35
|
const lines = renderTodoWidgetLines(this.state, this.theme, safeWidth, {
|
|
38
36
|
...layoutOptions,
|
|
39
|
-
|
|
37
|
+
workingMarker: PI_SPINNER_FRAMES[this.frameIndex],
|
|
40
38
|
});
|
|
41
39
|
if (blankLineBelow && lines.length > 0) lines.push("");
|
|
42
40
|
return lines;
|
|
@@ -47,18 +45,11 @@ export class TodoWidgetComponent implements Component {
|
|
|
47
45
|
this.timer = undefined;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
private get frames(): readonly string[] {
|
|
51
|
-
return this.options.fallbackGlyphs ? PI_SPINNER_FRAMES : DROPLET_FRAMES;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
48
|
private scheduleNextFrame(): void {
|
|
55
|
-
const delay = this.options.fallbackGlyphs
|
|
56
|
-
? PI_SPINNER_INTERVAL_MS
|
|
57
|
-
: DROPLET_DURATIONS_MS[this.frameIndex];
|
|
58
49
|
this.timer = setTimeout(() => {
|
|
59
|
-
this.frameIndex = (this.frameIndex + 1) %
|
|
50
|
+
this.frameIndex = (this.frameIndex + 1) % PI_SPINNER_FRAMES.length;
|
|
60
51
|
this.tui?.requestRender();
|
|
61
52
|
this.scheduleNextFrame();
|
|
62
|
-
},
|
|
53
|
+
}, PI_SPINNER_INTERVAL_MS);
|
|
63
54
|
}
|
|
64
55
|
}
|
package/src/widget-layout.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
|
-
import { formatTodoProgress, todoTasks } from "./format.js";
|
|
5
4
|
import { todoGlyph } from "./glyphs.js";
|
|
6
5
|
import { currentTodoPhaseIndex } from "./state.js";
|
|
7
|
-
import type
|
|
6
|
+
import { isTerminalTodo, todoTaskPriority, type Todo, type TodoPhase, type TodoState } from "./types.js";
|
|
8
7
|
|
|
9
8
|
export type TodoWidgetLayoutOptions = {
|
|
10
9
|
maxVisible?: number;
|
|
11
10
|
fallbackGlyphs?: boolean;
|
|
12
|
-
|
|
11
|
+
workingMarker?: string;
|
|
13
12
|
};
|
|
14
13
|
|
|
15
14
|
type ThemeLike = Partial<Pick<Theme, "bold" | "fg" | "strikethrough">>;
|
|
@@ -25,12 +24,15 @@ export function renderTodoWidgetLines(
|
|
|
25
24
|
): string[] {
|
|
26
25
|
const safeWidth = Math.max(1, Math.floor(width) || 1);
|
|
27
26
|
const phases = state?.phases ?? [];
|
|
28
|
-
const
|
|
27
|
+
const currentPhaseIndex = currentTodoPhaseIndex(phases);
|
|
28
|
+
const selectedPhaseIndex = currentPhaseIndex >= 0
|
|
29
|
+
? currentPhaseIndex
|
|
30
|
+
: lastNonEmptyPhaseIndex(phases);
|
|
29
31
|
if (selectedPhaseIndex < 0) return [];
|
|
30
32
|
const selectedPhase = phases[selectedPhaseIndex];
|
|
31
33
|
const maxVisible = boundedMaxVisible(options.maxVisible);
|
|
32
34
|
const selectedTasks = visibleTasks(selectedPhase.tasks, maxVisible);
|
|
33
|
-
const lines: string[] = [fit(toolTitle(
|
|
35
|
+
const lines: string[] = [fit(toolTitle("Todos", theme), safeWidth)];
|
|
34
36
|
|
|
35
37
|
for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex++) {
|
|
36
38
|
const phase = phases[phaseIndex];
|
|
@@ -39,9 +41,9 @@ export function renderTodoWidgetLines(
|
|
|
39
41
|
|
|
40
42
|
if (selected) {
|
|
41
43
|
for (const task of selectedTasks) {
|
|
42
|
-
lines.push(fit(taskLine(task, theme, options.fallbackGlyphs
|
|
44
|
+
lines.push(fit(taskLine(task, theme, options.fallbackGlyphs), safeWidth));
|
|
43
45
|
}
|
|
44
|
-
const openTasks = phase.tasks.filter(task => !
|
|
46
|
+
const openTasks = phase.tasks.filter(task => !isTerminalTodo(task));
|
|
45
47
|
const hidden = openTasks.length - selectedTasks.length;
|
|
46
48
|
if (hidden > 0) lines.push(fit(` +${hidden} more`, safeWidth));
|
|
47
49
|
const terminalSummary = terminalTaskSummary(phase.tasks);
|
|
@@ -52,18 +54,37 @@ export function renderTodoWidgetLines(
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
if (state?.workingOn) {
|
|
58
|
+
lines.push("");
|
|
59
|
+
const text = theme?.fg ? theme.fg("dim", state.workingOn) : state.workingOn;
|
|
60
|
+
lines.push(fit(` ${options.workingMarker ?? "⠋"} ${text}`, safeWidth));
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
return lines;
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
function lastNonEmptyPhaseIndex(phases: readonly TodoPhase[]): number {
|
|
67
|
+
for (let index = phases.length - 1; index >= 0; index -= 1) {
|
|
68
|
+
if (phases[index].tasks.length > 0) return index;
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
function boundedMaxVisible(value: number | undefined): number {
|
|
59
74
|
if (value === undefined || !Number.isFinite(value)) return 5;
|
|
60
75
|
return Math.max(1, Math.floor(value));
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
function phaseTitle(phase: TodoPhase, phaseIndex: number, selected: boolean, theme: ThemeLike | undefined): string {
|
|
64
|
-
const title =
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
const title = ` ${phaseIndex + 1}. ${phase.name}`;
|
|
80
|
+
const terminal = phase.tasks.filter(isTerminalTodo).length;
|
|
81
|
+
const progress = `· ${terminal}/${phase.tasks.length}`;
|
|
82
|
+
if (!selected) {
|
|
83
|
+
const line = `${title} ${progress}`;
|
|
84
|
+
return theme?.fg ? theme.fg("dim", line) : line;
|
|
85
|
+
}
|
|
86
|
+
const dimProgress = theme?.fg ? theme.fg("dim", progress) : progress;
|
|
87
|
+
return `${toolTitle(title, theme)} ${dimProgress}`;
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
function toolTitle(text: string, theme: ThemeLike | undefined): string {
|
|
@@ -74,29 +95,22 @@ function toolTitle(text: string, theme: ThemeLike | undefined): string {
|
|
|
74
95
|
function visibleTasks(tasks: readonly Todo[], maxVisible: number): DisplayTask[] {
|
|
75
96
|
const ordered = tasks
|
|
76
97
|
.map((task, taskIndex) => ({ ...task, taskIndex }))
|
|
77
|
-
.filter(task => !
|
|
78
|
-
.sort((left, right) =>
|
|
98
|
+
.filter(task => !isTerminalTodo(task))
|
|
99
|
+
.sort((left, right) => todoTaskPriority(left) - todoTaskPriority(right) || left.taskIndex - right.taskIndex);
|
|
79
100
|
const active = ordered.filter(isActive);
|
|
80
101
|
return active.length > maxVisible ? active : ordered.slice(0, maxVisible);
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
function taskLine(task: Todo, theme: ThemeLike | undefined, fallbackGlyphs = false
|
|
84
|
-
const marker =
|
|
104
|
+
function taskLine(task: Todo, theme: ThemeLike | undefined, fallbackGlyphs = false): string {
|
|
105
|
+
const marker = todoGlyph(task.status, fallbackGlyphs);
|
|
85
106
|
const color = task.status === "in_progress" ? "text" : task.status === "completed" ? "success" : "dim";
|
|
86
|
-
const name = (task
|
|
107
|
+
const name = isTerminalTodo(task) && theme?.strikethrough
|
|
87
108
|
? theme.strikethrough(task.name)
|
|
88
109
|
: task.name;
|
|
89
|
-
|
|
90
|
-
if (isActive(task) && theme?.bold) line = theme.bold(line);
|
|
110
|
+
const line = ` ${marker} ${name}`;
|
|
91
111
|
return theme?.fg ? theme.fg(color, line) : line;
|
|
92
112
|
}
|
|
93
113
|
|
|
94
|
-
function taskPriority(task: Todo): number {
|
|
95
|
-
if (isActive(task)) return 0;
|
|
96
|
-
if (task.status === "pending") return 1;
|
|
97
|
-
return 2;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
114
|
function terminalTaskSummary(tasks: readonly Todo[]): string | undefined {
|
|
101
115
|
const completed = tasks.filter(task => task.status === "completed").length;
|
|
102
116
|
const cancelled = tasks.filter(task => task.status === "cancelled").length;
|
|
@@ -111,10 +125,6 @@ function isActive(task: Todo): boolean {
|
|
|
111
125
|
return task.status === "in_progress";
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
function isTerminal(task: Todo): boolean {
|
|
115
|
-
return task.status === "completed" || task.status === "cancelled";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
128
|
function fit(line: string, width: number): string {
|
|
119
129
|
return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
|
|
120
130
|
}
|
package/src/widget.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
import type { TodoSettings } from "./settings.js";
|
|
5
|
-
import type
|
|
5
|
+
import { isTerminalTodo, type TodoState } from "./types.js";
|
|
6
6
|
import { TodoWidgetComponent } from "./widget-component.js";
|
|
7
7
|
|
|
8
8
|
export type TodoWidgetSettings = Partial<Pick<
|
|
@@ -11,31 +11,46 @@ export type TodoWidgetSettings = Partial<Pick<
|
|
|
11
11
|
>>;
|
|
12
12
|
|
|
13
13
|
type WidgetComponentFactory = (tui: TUI, theme: Theme) => Component & { dispose?(): void };
|
|
14
|
+
type TodoWidgetUI = {
|
|
15
|
+
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
16
|
+
setWidget?: {
|
|
17
|
+
(id: string, content: string[] | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
18
|
+
(id: string, content: WidgetComponentFactory | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const TERMINAL_WIDGET_DELAY_MS = 5_000;
|
|
23
|
+
type TodoWidgetLifecycle = {
|
|
24
|
+
hadOpenTasks: boolean;
|
|
25
|
+
terminalClearTimer?: ReturnType<typeof setTimeout>;
|
|
26
|
+
};
|
|
27
|
+
const widgetLifecycles = new WeakMap<TodoWidgetUI, TodoWidgetLifecycle>();
|
|
14
28
|
|
|
15
29
|
export type TodoWidgetContext = {
|
|
16
30
|
hasUI?: boolean;
|
|
17
|
-
ui?:
|
|
18
|
-
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
19
|
-
setWidget?: {
|
|
20
|
-
(id: string, content: string[] | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
21
|
-
(id: string, content: WidgetComponentFactory | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
22
|
-
};
|
|
23
|
-
};
|
|
31
|
+
ui?: TodoWidgetUI;
|
|
24
32
|
};
|
|
25
33
|
|
|
26
34
|
/** Update (or clear) the persistent todo widget without requiring the host UI at runtime. */
|
|
27
35
|
export function updateTodoWidget(ctx: TodoWidgetContext | undefined, state: TodoState | undefined, settings: TodoWidgetSettings = {}): void {
|
|
28
36
|
if (!ctx?.hasUI || !ctx.ui?.setWidget) return;
|
|
37
|
+
const ui = ctx.ui;
|
|
38
|
+
const lifecycle = widgetLifecycles.get(ui) ?? { hadOpenTasks: false };
|
|
39
|
+
|
|
29
40
|
try {
|
|
30
41
|
const placement = settings.widgetPlacement ?? "aboveEditor";
|
|
31
42
|
if (placement === "off") {
|
|
32
|
-
|
|
43
|
+
cancelTerminalClear(lifecycle);
|
|
44
|
+
widgetLifecycles.delete(ui);
|
|
45
|
+
ui.setWidget!("todo", undefined);
|
|
33
46
|
return;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
const hasTasks = state?.phases.some((phase) => phase.tasks.length > 0) ?? false;
|
|
50
|
+
const hasOpenTasks = state?.phases.some((phase) => phase.tasks.some((task) => !isTerminalTodo(task))) ?? false;
|
|
51
|
+
const showFinalState = hasTasks && !hasOpenTasks
|
|
52
|
+
&& (lifecycle.hadOpenTasks || lifecycle.terminalClearTimer !== undefined);
|
|
53
|
+
const visibleState = hasOpenTasks || showFinalState ? state : undefined;
|
|
39
54
|
const factory: WidgetComponentFactory | undefined = visibleState
|
|
40
55
|
? (tui, theme) => new TodoWidgetComponent(visibleState, theme, {
|
|
41
56
|
maxVisible: settings.maxVisibleTasks,
|
|
@@ -43,8 +58,45 @@ export function updateTodoWidget(ctx: TodoWidgetContext | undefined, state: Todo
|
|
|
43
58
|
blankLineBelow: placement === "aboveEditor",
|
|
44
59
|
}, tui)
|
|
45
60
|
: undefined;
|
|
46
|
-
|
|
61
|
+
ui.setWidget!("todo", factory, { placement });
|
|
62
|
+
|
|
63
|
+
if (hasOpenTasks) {
|
|
64
|
+
cancelTerminalClear(lifecycle);
|
|
65
|
+
lifecycle.hadOpenTasks = true;
|
|
66
|
+
widgetLifecycles.set(ui, lifecycle);
|
|
67
|
+
} else if (showFinalState) {
|
|
68
|
+
lifecycle.hadOpenTasks = false;
|
|
69
|
+
if (!lifecycle.terminalClearTimer) {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
widgetLifecycles.delete(ui);
|
|
72
|
+
clearWidget(ui);
|
|
73
|
+
}, TERMINAL_WIDGET_DELAY_MS);
|
|
74
|
+
timer.unref?.();
|
|
75
|
+
lifecycle.terminalClearTimer = timer;
|
|
76
|
+
}
|
|
77
|
+
widgetLifecycles.set(ui, lifecycle);
|
|
78
|
+
} else {
|
|
79
|
+
cancelTerminalClear(lifecycle);
|
|
80
|
+
widgetLifecycles.delete(ui);
|
|
81
|
+
}
|
|
47
82
|
} catch (error) {
|
|
48
|
-
|
|
83
|
+
notifyWidgetError(ui, error);
|
|
49
84
|
}
|
|
50
85
|
}
|
|
86
|
+
|
|
87
|
+
function cancelTerminalClear(lifecycle: TodoWidgetLifecycle): void {
|
|
88
|
+
if (lifecycle.terminalClearTimer) clearTimeout(lifecycle.terminalClearTimer);
|
|
89
|
+
delete lifecycle.terminalClearTimer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearWidget(ui: TodoWidgetUI): void {
|
|
93
|
+
try {
|
|
94
|
+
ui.setWidget?.("todo", undefined);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
notifyWidgetError(ui, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function notifyWidgetError(ui: TodoWidgetUI, error: unknown): void {
|
|
101
|
+
ui.notify?.(`Todo widget update failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
102
|
+
}
|