@ian-pascoe/pi-todo 0.5.0 → 0.6.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 +1 -1
- package/package.json +7 -4
- package/src/pi-todo-extension.ts +8 -77
- package/src/todo-render.ts +153 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Minimal session-native Todo List for Pi agents.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,10 +32,13 @@
|
|
|
32
32
|
"access": "public",
|
|
33
33
|
"provenance": true
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ian-pascoe/pi-utils": "^0.5.0"
|
|
37
|
+
},
|
|
35
38
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-ai": ">=
|
|
37
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
38
|
-
"@earendil-works/pi-tui": ">=
|
|
39
|
+
"@earendil-works/pi-ai": ">=1.1.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
41
|
+
"@earendil-works/pi-tui": ">=1.1.0",
|
|
39
42
|
"typebox": "*"
|
|
40
43
|
},
|
|
41
44
|
"engines": {
|
package/src/pi-todo-extension.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { type Component
|
|
3
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import { noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
6
|
import { projectTodoContext, todoStateFromEntry } from "./todo-context.js";
|
|
6
7
|
import {
|
|
@@ -10,14 +11,13 @@ import {
|
|
|
10
11
|
TODO_ACTIONS,
|
|
11
12
|
TODO_STATUSES,
|
|
12
13
|
TodoToolOutputSchema,
|
|
13
|
-
todoStatusMarker,
|
|
14
14
|
type TodoActionInput,
|
|
15
15
|
type TodoStateSnapshot,
|
|
16
|
-
type TodoStatus,
|
|
17
16
|
type TodoTask,
|
|
18
17
|
TodoOperationError,
|
|
19
18
|
type TodoToolDetails,
|
|
20
19
|
} from "./todo-list.js";
|
|
20
|
+
import { renderTodoCall, renderTodoResult, renderTodoWidget } from "./todo-render.js";
|
|
21
21
|
import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
|
|
22
22
|
|
|
23
23
|
const TODO_STATE_ENTRY_TYPE = "pi-todo-state";
|
|
@@ -33,8 +33,6 @@ function assertTodoJournalReadable(context: ExtensionContext): void {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const TODO_WIDGET_ID = "pi-todo";
|
|
36
|
-
const TODO_WIDGET_STATUS_ORDER: readonly TodoStatus[] = ["active", "pending", "completed"];
|
|
37
|
-
const TODO_COLLAPSED_TASK_LIMIT = 5;
|
|
38
36
|
|
|
39
37
|
const TodoParameters = Type.Object({
|
|
40
38
|
action: StringEnum(TODO_ACTIONS),
|
|
@@ -113,19 +111,6 @@ function restoreTodoState(context: ExtensionContext): TodoStateSnapshot {
|
|
|
113
111
|
return createEmptyTodoState();
|
|
114
112
|
}
|
|
115
113
|
|
|
116
|
-
function renderTodoTaskLine(task: TodoTask, theme: Theme): string {
|
|
117
|
-
const markerColor =
|
|
118
|
-
task.status === "active" ? "accent" : task.status === "completed" ? "success" : "muted";
|
|
119
|
-
const title =
|
|
120
|
-
task.status === "active"
|
|
121
|
-
? theme.bold(task.title)
|
|
122
|
-
: task.status === "completed"
|
|
123
|
-
? theme.strikethrough(task.title)
|
|
124
|
-
: task.title;
|
|
125
|
-
const titleColor = task.status === "completed" ? "dim" : "text";
|
|
126
|
-
return `${theme.fg(markerColor, todoStatusMarker(task.status))} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(titleColor, title)}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
114
|
class TodoWidget implements Component {
|
|
130
115
|
constructor(
|
|
131
116
|
private readonly tasks: readonly TodoTask[],
|
|
@@ -135,25 +120,7 @@ class TodoWidget implements Component {
|
|
|
135
120
|
invalidate(): void {}
|
|
136
121
|
|
|
137
122
|
render(width: number): string[] {
|
|
138
|
-
|
|
139
|
-
const pending = this.tasks.filter((task) => task.status === "pending").length;
|
|
140
|
-
const completed = this.tasks.length - active - pending;
|
|
141
|
-
const header =
|
|
142
|
-
this.theme.fg("toolTitle", this.theme.bold("TODO")) +
|
|
143
|
-
this.theme.fg("muted", ` ${active} active · ${pending} pending · ${completed} completed`);
|
|
144
|
-
const orderedTasks = this.tasks.toSorted((left, right) => {
|
|
145
|
-
const statusDifference =
|
|
146
|
-
TODO_WIDGET_STATUS_ORDER.indexOf(left.status) -
|
|
147
|
-
TODO_WIDGET_STATUS_ORDER.indexOf(right.status);
|
|
148
|
-
return statusDifference === 0 ? left.id - right.id : statusDifference;
|
|
149
|
-
});
|
|
150
|
-
const lines = [header];
|
|
151
|
-
for (const task of orderedTasks.slice(0, TODO_COLLAPSED_TASK_LIMIT)) {
|
|
152
|
-
lines.push(renderTodoTaskLine(task, this.theme));
|
|
153
|
-
}
|
|
154
|
-
const remaining = orderedTasks.length - TODO_COLLAPSED_TASK_LIMIT;
|
|
155
|
-
if (remaining > 0) lines.push(this.theme.fg("dim", `… ${remaining} more`));
|
|
156
|
-
return lines.map((line) => truncateToWidth(line, width, "…"));
|
|
123
|
+
return renderTodoWidget(this.tasks, this.theme, width);
|
|
157
124
|
}
|
|
158
125
|
}
|
|
159
126
|
|
|
@@ -240,45 +207,9 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
240
207
|
structuredContent: structuredClone(result.details),
|
|
241
208
|
};
|
|
242
209
|
},
|
|
243
|
-
renderCall: (params, theme) =>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (params.action === "add" && params.title) text += theme.fg("dim", ` "${params.title}"`);
|
|
247
|
-
if (params.action === "add" && params.tasks) {
|
|
248
|
-
const count = params.tasks.length;
|
|
249
|
-
text += theme.fg("dim", ` ${formatTaskCount(count)}`);
|
|
250
|
-
}
|
|
251
|
-
if (params.action === "update" && params.updates) {
|
|
252
|
-
text += theme.fg("dim", ` ${formatTaskCount(params.updates.length)}`);
|
|
253
|
-
}
|
|
254
|
-
return new Text(text, 0, 0);
|
|
255
|
-
},
|
|
256
|
-
renderResult: (result, { expanded }, theme) => {
|
|
257
|
-
const text = result.content.find((item) => item.type === "text");
|
|
258
|
-
if (!result.details) {
|
|
259
|
-
return new Text(theme.fg("error", text?.text ?? "Todo operation failed"), 0, 0);
|
|
260
|
-
}
|
|
261
|
-
if ("tasks" in result.details) {
|
|
262
|
-
const { action, tasks } = result.details;
|
|
263
|
-
if (tasks.length === 0) return new Text(theme.fg("dim", "Todo List is empty"), 0, 0);
|
|
264
|
-
const visibleTasks = expanded ? tasks : tasks.slice(0, TODO_COLLAPSED_TASK_LIMIT);
|
|
265
|
-
const verb = action === "add" ? "Added " : action === "update" ? "Updated " : "";
|
|
266
|
-
const heading = `${verb}${formatTaskCount(tasks.length)}:`;
|
|
267
|
-
const lines = [theme.fg("muted", heading)];
|
|
268
|
-
for (const task of visibleTasks) {
|
|
269
|
-
lines.push(renderTodoTaskLine(task, theme));
|
|
270
|
-
if (expanded && task.description) {
|
|
271
|
-
lines.push(
|
|
272
|
-
...task.description.split("\n").map((line) => theme.fg("dim", ` ${line}`)),
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const remaining = tasks.length - visibleTasks.length;
|
|
277
|
-
if (remaining > 0) lines.push(theme.fg("dim", `… ${remaining} more`));
|
|
278
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
279
|
-
}
|
|
280
|
-
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text?.text ?? "Done"), 0, 0);
|
|
281
|
-
},
|
|
210
|
+
renderCall: (params, theme, context) => renderTodoCall(params, theme, context),
|
|
211
|
+
renderResult: (result, options, theme, context) =>
|
|
212
|
+
renderTodoResult(result, options, theme, context),
|
|
282
213
|
});
|
|
283
214
|
|
|
284
215
|
pi.registerCommand("todo", {
|
|
@@ -293,7 +224,7 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
293
224
|
return;
|
|
294
225
|
}
|
|
295
226
|
if (context.mode !== "tui") {
|
|
296
|
-
context.ui.notify("/todo clear requires interactive mode", "error");
|
|
227
|
+
context.ui.notify(noticeText("Todo", "/todo clear requires interactive mode"), "error");
|
|
297
228
|
return;
|
|
298
229
|
}
|
|
299
230
|
await context.waitForIdle();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
appendDurationFooter,
|
|
5
|
+
callDurationFooter,
|
|
6
|
+
COLLAPSED_LINES,
|
|
7
|
+
type DurationContext,
|
|
8
|
+
expandHint,
|
|
9
|
+
previewBody,
|
|
10
|
+
SEPARATOR,
|
|
11
|
+
toolHeader,
|
|
12
|
+
type UiTheme,
|
|
13
|
+
widgetLines,
|
|
14
|
+
} from "@ian-pascoe/pi-utils/ui";
|
|
15
|
+
import {
|
|
16
|
+
formatTaskCount,
|
|
17
|
+
type TodoActionInput,
|
|
18
|
+
type TodoTask,
|
|
19
|
+
type TodoToolDetails,
|
|
20
|
+
} from "./todo-list.js";
|
|
21
|
+
|
|
22
|
+
/** The theme methods the Todo renderers draw with. */
|
|
23
|
+
export type TodoRenderTheme = Pick<UiTheme, "fg" | "bold" | "strikethrough">;
|
|
24
|
+
|
|
25
|
+
const TODO_WIDGET_STATUS_ORDER: readonly TodoTask["status"][] = ["active", "pending", "completed"];
|
|
26
|
+
|
|
27
|
+
const CHECKBOX_FILL = { pending: " ", active: ">", completed: "x" } as const;
|
|
28
|
+
const CHECKBOX_COLOR = { pending: "muted", active: "accent", completed: "success" } as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One Task row. The `[ ]`/`[>]`/`[x]` checkbox is the documented exception to the no-marks rule
|
|
32
|
+
* for tool rows: it shows the Task's state, not the tool's outcome.
|
|
33
|
+
*/
|
|
34
|
+
export function renderTodoTaskLine(task: TodoTask, theme: TodoRenderTheme): string {
|
|
35
|
+
const box =
|
|
36
|
+
theme.fg("dim", "[") +
|
|
37
|
+
theme.fg(CHECKBOX_COLOR[task.status], CHECKBOX_FILL[task.status]) +
|
|
38
|
+
theme.fg("dim", "]");
|
|
39
|
+
const title =
|
|
40
|
+
task.status === "active"
|
|
41
|
+
? theme.bold(task.title)
|
|
42
|
+
: task.status === "completed"
|
|
43
|
+
? theme.strikethrough(task.title)
|
|
44
|
+
: task.title;
|
|
45
|
+
const titleColor = task.status === "completed" ? "dim" : "text";
|
|
46
|
+
return `${box} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(titleColor, title)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The Todo Widget lines: Active Tasks first, then pending, then completed, in at most 10 lines. */
|
|
50
|
+
export function renderTodoWidget(
|
|
51
|
+
tasks: readonly TodoTask[],
|
|
52
|
+
theme: TodoRenderTheme,
|
|
53
|
+
width: number,
|
|
54
|
+
): string[] {
|
|
55
|
+
const active = tasks.filter((task) => task.status === "active").length;
|
|
56
|
+
const pending = tasks.filter((task) => task.status === "pending").length;
|
|
57
|
+
const completed = tasks.length - active - pending;
|
|
58
|
+
const orderedTasks = tasks.toSorted((left, right) => {
|
|
59
|
+
const statusDifference =
|
|
60
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(left.status) -
|
|
61
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(right.status);
|
|
62
|
+
return statusDifference === 0 ? left.id - right.id : statusDifference;
|
|
63
|
+
});
|
|
64
|
+
return widgetLines(theme, {
|
|
65
|
+
title: "Todo",
|
|
66
|
+
counts: [`${active} active`, `${pending} pending`, `${completed} completed`].join(SEPARATOR),
|
|
67
|
+
rows: orderedTasks.map((task) => renderTodoTaskLine(task, theme)),
|
|
68
|
+
}).map((line) => truncateToWidth(line, width, "..."));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function callArguments(params: TodoActionInput): string | undefined {
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
if (params.id !== undefined) parts.push(`#${params.id}`);
|
|
74
|
+
if (params.action === "add" && params.title) parts.push(`"${params.title}"`);
|
|
75
|
+
if (params.action === "add" && params.tasks) parts.push(formatTaskCount(params.tasks.length));
|
|
76
|
+
if (params.action === "update" && params.updates) {
|
|
77
|
+
parts.push(formatTaskCount(params.updates.length));
|
|
78
|
+
}
|
|
79
|
+
return parts.length === 0 ? undefined : parts.join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `todo` call row: bold `todo`, the action in `accent`, arguments in `muted`, then `Elapsed`. */
|
|
83
|
+
export function renderTodoCall(
|
|
84
|
+
params: TodoActionInput,
|
|
85
|
+
theme: TodoRenderTheme,
|
|
86
|
+
context: DurationContext,
|
|
87
|
+
): Component {
|
|
88
|
+
const container = new Container();
|
|
89
|
+
container.addChild(
|
|
90
|
+
new Text(toolHeader(theme, "todo", params.action, callArguments(params)), 0, 0),
|
|
91
|
+
);
|
|
92
|
+
container.addChild(callDurationFooter(theme, context));
|
|
93
|
+
return container;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function taskListLines(
|
|
97
|
+
action: "list" | "add" | "update",
|
|
98
|
+
tasks: readonly TodoTask[],
|
|
99
|
+
theme: TodoRenderTheme,
|
|
100
|
+
expanded: boolean,
|
|
101
|
+
): string[] {
|
|
102
|
+
const verb = action === "add" ? "Added " : action === "update" ? "Updated " : "";
|
|
103
|
+
const lines = [theme.fg("muted", `${verb}${formatTaskCount(tasks.length)}:`)];
|
|
104
|
+
if (expanded) {
|
|
105
|
+
for (const task of tasks) {
|
|
106
|
+
lines.push(renderTodoTaskLine(task, theme));
|
|
107
|
+
if (task.description) {
|
|
108
|
+
lines.push(...task.description.split("\n").map((line) => theme.fg("dim", ` ${line}`)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
lines.push(
|
|
114
|
+
...tasks.slice(0, COLLAPSED_LINES.list).map((task) => renderTodoTaskLine(task, theme)),
|
|
115
|
+
);
|
|
116
|
+
const hidden = tasks.length - COLLAPSED_LINES.list;
|
|
117
|
+
if (hidden > 0) lines.push(expandHint(theme, hidden));
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `todo` result row. A Task list is the Nearest Built-in `ls` (20 rows collapsed, Pi's Expand
|
|
123
|
+
* Hint beyond that); a failure shows its text in `error`. Every result ends with `Took`.
|
|
124
|
+
*/
|
|
125
|
+
export function renderTodoResult(
|
|
126
|
+
result: AgentToolResult<TodoToolDetails | undefined>,
|
|
127
|
+
options: ToolRenderResultOptions,
|
|
128
|
+
theme: TodoRenderTheme,
|
|
129
|
+
context: DurationContext,
|
|
130
|
+
): Component {
|
|
131
|
+
const text = result.content.find((item) => item.type === "text")?.text;
|
|
132
|
+
let body: string[];
|
|
133
|
+
if (!result.details) {
|
|
134
|
+
body = previewBody(theme, (text ?? "Todo operation failed").split("\n"), {
|
|
135
|
+
limit: COLLAPSED_LINES.fallback,
|
|
136
|
+
expanded: options.expanded,
|
|
137
|
+
color: "error",
|
|
138
|
+
});
|
|
139
|
+
} else if ("tasks" in result.details && result.details.tasks.length > 0) {
|
|
140
|
+
body = taskListLines(result.details.action, result.details.tasks, theme, options.expanded);
|
|
141
|
+
} else {
|
|
142
|
+
const message = "tasks" in result.details ? "Todo List is empty" : (text ?? "Done");
|
|
143
|
+
body = previewBody(theme, message.split("\n"), {
|
|
144
|
+
limit: COLLAPSED_LINES.fallback,
|
|
145
|
+
expanded: options.expanded,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const container = new Container();
|
|
149
|
+
container.addChild(new Spacer(1));
|
|
150
|
+
container.addChild(new Text(body.join("\n"), 0, 0));
|
|
151
|
+
appendDurationFooter(container, theme, context, { isPartial: options.isPartial });
|
|
152
|
+
return container;
|
|
153
|
+
}
|