@fyeeme/pi-todo 1.0.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/render.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * pi-todo — transcript rendering.
3
+ *
4
+ * Ported from oh-my-pi `packages/coding-agent/src/tools/todo.ts` (renderer
5
+ * section), adapted to pi's split renderCall/renderResult slots: omp merges
6
+ * call+result into one framed block (mergeCallAndResult), while pi renders the
7
+ * call line and result lines separately. Strings, glyphs, colors, roman
8
+ * numerals, per-phase progress, touched-phase collapsing, and the collapsed
9
+ * walking viewport all match omp:
10
+ * - call: ⏳(muted) Todo · <op> <task> · <phase> · N items (dim meta)
11
+ * - result: ☑(accent) Todo · N tasks + per-phase tree
12
+ * - tasks: ☑ success strikethrough / ☐ accent in-progress / ☐ error
13
+ * strikethrough abandoned / ☐ warning blocked (note) / ☐ dim pending
14
+ * - tree: ├─ / └─ dim branch glyphs with the muted trailing summary
15
+ * Dropped (host-internal, no pi counterpart): strike animations and spinner
16
+ * frames (final strike state renders), subagent-match lighting (the
17
+ * description provider stays empty outside omp), framed block chrome.
18
+ */
19
+
20
+ import { Text, type Component } from "@earendil-works/pi-tui";
21
+ import type { Theme } from "@earendil-works/pi-coding-agent";
22
+ import type { ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
23
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
24
+ import {
25
+ type TodoCompletionTransition,
26
+ type TodoItem,
27
+ formatMoreItems,
28
+ phaseRomanNumeral,
29
+ phasesToMarkdown,
30
+ selectCollapsedTodos,
31
+ type TodoPhase,
32
+ type TodoToolDetails,
33
+ } from "./state.ts";
34
+
35
+ /** Display cap: keeps transcript lines width-safe without knowing the terminal width. */
36
+ const MAX_LINE = 100;
37
+
38
+ /** omp PREVIEW_LIMITS.COLLAPSED_ITEMS. */
39
+ const COLLAPSED_ITEMS = 8;
40
+
41
+ function clip(text: string): string {
42
+ const single = text.replace(/[\t\n\r]+/g, " ");
43
+ return single.length > MAX_LINE ? `${single.slice(0, MAX_LINE - 1)}…` : single;
44
+ }
45
+
46
+ // =============================================================================
47
+ // Render args normalization (omp normalizeTodoArg)
48
+ // =============================================================================
49
+
50
+ type TodoRenderOp = {
51
+ op?: string;
52
+ task?: string;
53
+ phase?: string;
54
+ items?: string[];
55
+ };
56
+
57
+ /** New single-op shape `{op,...}`; legacy `{ops:[...]}` still seen in old transcripts. */
58
+ type TodoRenderArgs = TodoRenderOp & {
59
+ ops?: TodoRenderOp[];
60
+ };
61
+
62
+ /**
63
+ * Normalize streaming/legacy render args to a flat op list. Accepts the new
64
+ * top-level `{op,...}` shape (returned as a one-element list), the legacy
65
+ * `{ops:[...]}` batch from old transcripts, and partially-parsed streaming
66
+ * deltas without crashing.
67
+ */
68
+ function normalizeTodoArg(args: TodoRenderArgs | undefined): TodoRenderOp[] {
69
+ if (!args || typeof args !== "object") return [];
70
+ if (Array.isArray(args.ops)) {
71
+ return args.ops.filter((entry): entry is TodoRenderOp => !!entry && typeof entry === "object");
72
+ }
73
+ return typeof args.op === "string" ? [args] : [];
74
+ }
75
+
76
+ // =============================================================================
77
+ // Phase headers (omp formatPhaseDisplayName / formatPhaseProgress)
78
+ // =============================================================================
79
+
80
+ function formatPhaseDisplayName(name: string, oneBasedIndex: number): string {
81
+ return `${phaseRomanNumeral(oneBasedIndex)}. ${clip(name)}`;
82
+ }
83
+
84
+ /** Dim `closed/total` suffix — counts completed + abandoned (omp isClosedTodo). */
85
+ function formatPhaseProgress(phase: TodoPhase, theme: Theme): string {
86
+ const done = phase.tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
87
+ return theme.fg("dim", ` ${done}/${phase.tasks.length}`);
88
+ }
89
+
90
+ /** One-line summary for a collapsed (untouched) phase: dim header + progress. */
91
+ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, theme: Theme): string {
92
+ const name = theme.fg("dim", theme.bold(formatPhaseDisplayName(phase.name, oneBasedIndex)));
93
+ return `${name}${formatPhaseProgress(phase, theme)}`;
94
+ }
95
+
96
+ // =============================================================================
97
+ // Task lines (omp formatTodoLine, final strike state)
98
+ // =============================================================================
99
+
100
+ /** omp theme.checkbox unicode set (checkbox.checked / checkbox.unchecked). */
101
+ const CHECKBOX = { checked: "☑", unchecked: "☐" } as const;
102
+
103
+ function formatTodoLine(item: TodoItem, theme: Theme, matched = false): string {
104
+ const label = clip(item.content);
105
+ const box = CHECKBOX.unchecked;
106
+ switch (item.status) {
107
+ case "completed":
108
+ return theme.fg("success", `${CHECKBOX.checked} ${theme.strikethrough(label)}`);
109
+ case "in_progress":
110
+ return theme.fg("accent", `${box} ${label}`);
111
+ case "abandoned":
112
+ return theme.fg("error", `${box} ${theme.strikethrough(label)}`);
113
+ case "blocked": {
114
+ const note = item.blocker ? `blocked: ${clip(item.blocker)}` : "blocked";
115
+ return theme.fg("warning", `${box} ${label} (${note})`);
116
+ }
117
+ default:
118
+ // A pending todo lit by a live subagent match renders accent (omp #5873);
119
+ // the subagent description provider is host-internal, so pending stays dim.
120
+ return theme.fg(matched ? "accent" : "dim", `${box} ${label}`);
121
+ }
122
+ }
123
+
124
+ // =============================================================================
125
+ // Tree lines (omp renderTreeList trailingSummary branch)
126
+ // =============================================================================
127
+
128
+ const TREE_BRANCH = "├─";
129
+ const TREE_LAST = "└─";
130
+
131
+ interface TreeLineOptions {
132
+ items: TodoItem[];
133
+ /** Trailing muted summary row; empty string renders none (omp contract). */
134
+ trailingSummary?: string;
135
+ renderItem: (item: TodoItem) => string;
136
+ theme: Theme;
137
+ }
138
+
139
+ function renderTreeLines({ items, trailingSummary, renderItem, theme }: TreeLineOptions): string[] {
140
+ const summary = trailingSummary;
141
+ const lines: string[] = [];
142
+ for (let i = 0; i < items.length; i++) {
143
+ const rendered = renderItem(items[i]);
144
+ if (!rendered) continue;
145
+ const isLast = summary === "" && i === items.length - 1;
146
+ const prefix = `${theme.fg("dim", isLast ? TREE_LAST : TREE_BRANCH)} `;
147
+ lines.push(`${prefix}${rendered}`);
148
+ }
149
+ if (summary !== undefined && summary !== "") {
150
+ lines.push(`${theme.fg("dim", TREE_LAST)} ${theme.fg("muted", summary)}`);
151
+ }
152
+ return lines;
153
+ }
154
+
155
+ // =============================================================================
156
+ // Touched-phase diffing (omp computeTouchedPhases)
157
+ // =============================================================================
158
+
159
+ /**
160
+ * Phases the latest update touched, plus the active (in_progress) phase.
161
+ * Returns `null` when there is no usable signal, meaning "render every phase
162
+ * fully" — this preserves the legacy view and the manual-expand path.
163
+ */
164
+ function computeTouchedPhases(
165
+ args: TodoRenderArgs | undefined,
166
+ phases: TodoPhase[],
167
+ completedTasks: TodoCompletionTransition[],
168
+ ): Set<string> | null {
169
+ const touched = new Set<string>();
170
+ // The phase holding the in_progress task is where attention sits after the
171
+ // auto-promotion that follows every completion.
172
+ for (const phase of phases) {
173
+ if (phase.tasks.some(task => task.status === "in_progress")) touched.add(phase.name);
174
+ }
175
+ // Phases with a task that just transitioned to completed in this update.
176
+ for (const transition of completedTasks) touched.add(transition.phase);
177
+ // Phases explicitly named by the ops that ran. `init` replaces the whole
178
+ // list, so the entire plan is fresh and every phase counts as touched.
179
+ const ops = normalizeTodoArg(args);
180
+ for (const op of ops) {
181
+ if (!op || typeof op !== "object") continue;
182
+ if (op.op === "init") {
183
+ for (const phase of phases) touched.add(phase.name);
184
+ break;
185
+ }
186
+ if (typeof op.phase === "string" && op.phase && phases.some(phase => phase.name === op.phase)) {
187
+ touched.add(op.phase);
188
+ }
189
+ if (typeof op.task === "string" && op.task) {
190
+ const located = phases.find(phase => phase.tasks.some(task => task.content === op.task));
191
+ if (located) touched.add(located.name);
192
+ }
193
+ }
194
+ return touched.size > 0 ? touched : null;
195
+ }
196
+
197
+ // =============================================================================
198
+ // Call / result renderers
199
+ // =============================================================================
200
+
201
+ export interface TodoRenderArgsPublic {
202
+ op?: string;
203
+ task?: string;
204
+ phase?: string;
205
+ }
206
+
207
+ export function renderTodoCall(args: TodoRenderArgsPublic, theme: Theme): Component {
208
+ // omp renderCall: renderStatusLine({icon:"pending", title:"Todo", meta})
209
+ // with one meta entry per op: "<op> <task> <phase> N items".
210
+ const opsList = normalizeTodoArg(args as TodoRenderArgs);
211
+ const ops =
212
+ opsList.length === 0
213
+ ? ["update"]
214
+ : opsList.map(e => {
215
+ const parts = [clip(e.op ?? "update")];
216
+ if (e.task) parts.push(clip(e.task));
217
+ if (e.phase) parts.push(clip(e.phase));
218
+ if (Array.isArray(e.items) && e.items.length) {
219
+ parts.push(`${e.items.length} item${e.items.length === 1 ? "" : "s"}`);
220
+ }
221
+ return parts.join(" ");
222
+ });
223
+ const line =
224
+ `${theme.fg("muted", "⏳")} ${theme.fg("accent", "Todo")}` +
225
+ (ops.length > 0 ? ` ${theme.fg("dim", `· ${ops.join(" · ")}`)}` : "");
226
+ return new Text(line, 0, 0);
227
+ }
228
+
229
+ export function renderTodoResult(
230
+ result: AgentToolResult<TodoToolDetails>,
231
+ options: ToolRenderResultOptions,
232
+ theme: Theme,
233
+ args?: TodoRenderArgsPublic,
234
+ ): Component {
235
+ // Errors never reach this renderer: pi tools throw on failure and the host
236
+ // shell renders the thrown message in the standard error block.
237
+ const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
238
+ const completedTasks = result.details?.completedTasks ?? [];
239
+ const allTasks = phases.flatMap(phase => phase.tasks);
240
+ // omp header: tool.todo glyph (accent) + title + "N tasks" dim meta.
241
+ const header = `${theme.fg("accent", "☑")} ${theme.fg("accent", "Todo")} ${theme.fg("dim", `· ${allTasks.length} tasks`)}`;
242
+
243
+ if (allTasks.length === 0) {
244
+ // omp: provider fallback text on one dim line under the header.
245
+ const fallback = clip(result.content?.find(content => content.type === "text")?.text ?? "No todos");
246
+ return new Text(`${header}\n ${theme.fg("dim", fallback)}`, 0, 0);
247
+ }
248
+
249
+ const bodyLines: string[] = [];
250
+ const multiPhase = phases.length > 1;
251
+ const indent = multiPhase ? " " : "";
252
+ // Collapse phases this update didn't touch down to a one-line summary so a
253
+ // single task flip doesn't redraw every phase's full task list. The manual
254
+ // expand toggle (and the no-signal fallback) still shows all.
255
+ const touched = options.expanded || !multiPhase ? null : computeTouchedPhases(args as TodoRenderArgs, phases, completedTasks);
256
+ // Subagent description matching is host-internal; pending renders dim.
257
+ const isMatched = (_task: TodoItem): boolean => false;
258
+
259
+ for (let p = 0; p < phases.length; p++) {
260
+ const phase = phases[p];
261
+ if (touched && !touched.has(phase.name)) {
262
+ bodyLines.push(formatPhaseSummary(phase, p + 1, theme));
263
+ continue;
264
+ }
265
+ if (multiPhase) {
266
+ // Progress belongs on the expanded header too: the collapsed viewport
267
+ // below hides closed rows, so without it the active phase would be the
268
+ // one phase with no visible completion signal at all.
269
+ const name = theme.fg("accent", theme.bold(formatPhaseDisplayName(phase.name, p + 1)));
270
+ bodyLines.push(`${name}${formatPhaseProgress(phase, theme)}`);
271
+ }
272
+ // Collapsed: walking viewport — the last closed task leads, then active
273
+ // work, then following pending tasks (omp #5873). Expanded: every task.
274
+ const treeLines = options.expanded
275
+ ? renderTreeLines({ items: phase.tasks, renderItem: todo => formatTodoLine(todo, theme), theme })
276
+ : (() => {
277
+ const selection = selectCollapsedTodos(phase.tasks, isMatched, COLLAPSED_ITEMS);
278
+ return renderTreeLines({
279
+ items: selection.items,
280
+ trailingSummary: selection.summary,
281
+ renderItem: todo => formatTodoLine(todo, theme, isMatched(todo)),
282
+ theme,
283
+ });
284
+ })();
285
+ for (const line of treeLines) {
286
+ bodyLines.push(`${indent}${line}`);
287
+ }
288
+ }
289
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
290
+
291
+ return new Text([header, ...bodyLines].join("\n"), 0, 0);
292
+ }
293
+
294
+ // Re-exported for consumers that compose the pieces (tests).
295
+ export { formatMoreItems, phasesToMarkdown };
package/src/restore.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * pi-todo — session persistence helpers.
3
+ *
4
+ * omp persisted todo state through the host session (tool result details +
5
+ * user_todo_edit entries). The pi edition persists an explicit full snapshot
6
+ * entry after every successful mutation (`todo-phases`), so restore is a
7
+ * backward scan for the latest valid snapshot on the current branch — branch
8
+ * awareness comes for free because appendEntry chains off the current leaf.
9
+ */
10
+
11
+ import { clonePhases, isTodoPhaseSnapshot, type TodoPhase } from "./state.ts";
12
+
13
+ /** Session entry customType for todo snapshots (see pi.appendEntry). */
14
+ export const TODO_PHASES_ENTRY_TYPE = "todo-phases";
15
+
16
+ /** Session entry customType for stop-time reminders. */
17
+ export const TODO_REMINDER_ENTRY_TYPE = "todo-reminder";
18
+
19
+ /**
20
+ * Reconstruct todo state from session entries: the latest valid
21
+ * `todo-phases` snapshot reachable on the current branch wins; malformed
22
+ * entries are skipped so a corrupt entry can never break startup.
23
+ */
24
+ export function restorePhasesFromEntries(entries: readonly unknown[]): TodoPhase[] {
25
+ for (let i = entries.length - 1; i >= 0; i--) {
26
+ const entry = entries[i] as { type?: string; customType?: string; data?: unknown } | undefined;
27
+ if (!entry || entry.type !== "custom" || entry.customType !== TODO_PHASES_ENTRY_TYPE) continue;
28
+ if (isTodoPhaseSnapshot(entry.data)) {
29
+ return clonePhases(entry.data.phases);
30
+ }
31
+ }
32
+ return [];
33
+ }