@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/README.md +21 -82
- package/media/todo-plan.png +0 -0
- package/package.json +3 -2
- package/src/format.ts +64 -27
- package/src/persistence.ts +30 -59
- package/src/reminder-cadence.ts +59 -0
- package/src/reminder.ts +24 -0
- package/src/renderer.ts +11 -67
- package/src/schema.ts +17 -21
- package/src/settings.ts +85 -66
- package/src/state.ts +163 -69
- package/src/tool.ts +100 -34
- package/src/types.ts +46 -22
- package/src/visibility.ts +1 -5
- package/src/widget-component.ts +17 -19
- package/src/widget-layout.ts +43 -74
- package/src/widget.ts +73 -28
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 {
|
|
17
|
+
import { DEFAULT_TODO_SETTINGS, loadTodoSettings, type TodoSettings } from "./settings.js";
|
|
9
18
|
import { createTodoState, todoAddressKey, transitionTodoState } from "./state.js";
|
|
10
|
-
import
|
|
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,
|
|
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):
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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
|
|
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
|
|
175
|
+
"Maintain a phased task plan for complex work with 3+ distinct steps.",
|
|
111
176
|
"Actions:",
|
|
112
|
-
" set: Replace the entire plan
|
|
113
|
-
" add:
|
|
114
|
-
" transition:
|
|
115
|
-
" view: Return the plan
|
|
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
|
|
182
|
+
promptSnippet: "Track multi-step work in a phased task plan",
|
|
118
183
|
promptGuidelines: [
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 {
|
package/src/widget-component.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
22
|
+
private readonly options: TodoWidgetComponentOptions = {},
|
|
21
23
|
private readonly tui?: Pick<TUI, "requestRender">,
|
|
22
24
|
) {
|
|
23
|
-
if (tui && state.
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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) %
|
|
50
|
+
this.frameIndex = (this.frameIndex + 1) % PI_SPINNER_FRAMES.length;
|
|
53
51
|
this.tui?.requestRender();
|
|
54
52
|
this.scheduleNextFrame();
|
|
55
|
-
},
|
|
53
|
+
}, PI_SPINNER_INTERVAL_MS);
|
|
56
54
|
}
|
|
57
55
|
}
|
package/src/widget-layout.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
|
44
|
+
lines.push(fit(taskLine(task, theme, options.fallbackGlyphs), safeWidth));
|
|
46
45
|
}
|
|
47
|
-
const openTasks = phase.tasks.filter(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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
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 => !
|
|
113
|
-
.sort((left, right) =>
|
|
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
|
|
119
|
-
const marker =
|
|
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
|
|
107
|
+
const name = isTerminalTodo(task) && theme?.strikethrough
|
|
122
108
|
? theme.strikethrough(task.name)
|
|
123
109
|
: task.name;
|
|
124
|
-
|
|
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
|
|
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
|
}
|