@pi9/todo 0.1.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/src/tool.ts CHANGED
@@ -1,39 +1,43 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, type Component } from "@earendil-works/pi-tui";
3
- import { formatTodoSummary } from "./format.js";
3
+ import { formatTodoCompactionContext, formatTodoSummary } from "./format.js";
4
4
  import { restoreTodoState } from "./persistence.js";
5
+ import {
6
+ beginReminderAgentRun,
7
+ consumeDueReminder,
8
+ createReminderCadenceState,
9
+ noteReminderTurn,
10
+ noteTodoInteraction,
11
+ type ReminderCadenceConfig,
12
+ } from "./reminder-cadence.js";
13
+ import { formatTodoReminder } from "./reminder.js";
5
14
  import { renderResult as renderTodoResult } from "./renderer.js";
6
15
  import { TodoToolFrame, type TodoToolFrameContent, type TodoToolFrameTheme } from "./tool-frame.js";
7
16
  import { TodoParamsSchema } from "./schema.js";
8
- import { DEFAULT_TODO_UI_SETTINGS, loadTodoUiSettings, type TodoUiSettings } from "./settings.js";
17
+ import { DEFAULT_TODO_SETTINGS, loadTodoSettings, type TodoSettings } from "./settings.js";
9
18
  import { createTodoState, todoAddressKey, transitionTodoState } from "./state.js";
10
- import { TODO_STATUSES, type TodoAction, type TodoAddress, type TodoState, type TodoToolDetails } from "./types.js";
19
+ import type { TodoAddress, TodoState, TodoStatus, TodoToolDetails } from "./types.js";
11
20
  import { shouldRenderTodoAction } from "./visibility.js";
12
21
  import { updateTodoWidget } from "./widget.js";
13
22
 
