@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@ian-pascoe/pi-todo` gives Pi agents a minimal, session-native **Todo List** without imposing a planning workflow.
4
4
 
5
- Requires Node `>=22.19.0` and Pi `>=0.99.0`.
5
+ Requires Node `>=22.19.0` and Pi `>=1.1.0`.
6
6
 
7
7
  ## Install
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-todo",
3
- "version": "0.5.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": ">=0.99.0",
37
- "@earendil-works/pi-coding-agent": ">=0.99.0",
38
- "@earendil-works/pi-tui": ">=0.99.0",
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": {
@@ -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, Text, truncateToWidth } from "@earendil-works/pi-tui";
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
- const active = this.tasks.filter((task) => task.status === "active").length;
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
- let text = theme.fg("toolTitle", theme.bold("todo")) + theme.fg("muted", ` ${params.action}`);
245
- if (params.id !== undefined) text += theme.fg("accent", ` #${params.id}`);
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
+ }