@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 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
- ## Features
5
+ ![A phased todo plan showing completed, active, and pending tasks](media/todo-plan.png)
6
6
 
7
- - Concise phased plans with immutable task names
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
- Todo snapshots are stored in tool-result details, so `/tree` navigation restores the plan associated with that branch.
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. Todo output uses native-style self-rendered shells, and hidden successful operations render zero lines. When expanded, the latest rendered `set` result on the active branch follows later additions and transitions; historical details and collapsed rendering remain unchanged.
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 are transient user-role context messages: they are supplied to the model only for the current request and are never added to session history. A reminder is due only after `reminderMinTurns`, then when either `reminderMaxTurns` or `reminderOutputTokens` is reached, up to `reminderMaxPerRun` times per agent run. This guarded-OR cadence prevents reminders during short bursts while still catching either many small turns or a few output-heavy turns. Output tokens are counted because model-generated work, rather than prompt size, is the useful signal that a plan may have become stale. Any successful Todo action, including `view`, resets the turn/token window at the end of that turn; failed actions do not. Set `dynamicReminders` to `false` to disable 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 a successful manual, threshold, or overflow Pi compaction, the extension injects a one-shot transient full phased plan into the next model context build. It includes every phase and task with literal statuses, including `completed` and `cancelled` tasks and terminal-only plans; plans with zero tasks are skipped. This does not change Pi's compaction summary or session history. The injection takes priority over a due dynamic cadence reminder and resets its staleness window, regardless of `dynamicReminders`. Its pending state is in memory only and is cleared on session start/reload or `/tree` navigation, so it is not persisted across an extension reload before delivery.
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 it.
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.2.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 [`Todo: ${summary}`, ...formatTodoTaskLines(state)].join("\n");
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) => ` ${taskMarker(task)} ${task.name}`));
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
- switch (task.status) {
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 { TodoState } from "./types.js";
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 activeTasks = activePhase.tasks
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
- activeTasks.length > 0 ? `In progress: ${activeTasks.join("; ")}` : "No task is in_progress.",
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 { Todo, TodoAddress, TodoToolDetails } from "./types.js";
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) => taskPriority(left.task) - taskPriority(right.task) || left.index - right.index)
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 = isTerminal(task) && theme?.strikethrough ? theme.strikethrough(task.name) : task.name;
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({ minLength: 1, description: "Unique immutable phase name (1–2 words)." }),
7
- tasks: Type.Array(Type.String({
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({ minLength: 1, description: "Exact phase name." }),
15
- task: Type.String({ minLength: 1, description: "Exact task name within the phase." }),
16
- status: StringEnum(TODO_STATUSES, { description: "New task status." }),
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
- phase: Type.Optional(Type.String({ minLength: 1, description: "Exact phase to view; omit for the full plan." })),
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 = action.phase === undefined
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) => ({ name: task, status: "pending" })),
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 || tasks.length === 0
77
+ return !tasks
83
78
  ? phase
84
- : { ...phase, tasks: [...phase.tasks, ...tasks.map((name) => ({ name, status: "pending" as const }))] };
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(state: TodoState, transitions: readonly TodoTransitionInput[]): TodoState {
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
- return {
112
- phases: state.phases.map((phase) => ({
113
- ...phase,
114
- tasks: phase.tasks.map((task) => {
115
- const status = statuses.get(todoAddressKey(phase.name, task.name));
116
- return status === undefined ? task : { ...task, status };
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
- ...(input.phase === undefined ? {} : { phase: name(input.phase, "view phase") }),
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
- const tasks = input.tasks.map((task, taskIndex) => name(task, `phases[${phaseIndex}].tasks[${taskIndex}]`));
151
- assertUnique(tasks, (task) => `Duplicate task name in phase ${phaseName}: ${task}.`);
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; supplied tasks start pending.",
178
- " add(phases): Add phases or tasks; preserve existing tasks and statuses.",
179
- " transition(transitions): Set statuses by exact phase and task names.",
180
- " view(phase?): Return the full plan or one exact phase.",
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
- "Use todo for work with 3+ distinct steps; skip it for 1–2 steps.",
185
- "Transition todo tasks immediately when work starts or ends; do not defer updates until the end.",
186
- "Complete todo tasks only after the work is done and verified; cancel abandoned or obsolete tasks.",
187
- "Add material new work to todo as new tasks rather than expanding existing task scope.",
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 string[];
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;
@@ -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.phases.some(phase => phase.tasks.some(task => task.status === "in_progress"))) {
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
- activeMarker: this.frames[this.frameIndex],
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) % this.frames.length;
50
+ this.frameIndex = (this.frameIndex + 1) % PI_SPINNER_FRAMES.length;
60
51
  this.tui?.requestRender();
61
52
  this.scheduleNextFrame();
62
- }, delay);
53
+ }, PI_SPINNER_INTERVAL_MS);
63
54
  }
64
55
  }
@@ -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 { Todo, TodoPhase, TodoState } from "./types.js";
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
- activeMarker?: string;
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 selectedPhaseIndex = currentTodoPhaseIndex(phases);
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(formatTodoProgress("Todos", todoTasks(state)), theme), safeWidth)];
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, options.activeMarker), safeWidth));
44
+ lines.push(fit(taskLine(task, theme, options.fallbackGlyphs), safeWidth));
43
45
  }
44
- const openTasks = phase.tasks.filter(task => !isTerminal(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 = `${phaseIndex + 1}. ${formatTodoProgress(phase.name, phase.tasks)}`;
65
- if (selected) return toolTitle(` ${title}`, theme);
66
- return theme?.fg ? theme.fg("dim", ` ${title}`) : ` ${title}`;
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 => !isTerminal(task))
78
- .sort((left, right) => taskPriority(left) - taskPriority(right) || left.taskIndex - right.taskIndex);
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, activeMarker?: string): string {
84
- const marker = isActive(task) && activeMarker ? activeMarker : todoGlyph(task.status, fallbackGlyphs);
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.status === "completed" || task.status === "cancelled") && theme?.strikethrough
107
+ const name = isTerminalTodo(task) && theme?.strikethrough
87
108
  ? theme.strikethrough(task.name)
88
109
  : task.name;
89
- let line = ` ${marker} ${name}`;
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 { TodoState } from "./types.js";
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
- ctx.ui.setWidget("todo", undefined);
43
+ cancelTerminalClear(lifecycle);
44
+ widgetLifecycles.delete(ui);
45
+ ui.setWidget!("todo", undefined);
33
46
  return;
34
47
  }
35
48
 
36
- const visibleState = state?.phases.some(phase => phase.tasks.some(task =>
37
- task.status === "pending" || task.status === "in_progress",
38
- )) ? state : undefined;
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
- ctx.ui.setWidget("todo", factory, { placement });
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
- ctx.ui.notify?.(`Todo widget update failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
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
+ }