14
- function taskStatuses(state: TodoState): Map<string, string> {
23
+ function taskStatuses(state: TodoState): Map<string, TodoStatus> {
15
24
  return new Map(state.phases.flatMap((phase) => phase.tasks.map((task) => [todoAddressKey(phase.name, task.name), task.status])));
16
25
  }
17
26
 
18
- function taskAddresses(state: TodoState): Map<string, TodoAddress> {
19
- return new Map(state.phases.flatMap((phase) => phase.tasks.map((task) => {
20
- const address = { phase: phase.name, task: task.name };
21
- return [todoAddressKey(address.phase, address.task), address];
27
+ function taskAddresses(state: TodoState): TodoAddress[] {
28
+ return state.phases.flatMap((phase) => phase.tasks.map((task) => ({
29
+ phase: phase.name,
30
+ task: task.name,
22
31
  })));
23
32
  }
24
33
 
25
34
  function changedTasks(previous: TodoState, next: TodoState): TodoAddress[] {
26
35
  const before = taskStatuses(previous);
27
- const after = taskStatuses(next);
28
- const addresses = taskAddresses(next);
29
- return [...after.keys()].filter((key) => before.get(key) !== after.get(key)).map((key) => addresses.get(key)!);
30
- }
31
-
32
- function completedTasks(previous: TodoState, next: TodoState): TodoAddress[] {
33
- const before = taskStatuses(previous);
34
- return next.phases.flatMap((phase) => phase.tasks
35
- .filter((task) => task.status === "completed" && before.has(todoAddressKey(phase.name, task.name)) && before.get(todoAddressKey(phase.name, task.name)) !== "completed")
36
- .map((task) => ({ phase: phase.name, task: task.name })));
36
+ return next.phases.flatMap((phase) => phase.tasks.flatMap((task) =>
37
+ before.get(todoAddressKey(phase.name, task.name)) === task.status
38
+ ? []
39
+ : [{ phase: phase.name, task: task.name }],
40
+ ));
37
41
  }
38
42
 
39
43
  function createTodoFrame(
@@ -58,6 +62,15 @@ type TodoRenderInput = {
58
62
  type TodoRenderTheme = Parameters<typeof renderTodoResult>[2];
59
63
  type TrackedSetRenderer = { toolCallId: string; invalidate?: () => void };
60
64
 
65
+ function reminderConfig(settings: TodoSettings): ReminderCadenceConfig {
66
+ return {
67
+ minTurns: settings.reminderMinTurns,
68
+ maxTurns: settings.reminderMaxTurns,
69
+ outputTokens: settings.reminderOutputTokens,
70
+ maxPerRun: settings.reminderMaxPerRun,
71
+ };
72
+ }
73
+
61
74
  /** Expanded content for the one set result that is allowed to follow in-memory state. */
62
75
  class LiveSetResult implements Component {
63
76
  constructor(
@@ -81,7 +94,10 @@ class LiveSetResult implements Component {
81
94
 
82
95
  export function registerTodoTool(pi: ExtensionAPI): void {
83
96
  let state = createTodoState();
84
- let settings: TodoUiSettings = { ...DEFAULT_TODO_UI_SETTINGS };
97
+ let settings: TodoSettings = { ...DEFAULT_TODO_SETTINGS };
98
+ let reminderCadence = createReminderCadenceState();
99
+ let pendingCompactionContext: string | undefined;
100
+ let interactedWithTodoThisTurn = false;
85
101
  let queue: Promise<void> = Promise.resolve();
86
102
  let latestSetRenderer: TrackedSetRenderer | undefined;
87
103
 
@@ -95,30 +111,80 @@ export function registerTodoTool(pi: ExtensionAPI): void {
95
111
  updateTodoWidget(ctx, state, settings);
96
112
  };
97
113
 
114
+ const resetReminderTracking = (): void => {
115
+ reminderCadence = createReminderCadenceState();
116
+ interactedWithTodoThisTurn = false;
117
+ };
118
+
98
119
  pi.on("session_start", async (_event, ctx) => {
99
- const loaded = await loadTodoUiSettings(ctx);
120
+ const loaded = await loadTodoSettings(ctx);
100
121
  settings = loaded.settings;
101
122
  if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
102
123
  restore(ctx);
124
+ pendingCompactionContext = undefined;
125
+ resetReminderTracking();
126
+ });
127
+ pi.on("session_tree", (_event, ctx) => {
128
+ restore(ctx);
129
+ pendingCompactionContext = undefined;
130
+ resetReminderTracking();
131
+ });
132
+ pi.on("session_compact", () => {
133
+ pendingCompactionContext = formatTodoCompactionContext(state);
134
+ if (pendingCompactionContext) reminderCadence = noteTodoInteraction(reminderCadence);
135
+ });
136
+ pi.on("before_agent_start", () => {
137
+ reminderCadence = beginReminderAgentRun(reminderCadence);
138
+ });
139
+ pi.on("turn_end", (event) => {
140
+ reminderCadence = interactedWithTodoThisTurn
141
+ ? noteTodoInteraction(reminderCadence)
142
+ : noteReminderTurn(
143
+ reminderCadence,
144
+ event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0,
145
+ );
146
+ interactedWithTodoThisTurn = false;
147
+ });
148
+ pi.on("context", (event) => {
149
+ if (pendingCompactionContext) {
150
+ const content = pendingCompactionContext;
151
+ pendingCompactionContext = undefined;
152
+ return {
153
+ messages: [...event.messages, { role: "user", content, timestamp: Date.now() }],
154
+ };
155
+ }
156
+
157
+ if (!settings.dynamicReminders) return;
158
+
159
+ const reminder = formatTodoReminder(state);
160
+ if (!reminder) return;
161
+
162
+ const consumed = consumeDueReminder(reminderCadence, reminderConfig(settings));
163
+ if (!consumed.due) return;
164
+
165
+ reminderCadence = consumed.state;
166
+ return {
167
+ messages: [...event.messages, { role: "user", content: reminder, timestamp: Date.now() }],
168
+ };
103
169
  });
104
- pi.on("session_tree", (_event, ctx) => restore(ctx));
105
170
 
106
171
  pi.registerTool({
107
172
  name: "todo",
108
173
  label: "Todo",
109
174
  description: [
110
- "Maintain a concise phased plan that reflects both intended work and current execution progress.",
175
+ "Maintain a phased task plan for complex work with 3+ distinct steps.",
111
176
  "Actions:",
112
- " set: Replace the entire plan. All tasks reset to pending. Use only for a new plan or full re-plan.",
113
- " add: Append tasks or phases without touching existing work.",
114
- " transition: Update task statuses (" + TODO_STATUSES.join(", ") + ") by exact phase and task name.",
115
- " view: Return the plan, optionally filtered to one 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.",
116
181
  ].join("\n"),
117
- promptSnippet: "Track multi-step work with the todo tool; keep statuses current as you go",
182
+ promptSnippet: "Track multi-step work in a phased task plan",
118
183
  promptGuidelines: [
119
- "Use todo for non-trivial work with three or more distinct steps; skip todo for simple tasks.",
120
- "Transition todo statuses immediately and honestly—mark tasks completed only when fully done, not merely attempted, and cancel abandoned tasks rather than leaving them pending.",
121
- "Todo tasks in_progress must all belong to a single phase; finish or cancel a phase's active tasks before starting the next.",
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.",
122
188
  ],
123
189
  parameters: TodoParamsSchema,
124
190
  renderShell: "self",
@@ -126,24 +192,24 @@ export function registerTodoTool(pi: ExtensionAPI): void {
126
192
  execute(_toolCallId, params, _signal, _onUpdate, ctx) {
127
193
  const run = queue.then(() => {
128
194
  const previous = state;
129
- const next = transitionTodoState(previous, params as TodoAction);
195
+ const next = transitionTodoState(previous, params);
130
196
  const details: TodoToolDetails = {
131
197
  action: params.action,
132
198
  state: next,
133
199
  changedTasks: params.action === "view"
134
200
  ? []
135
201
  : params.action === "set"
136
- ? [...taskAddresses(next).values()]
202
+ ? taskAddresses(next)
137
203
  : changedTasks(previous, next),
138
- completedTasks: params.action === "transition" ? completedTasks(previous, next) : [],
139
204
  };
140
205
  if (params.action !== "view") {
141
206
  state = next;
142
207
  invalidateLatestSetRenderer();
143
208
  }
144
209
  updateTodoWidget(ctx, state, settings);
210
+ interactedWithTodoThisTurn = true;
145
211
  return {
146
- content: [{ type: "text" as const, text: formatTodoSummary(next) }],
212
+ content: [{ type: "text" as const, text: formatTodoSummary(next, params.action === "view") }],
147
213
  details,
148
214
  };
149
215
  });
package/src/types.ts CHANGED
@@ -1,63 +1,87 @@
1
1
  export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
2
2
  export type TodoStatus = typeof TODO_STATUSES[number];
3
3
 
4
+ export function isTodoStatus(value: unknown): value is TodoStatus {
5
+ return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
6
+ }
7
+
4
8
  export const TODO_ACTIONS = ["set", "add", "transition", "view"] as const;
5
9
  export type TodoActionName = typeof TODO_ACTIONS[number];
6
10
 
11
+ export function isTodoActionName(value: unknown): value is TodoActionName {
12
+ return typeof value === "string" && (TODO_ACTIONS as readonly string[]).includes(value);
13
+ }
14
+
7
15
  export type Todo = {
8
- name: string;
9
- status: TodoStatus;
16
+ readonly name: string;
17
+ readonly description: string;
18
+ readonly status: TodoStatus;
10
19
  };
11
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
+
12
31
  export type TodoPhase = {
13
- name: string;
14
- tasks: Todo[];
32
+ readonly name: string;
33
+ readonly tasks: readonly Todo[];
15
34
  };
16
35
 
17
36
  export type TodoState = {
18
- phases: TodoPhase[];
37
+ readonly phases: readonly TodoPhase[];
38
+ readonly workingOn?: string;
19
39
  };
20
40
 
21
41
  export type TodoAddress = {
22
- phase: string;
23
- task: string;
42
+ readonly phase: string;
43
+ readonly task: string;
24
44
  };
25
45
 
26
46
  /** Persisted with a successful tool result so session state can be restored. */
27
47
  export type TodoToolDetails = {
28
- action: TodoActionName;
29
- state: TodoState;
30
- changedTasks: TodoAddress[];
31
- completedTasks: TodoAddress[];
48
+ readonly action: TodoActionName;
49
+ readonly state: TodoState;
50
+ readonly changedTasks: readonly TodoAddress[];
51
+ };
52
+
53
+ export type TodoTaskInput = {
54
+ readonly name: string;
55
+ readonly description: string;
32
56
  };
33
57
 
34
58
  export type TodoPhaseInput = {
35
- name: string;
36
- tasks: string[];
59
+ readonly name: string;
60
+ readonly tasks: readonly TodoTaskInput[];
37
61
  };
38
62
 
39
63
  export type TodoTransitionInput = TodoAddress & {
40
- status: TodoStatus;
64
+ readonly status: TodoStatus;
41
65
  };
42
66
 
43
67
  export type SetTodoAction = {
44
- action: "set";
45
- phases: TodoPhaseInput[];
68
+ readonly action: "set";
69
+ readonly phases: readonly TodoPhaseInput[];
46
70
  };
47
71
 
48
72
  export type AddTodoAction = {
49
- action: "add";
50
- phases: TodoPhaseInput[];
73
+ readonly action: "add";
74
+ readonly phases: readonly TodoPhaseInput[];
51
75
  };
52
76
 
53
77
  export type TransitionTodoAction = {
54
- action: "transition";
55
- transitions: TodoTransitionInput[];
78
+ readonly action: "transition";
79
+ readonly transitions: readonly TodoTransitionInput[];
80
+ readonly workingOn?: string;
56
81
  };
57
82
 
58
83
  export type ViewTodoAction = {
59
- action: "view";
60
- phase?: string;
84
+ readonly action: "view";
61
85
  };
62
86
 
63
87
  export type TodoAction = SetTodoAction | AddTodoAction | TransitionTodoAction | ViewTodoAction;
package/src/visibility.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import type { TodoToolVisibility } from "./settings.js";
2
- import { TODO_ACTIONS, type TodoActionName } from "./types.js";
3
-
4
- function isTodoActionName(action: unknown): action is TodoActionName {
5
- return typeof action === "string" && (TODO_ACTIONS as readonly string[]).includes(action);
6
- }
2
+ import { isTodoActionName } from "./types.js";
7
3
 
8
4
  /** Returns whether a successful todo action should be shown in tool output. */
9
5
  export function shouldRenderTodoAction(action: unknown, visibility: TodoToolVisibility): boolean {
@@ -1,14 +1,16 @@
1
- import { wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
1
+ 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 { 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
 
10
+ type TodoWidgetComponentOptions = TodoWidgetLayoutOptions & {
11
+ blankLineBelow?: boolean;
12
+ };
13
+
12
14
  /** A width-aware component for Pi's persistent widget area. */
13
15
  export class TodoWidgetComponent implements Component {
14
16
  private frameIndex = 0;
@@ -17,22 +19,25 @@ export class TodoWidgetComponent implements Component {
17
19
  constructor(
18
20
  private readonly state: TodoState,
19
21
  private readonly theme: Theme | undefined,
20
- private readonly options: TodoWidgetLayoutOptions = {},
22
+ private readonly options: TodoWidgetComponentOptions = {},
21
23
  private readonly tui?: Pick<TUI, "requestRender">,
22
24
  ) {
23
- if (tui && state.phases.some(phase => phase.tasks.some(task => task.status === "in_progress"))) {
25
+ if (tui && state.workingOn) {
24
26
  this.scheduleNextFrame();
25
27
  }
26
28
  }
27
29
 
28
- invalidate(): void { }
30
+ invalidate(): void {}
29
31
 
30
32
  render(width: number): string[] {
31
33
  const safeWidth = Math.max(1, Math.floor(width) || 1);
32
- return renderTodoWidgetLines(this.state, this.theme, safeWidth, {
33
- ...this.options,
34
- activeMarker: this.frames[this.frameIndex],
35
- }).flatMap(line => wrapTextWithAnsi(line, safeWidth));
34
+ const { blankLineBelow, ...layoutOptions } = this.options;
35
+ const lines = renderTodoWidgetLines(this.state, this.theme, safeWidth, {
36
+ ...layoutOptions,
37
+ workingMarker: PI_SPINNER_FRAMES[this.frameIndex],
38
+ });
39
+ if (blankLineBelow && lines.length > 0) lines.push("");
40
+ return lines;
36
41
  }
37
42
 
38
43
  dispose(): void {
@@ -40,18 +45,11 @@ export class TodoWidgetComponent implements Component {
40
45
  this.timer = undefined;
41
46
  }
42
47
 
43
- private get frames(): readonly string[] {
44
- return this.options.fallbackGlyphs ? PI_SPINNER_FRAMES : DROPLET_FRAMES;
45
- }
46
-
47
48
  private scheduleNextFrame(): void {
48
- const delay = this.options.fallbackGlyphs
49
- ? PI_SPINNER_INTERVAL_MS
50
- : DROPLET_DURATIONS_MS[this.frameIndex];
51
49
  this.timer = setTimeout(() => {
52
- this.frameIndex = (this.frameIndex + 1) % this.frames.length;
50
+ this.frameIndex = (this.frameIndex + 1) % PI_SPINNER_FRAMES.length;
53
51
  this.tui?.requestRender();
54
52
  this.scheduleNextFrame();
55
- }, delay);
53
+ }, PI_SPINNER_INTERVAL_MS);
56
54
  }
57
55
  }
@@ -2,22 +2,20 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  import { todoGlyph } from "./glyphs.js";
5
- import type { Todo, TodoPhase, TodoState } from "./types.js";
5
+ import { currentTodoPhaseIndex } from "./state.js";
6
+ import { isTerminalTodo, todoTaskPriority, type Todo, type TodoPhase, type TodoState } from "./types.js";
6
7
 
7
8
  export type TodoWidgetLayoutOptions = {
8
9
  maxVisible?: number;
9
10
  fallbackGlyphs?: boolean;
10
- activeMarker?: string;
11
+ workingMarker?: string;
11
12
  };
12
13
 
13
14
  type ThemeLike = Partial<Pick<Theme, "bold" | "fg" | "strikethrough">>;
14
15
 
15
16
  type DisplayTask = Todo & { taskIndex: number };
16
17
 
17
- /**
18
- * Produces compact widget rows. Every returned row is constrained to the supplied display width;
19
- * the component may subsequently wrap a row when a host chooses a narrower cell.
20
- */
18
+ /** Produces compact widget rows constrained to the supplied display width. */
21
19
  export function renderTodoWidgetLines(
22
20
  state: TodoState | undefined,
23
21
  theme: ThemeLike | undefined,
@@ -25,15 +23,16 @@ export function renderTodoWidgetLines(
25
23
  options: TodoWidgetLayoutOptions = {},
26
24
  ): string[] {
27
25
  const safeWidth = Math.max(1, Math.floor(width) || 1);
28
- const phases = Array.isArray(state?.phases) ? state.phases : [];
29
- if (phases.length === 0 || !phases.some(phase => phase.tasks.length > 0)) return [];
30
-
31
- const selectedPhaseIndex = selectPhase(phases);
26
+ const phases = state?.phases ?? [];
27
+ const currentPhaseIndex = currentTodoPhaseIndex(phases);
28
+ const selectedPhaseIndex = currentPhaseIndex >= 0
29
+ ? currentPhaseIndex
30
+ : lastNonEmptyPhaseIndex(phases);
32
31
  if (selectedPhaseIndex < 0) return [];
33
32
  const selectedPhase = phases[selectedPhaseIndex];
34
33
  const maxVisible = boundedMaxVisible(options.maxVisible);
35
34
  const selectedTasks = visibleTasks(selectedPhase.tasks, maxVisible);
36
- const lines: string[] = [fit(toolTitle(summary(phases), theme), safeWidth)];
35
+ const lines: string[] = [fit(toolTitle("Todos", theme), safeWidth)];
37
36
 
38
37
  for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex++) {
39
38
  const phase = phases[phaseIndex];
@@ -42,9 +41,9 @@ export function renderTodoWidgetLines(
42
41
 
43
42
  if (selected) {
44
43
  for (const task of selectedTasks) {
45
- lines.push(fit(taskLine(task, theme, options.fallbackGlyphs, options.activeMarker), safeWidth));
44
+ lines.push(fit(taskLine(task, theme, options.fallbackGlyphs), safeWidth));
46
45
  }
47
- const openTasks = phase.tasks.filter(task => !isTerminal(task));
46
+ const openTasks = phase.tasks.filter(task => !isTerminalTodo(task));
48
47
  const hidden = openTasks.length - selectedTasks.length;
49
48
  if (hidden > 0) lines.push(fit(` +${hidden} more`, safeWidth));
50
49
  const terminalSummary = terminalTaskSummary(phase.tasks);
@@ -55,50 +54,37 @@ export function renderTodoWidgetLines(
55
54
  }
56
55
  }
57
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
+
58
63
  return lines;
59
64
  }
60
65
 
61
- /** Alias kept for callers that prefer a builder-style name. */
62
- export const buildTodoWidgetLines = renderTodoWidgetLines;
63
-
64
- function boundedMaxVisible(value: number | undefined): number {
65
- if (!Number.isFinite(value)) return 5;
66
- return Math.max(1, Math.floor(value!));
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;
67
71
  }
68
72
 
69
- function summary(phases: TodoPhase[]): string {
70
- const tasks = phases.flatMap(phase => phase.tasks);
71
- const active = tasks.filter(isActive).length;
72
- const pending = tasks.filter(task => task.status === "pending").length;
73
- const completed = tasks.filter(task => task.status === "completed").length;
74
- const cancelled = tasks.filter(task => task.status === "cancelled").length;
75
- return [
76
- "Todos",
77
- ...(active ? [`${active} active`] : []),
78
- ...(pending ? [`${pending} pending`] : []),
79
- ...(completed ? [`${completed} completed`] : []),
80
- ...(cancelled ? [`${cancelled} cancelled`] : []),
81
- ].join(" · ");
73
+ function boundedMaxVisible(value: number | undefined): number {
74
+ if (value === undefined || !Number.isFinite(value)) return 5;
75
+ return Math.max(1, Math.floor(value));
82
76
  }
83
77
 
84
78
  function phaseTitle(phase: TodoPhase, phaseIndex: number, selected: boolean, theme: ThemeLike | undefined): string {
85
- const title = `${phaseIndex + 1}. ${phaseSummary(phase)}`;
86
- if (selected) return toolTitle(` ${title}`, theme);
87
- return theme?.fg ? theme.fg("dim", ` ${title}`) : ` ${title}`;
88
- }
89
-
90
- function phaseSummary(phase: TodoPhase): string {
91
- const active = phase.tasks.filter(isActive).length;
92
- const pending = phase.tasks.filter(task => task.status === "pending").length;
93
- const completed = phase.tasks.filter(task => task.status === "completed").length;
94
- const cancelled = phase.tasks.filter(task => task.status === "cancelled").length;
95
- return [
96
- phase.name,
97
- ...(active ? [`${active} active`] : []),
98
- ...(pending ? [`${pending} pending`] : []),
99
- ...(completed ? [`${completed} completed`] : []),
100
- ...(cancelled ? [`${cancelled} cancelled`] : []),
101
- ].join(" · ");
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}`;
102
88
  }
103
89
 
104
90
  function toolTitle(text: string, theme: ThemeLike | undefined): string {
@@ -106,39 +92,26 @@ function toolTitle(text: string, theme: ThemeLike | undefined): string {
106
92
  return theme?.fg ? theme.fg("toolTitle", bold) : bold;
107
93
  }
108
94
 
109
- function visibleTasks(tasks: Todo[], maxVisible: number): DisplayTask[] {
95
+ function visibleTasks(tasks: readonly Todo[], maxVisible: number): DisplayTask[] {
110
96
  const ordered = tasks
111
97
  .map((task, taskIndex) => ({ ...task, taskIndex }))
112
- .filter(task => !isTerminal(task))
113
- .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);
114
100
  const active = ordered.filter(isActive);
115
101
  return active.length > maxVisible ? active : ordered.slice(0, maxVisible);
116
102
  }
117
103
 
118
- function taskLine(task: Todo, theme: ThemeLike | undefined, fallbackGlyphs = false, activeMarker?: string): string {
119
- 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);
120
106
  const color = task.status === "in_progress" ? "text" : task.status === "completed" ? "success" : "dim";
121
- const name = (task.status === "completed" || task.status === "cancelled") && theme?.strikethrough
107
+ const name = isTerminalTodo(task) && theme?.strikethrough
122
108
  ? theme.strikethrough(task.name)
123
109
  : task.name;
124
- let line = ` ${marker} ${name}`;
125
- if (isActive(task) && theme?.bold) line = theme.bold(line);
110
+ const line = ` ${marker} ${name}`;
126
111
  return theme?.fg ? theme.fg(color, line) : line;
127
112
  }
128
113
 
129
- function taskPriority(task: Todo): number {
130
- if (isActive(task)) return 0;
131
- if (task.status === "pending") return 1;
132
- return 2;
133
- }
134
-
135
- function selectPhase(phases: TodoPhase[]): number {
136
- const active = phases.findIndex(phase => phase.tasks.some(isActive));
137
- if (active >= 0) return active;
138
- return phases.findIndex(phase => phase.tasks.some(task => task.status === "pending"));
139
- }
140
-
141
- function terminalTaskSummary(tasks: Todo[]): string | undefined {
114
+ function terminalTaskSummary(tasks: readonly Todo[]): string | undefined {
142
115
  const completed = tasks.filter(task => task.status === "completed").length;
143
116
  const cancelled = tasks.filter(task => task.status === "cancelled").length;
144
117
  const parts = [
@@ -152,10 +125,6 @@ function isActive(task: Todo): boolean {
152
125
  return task.status === "in_progress";
153
126
  }
154
127
 
155
- function isTerminal(task: Todo): boolean {
156
- return task.status === "completed" || task.status === "cancelled";
157
- }
158
-
159
128
  function fit(line: string, width: number): string {
160
129
  return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
161
130
  }