@narumitw/pi-todo 0.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/index.ts +264 -0
- package/dist/index.ts.map +7 -0
- package/package.json +58 -0
- package/src/index.ts +1 -0
- package/src/todo-widget.ts +354 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# ✅ pi-todo — Keep Multi-Step Work Visible
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-todo)
|
|
4
|
+
[](https://pi.dev)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Give coding agents a focused todo list for tracking multi-step work above Pi's editor.
|
|
8
|
+
|
|
9
|
+
The list follows the active session branch and disappears cleanly when no tracked work remains or the session ends.
|
|
10
|
+
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- Registers one `update_todo_list` tool with clear guidance for meaningful multi-step work.
|
|
14
|
+
- Keeps task text concise and action-oriented, with at most one task in progress.
|
|
15
|
+
- Shows a compact themed task list and completion count above the editor in TUI mode.
|
|
16
|
+
- Restores the latest valid list when Pi starts a session or navigates between branches.
|
|
17
|
+
- Restores the exact current list to model context only when compaction removes its latest visible successful tool update.
|
|
18
|
+
- Sanitizes terminal and bidirectional controls before rendering model-provided text.
|
|
19
|
+
- Works without settings, files, network access, or external services.
|
|
20
|
+
|
|
21
|
+
## 📦 Install
|
|
22
|
+
|
|
23
|
+
Install persistently from npm:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:@narumitw/pi-todo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Try from npm without installing permanently:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi -e npm:@narumitw/pi-todo
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Build and load this package directly from a repository checkout:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm --workspace @narumitw/pi-todo run build
|
|
39
|
+
pi --no-extensions -e ./packages/pi-todo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The package declares `dist/index.ts`, so an unbuilt local checkout must run the build before Pi loads the package directory.
|
|
43
|
+
|
|
44
|
+
Pi extensions run with the user's permissions, so install only trusted code.
|
|
45
|
+
|
|
46
|
+
## 🚀 Quick start
|
|
47
|
+
|
|
48
|
+
Ask Pi to perform work with multiple meaningful steps.
|
|
49
|
+
|
|
50
|
+
The agent can create a concise list through `update_todo_list`, mark one task `in_progress`, and revise the list when the plan changes.
|
|
51
|
+
|
|
52
|
+
Stable tool guidance tells the agent to update the list immediately after task status changes and to reconcile it before progress reports or the final response.
|
|
53
|
+
|
|
54
|
+
The agent sends the complete current list with every update and sends an empty list when no tracked work remains.
|
|
55
|
+
|
|
56
|
+
## 🛠️ Tools
|
|
57
|
+
|
|
58
|
+
### `update_todo_list`
|
|
59
|
+
|
|
60
|
+
Replace the complete current todo list for the active session.
|
|
61
|
+
|
|
62
|
+
Each item has this shape:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"text": "Run the focused tests",
|
|
67
|
+
"status": "in_progress"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Accepted statuses are `pending`, `in_progress`, and `completed`.
|
|
72
|
+
|
|
73
|
+
A list may contain up to 50 items, each item may contain up to 300 characters, and at most one item may be `in_progress`.
|
|
74
|
+
|
|
75
|
+
The tool result stores a versioned snapshot in the session branch so branch navigation can reconstruct the latest valid list.
|
|
76
|
+
|
|
77
|
+
Branch reconstruction also accepts valid results stored under the previous `todo_widget` name, but the extension only registers `update_todo_list` for new calls.
|
|
78
|
+
|
|
79
|
+
During ordinary turns, the model reads the complete list from the persisted `update_todo_list` assistant tool call, while its successful result confirms the active state and preserves append-only prompt history.
|
|
80
|
+
|
|
81
|
+
If compaction or branch context construction removes that matching call/result pair, the extension appends one hidden, non-persistent state-only fallback containing the current list as JSON data.
|
|
82
|
+
|
|
83
|
+
The fallback stays at the end of model context until a later valid todo tool call and successful result become visible, and it is omitted when the list is cleared.
|
|
84
|
+
|
|
85
|
+
In TUI mode, updates appear immediately in a widget above the editor.
|
|
86
|
+
|
|
87
|
+
The widget header shows completed and total task counts, followed by themed completed, in-progress, and pending rows.
|
|
88
|
+
Long task text wraps to the available terminal width with continuation lines aligned beneath the text.
|
|
89
|
+
|
|
90
|
+
In RPC, print, and JSON modes, the tool still returns structured details but does not create a visual widget.
|
|
91
|
+
|
|
92
|
+
## 🔒 Security and privacy
|
|
93
|
+
|
|
94
|
+
The extension does not read or write files, start processes, access credentials, or make network requests.
|
|
95
|
+
|
|
96
|
+
Task text is stored in Pi's normal session tool results and therefore follows the user's existing session persistence choices.
|
|
97
|
+
|
|
98
|
+
Terminal escape sequences, control characters, and bidirectional display controls are removed at the rendering boundary without changing the stored tool payload.
|
|
99
|
+
|
|
100
|
+
## 🚧 Limitations
|
|
101
|
+
|
|
102
|
+
- The visual widget uses a fixed position above the editor and is available only in TUI mode.
|
|
103
|
+
- The extension provides a model tool rather than a slash command or manual task editor.
|
|
104
|
+
- The extension reminds the model to update statuses but cannot infer task completion or force a tool call.
|
|
105
|
+
- Branch reconstruction uses only valid versioned `update_todo_list` or legacy `todo_widget` tool results on the active branch.
|
|
106
|
+
- The widget has no independent scrolling or height-based collapsing, so Pi may clip later rows when terminal height is constrained.
|
|
107
|
+
|
|
108
|
+
## 🗂️ Package layout
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
packages/pi-todo/
|
|
112
|
+
├── dist/
|
|
113
|
+
│ └── index.ts # Generated Jiti runtime entrypoint
|
|
114
|
+
├── scripts/
|
|
115
|
+
│ └── build-runtime.mjs # Deterministic runtime builder and validator
|
|
116
|
+
├── src/
|
|
117
|
+
│ ├── index.ts # Thin authoritative extension forwarder
|
|
118
|
+
│ └── todo-widget.ts # Tool, lifecycle, state reconstruction, and rendering
|
|
119
|
+
├── test/
|
|
120
|
+
│ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
|
|
121
|
+
│ └── todo-widget.test.ts # Extension behavior coverage
|
|
122
|
+
├── LICENSE
|
|
123
|
+
├── README.md
|
|
124
|
+
├── package.json
|
|
125
|
+
└── tsconfig.json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The package exposes its Pi extension through `package.json`:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"pi": {
|
|
133
|
+
"extensions": ["./dist/index.ts"]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 🔎 Keywords
|
|
139
|
+
|
|
140
|
+
Pi extension, coding agent, todo list, task progress, session widget, TypeScript Pi package.
|
|
141
|
+
|
|
142
|
+
## 📄 License
|
|
143
|
+
|
|
144
|
+
MIT.
|
|
145
|
+
|
|
146
|
+
See [`LICENSE`](./LICENSE).
|
package/dist/index.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
|
|
4
|
+
// src/todo-widget.ts
|
|
5
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
var TOOL_NAME = "update_todo_list";
|
|
9
|
+
var WIDGET_KEY = "todo";
|
|
10
|
+
var TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
|
|
11
|
+
var TODO_CONTEXT_VERSION = 1;
|
|
12
|
+
var TODO_DETAILS_VERSION = 1;
|
|
13
|
+
var MAX_TODO_ITEMS = 50;
|
|
14
|
+
var MAX_TODO_TEXT_LENGTH = 300;
|
|
15
|
+
var WIDGET_OPTIONS = { placement: "aboveEditor" };
|
|
16
|
+
var LEGACY_TOOL_NAME = "todo_widget";
|
|
17
|
+
var TODO_STATUSES = ["pending", "in_progress", "completed"];
|
|
18
|
+
var BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
19
|
+
var TodoParameters = Type.Object({
|
|
20
|
+
items: Type.Array(
|
|
21
|
+
Type.Object({
|
|
22
|
+
text: Type.String({
|
|
23
|
+
minLength: 1,
|
|
24
|
+
maxLength: MAX_TODO_TEXT_LENGTH,
|
|
25
|
+
description: "A concise, action-oriented task"
|
|
26
|
+
}),
|
|
27
|
+
status: StringEnum(TODO_STATUSES, {
|
|
28
|
+
description: "The task's current status"
|
|
29
|
+
})
|
|
30
|
+
}),
|
|
31
|
+
{
|
|
32
|
+
maxItems: MAX_TODO_ITEMS,
|
|
33
|
+
description: "The complete current todo list; send an empty list to clear it"
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
});
|
|
37
|
+
function todoWidgetExtension(pi) {
|
|
38
|
+
let activeSession;
|
|
39
|
+
let items = [];
|
|
40
|
+
const ownsSession = (ctx) => ctx.sessionManager === activeSession;
|
|
41
|
+
const publish = (ctx) => {
|
|
42
|
+
if (!ownsSession(ctx) || ctx.mode !== "tui") return;
|
|
43
|
+
if (items.length === 0) {
|
|
44
|
+
ctx.ui.setWidget(WIDGET_KEY, void 0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const snapshot = cloneItems(items);
|
|
48
|
+
ctx.ui.setWidget(
|
|
49
|
+
WIDGET_KEY,
|
|
50
|
+
(_tui, theme) => ({
|
|
51
|
+
render: (width) => renderTodoWidget(snapshot, theme, width),
|
|
52
|
+
invalidate: () => {
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
WIDGET_OPTIONS
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
pi.registerTool({
|
|
59
|
+
name: TOOL_NAME,
|
|
60
|
+
label: "Todo List",
|
|
61
|
+
description: "Replace the current session todo list with the complete supplied list. Call update_todo_list whenever actual task state changes; keep at most one item in_progress and send an empty list to clear it.",
|
|
62
|
+
promptSnippet: "Maintain the complete session todo list as multi-step work progresses",
|
|
63
|
+
promptGuidelines: [
|
|
64
|
+
"Use update_todo_list to track work with multiple meaningful steps; skip it for simple, single-step tasks.",
|
|
65
|
+
"Use update_todo_list to keep the list aligned with actual work: mark a task in_progress before starting it, mark it completed as soon as it finishes, and revise the list before continuing when the plan changes.",
|
|
66
|
+
"Before a progress report or final response, call update_todo_list to reconcile every item with actual work; do not report completion while the list is stale.",
|
|
67
|
+
"On every update_todo_list call, send the complete current list, keep at most one task in_progress, and send an empty list when no tracked work remains."
|
|
68
|
+
],
|
|
69
|
+
parameters: TodoParameters,
|
|
70
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
if (!ownsSession(ctx)) {
|
|
73
|
+
throw new Error("Cannot update the todo list because the session changed.");
|
|
74
|
+
}
|
|
75
|
+
validateItems(params.items);
|
|
76
|
+
items = cloneItems(params.items);
|
|
77
|
+
publish(ctx);
|
|
78
|
+
const details = {
|
|
79
|
+
version: TODO_DETAILS_VERSION,
|
|
80
|
+
items: cloneItems(items)
|
|
81
|
+
};
|
|
82
|
+
if (items.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: "Todo list cleared." }],
|
|
85
|
+
details
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const completed = items.filter((item) => item.status === "completed").length;
|
|
89
|
+
const inProgress = items.some((item) => item.status === "in_progress");
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: `Todo list updated: ${completed} of ${items.length} complete${inProgress ? "; 1 in progress" : ""}.`
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
details
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
pi.on("session_start", (_event, ctx) => {
|
|
102
|
+
activeSession = ctx.sessionManager;
|
|
103
|
+
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
104
|
+
publish(ctx);
|
|
105
|
+
});
|
|
106
|
+
pi.on("context", (event, ctx) => {
|
|
107
|
+
if (!ownsSession(ctx)) return;
|
|
108
|
+
const messages = reconcileTodoContext(event.messages, items);
|
|
109
|
+
if (messages !== event.messages) return { messages };
|
|
110
|
+
});
|
|
111
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
112
|
+
if (!ownsSession(ctx)) return;
|
|
113
|
+
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
114
|
+
publish(ctx);
|
|
115
|
+
});
|
|
116
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
117
|
+
if (!ownsSession(ctx)) return;
|
|
118
|
+
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, void 0);
|
|
119
|
+
items = [];
|
|
120
|
+
activeSession = void 0;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function renderTodoWidget(items, theme, width) {
|
|
124
|
+
const completed = items.filter((item) => item.status === "completed").length;
|
|
125
|
+
const divider = theme.fg("borderMuted", "\u2500".repeat(Math.max(0, width)));
|
|
126
|
+
const lines = [divider, theme.fg("muted", `Todo \xB7 ${completed}/${items.length} complete`)];
|
|
127
|
+
const renderWidth = Math.max(0, width);
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
const text = sanitizeTodoText(item.text);
|
|
130
|
+
let prefix;
|
|
131
|
+
let styledText;
|
|
132
|
+
switch (item.status) {
|
|
133
|
+
case "completed":
|
|
134
|
+
prefix = theme.fg("success", "\u2713 ");
|
|
135
|
+
styledText = theme.fg("muted", theme.strikethrough(text));
|
|
136
|
+
break;
|
|
137
|
+
case "in_progress":
|
|
138
|
+
prefix = theme.fg("accent", "\u25B6 ");
|
|
139
|
+
styledText = theme.fg("accent", theme.bold(text));
|
|
140
|
+
break;
|
|
141
|
+
case "pending":
|
|
142
|
+
prefix = theme.fg("dim", "\u25CB ");
|
|
143
|
+
styledText = theme.fg("text", text);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (renderWidth <= 2) {
|
|
147
|
+
lines.push(prefix);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const wrappedText = wrapTextWithAnsi(styledText, renderWidth - 2);
|
|
151
|
+
lines.push(...wrappedText.map((line, index) => `${index === 0 ? prefix : " "}${line}`));
|
|
152
|
+
}
|
|
153
|
+
return lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
154
|
+
}
|
|
155
|
+
function reconcileTodoContext(messages, items) {
|
|
156
|
+
const existing = messages.filter(isTodoContextMessage);
|
|
157
|
+
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
158
|
+
const content = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
|
|
159
|
+
if (existing.length === 1 && messages.at(-1) === existing[0] && existing[0]?.content === content) {
|
|
160
|
+
return messages;
|
|
161
|
+
}
|
|
162
|
+
if (existing.length === 0 && content === void 0) return messages;
|
|
163
|
+
if (content === void 0) return withoutExisting;
|
|
164
|
+
return [
|
|
165
|
+
...withoutExisting,
|
|
166
|
+
{
|
|
167
|
+
role: "custom",
|
|
168
|
+
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
169
|
+
content,
|
|
170
|
+
display: false,
|
|
171
|
+
details: { version: TODO_CONTEXT_VERSION },
|
|
172
|
+
timestamp: 0
|
|
173
|
+
}
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
function sanitizeTodoText(value) {
|
|
177
|
+
let text = "";
|
|
178
|
+
for (const character of stripTerminalSequences(value).replace(BIDI_CONTROLS, "")) {
|
|
179
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
180
|
+
const isControl = codePoint <= 31 || codePoint >= 127 && codePoint <= 159;
|
|
181
|
+
text += isControl ? " " : character;
|
|
182
|
+
}
|
|
183
|
+
return text.replace(/\s+/gu, " ").trim();
|
|
184
|
+
}
|
|
185
|
+
function todoContextContent(items) {
|
|
186
|
+
return `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]
|
|
187
|
+
Current todo list as JSON data:
|
|
188
|
+
${JSON.stringify(items)}`;
|
|
189
|
+
}
|
|
190
|
+
function hasModelVisibleTodoState(messages, items) {
|
|
191
|
+
const currentResults = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const message of messages) {
|
|
193
|
+
if (message.role === "toolResult" && !message.isError && (message.toolName === TOOL_NAME || message.toolName === LEGACY_TOOL_NAME) && isTodoDetails(message.details) && todoItemsEqual(message.details.items, items)) {
|
|
194
|
+
currentResults.set(message.toolCallId, message.toolName);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (currentResults.size === 0) return false;
|
|
198
|
+
return messages.some(
|
|
199
|
+
(message) => message.role === "assistant" && message.content.some(
|
|
200
|
+
(content) => content.type === "toolCall" && currentResults.get(content.id) === content.name && isTodoToolArguments(content.arguments) && todoItemsEqual(content.arguments.items, items)
|
|
201
|
+
)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function isTodoToolArguments(value) {
|
|
205
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
206
|
+
return isTodoItems(value.items);
|
|
207
|
+
}
|
|
208
|
+
function isTodoContextMessage(message) {
|
|
209
|
+
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
210
|
+
}
|
|
211
|
+
function validateItems(items) {
|
|
212
|
+
for (const [index, item] of items.entries()) {
|
|
213
|
+
if (item.text.trim().length === 0) {
|
|
214
|
+
throw new Error(`Todo item ${index + 1} must contain non-whitespace text.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const currentCount = items.filter((item) => item.status === "in_progress").length;
|
|
218
|
+
if (currentCount > 1) {
|
|
219
|
+
throw new Error("Todo list can contain at most one in_progress item.");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function reconstructItems(entries) {
|
|
223
|
+
let restored = [];
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.type !== "message") continue;
|
|
226
|
+
const message = entry.message;
|
|
227
|
+
if (message.role !== "toolResult" || message.toolName !== TOOL_NAME && message.toolName !== LEGACY_TOOL_NAME) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!isTodoDetails(message.details)) continue;
|
|
231
|
+
restored = cloneItems(message.details.items);
|
|
232
|
+
}
|
|
233
|
+
return restored;
|
|
234
|
+
}
|
|
235
|
+
function isTodoDetails(value) {
|
|
236
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
237
|
+
const record = value;
|
|
238
|
+
return record.version === TODO_DETAILS_VERSION && isTodoItems(record.items);
|
|
239
|
+
}
|
|
240
|
+
function isTodoItems(value) {
|
|
241
|
+
if (!Array.isArray(value) || value.length > MAX_TODO_ITEMS) return false;
|
|
242
|
+
let currentCount = 0;
|
|
243
|
+
for (const item of value) {
|
|
244
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return false;
|
|
245
|
+
const candidate = item;
|
|
246
|
+
if (typeof candidate.text !== "string" || candidate.text.length === 0 || candidate.text.length > MAX_TODO_TEXT_LENGTH || candidate.text.trim().length === 0 || !TODO_STATUSES.includes(candidate.status)) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
if (candidate.status === "in_progress") currentCount += 1;
|
|
250
|
+
}
|
|
251
|
+
return currentCount <= 1;
|
|
252
|
+
}
|
|
253
|
+
function todoItemsEqual(left, right) {
|
|
254
|
+
return left.length === right.length && left.every(
|
|
255
|
+
(item, index) => item.text === right[index]?.text && item.status === right[index]?.status
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
function cloneItems(items) {
|
|
259
|
+
return items.map((item) => ({ text: item.text, status: item.status }));
|
|
260
|
+
}
|
|
261
|
+
export {
|
|
262
|
+
todoWidgetExtension as default
|
|
263
|
+
};
|
|
264
|
+
//# sourceMappingURL=index.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/todo-widget.ts"],
|
|
4
|
+
"sourcesContent": ["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport type {\n\tContextEvent,\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionEntry,\n\tTheme,\n} from \"@earendil-works/pi-coding-agent\";\nimport { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from \"@earendil-works/pi-tui\";\nimport { Type } from \"typebox\";\n\nexport const TOOL_NAME = \"update_todo_list\";\nexport const WIDGET_KEY = \"todo\";\nexport const TODO_CONTEXT_MESSAGE_TYPE = \"todo-list-status\";\nexport const TODO_CONTEXT_VERSION = 1;\nexport const TODO_DETAILS_VERSION = 1;\nexport const MAX_TODO_ITEMS = 50;\nexport const MAX_TODO_TEXT_LENGTH = 300;\n\nconst WIDGET_OPTIONS = { placement: \"aboveEditor\" } as const;\nconst LEGACY_TOOL_NAME = \"todo_widget\";\nconst TODO_STATUSES = [\"pending\", \"in_progress\", \"completed\"] as const;\nconst BIDI_CONTROLS = /[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]/gu;\n\ntype TodoStatus = (typeof TODO_STATUSES)[number];\n\nexport interface TodoItem {\n\ttext: string;\n\tstatus: TodoStatus;\n}\n\nexport interface TodoDetails {\n\tversion: typeof TODO_DETAILS_VERSION;\n\titems: TodoItem[];\n}\n\nconst TodoParameters = Type.Object({\n\titems: Type.Array(\n\t\tType.Object({\n\t\t\ttext: Type.String({\n\t\t\t\tminLength: 1,\n\t\t\t\tmaxLength: MAX_TODO_TEXT_LENGTH,\n\t\t\t\tdescription: \"A concise, action-oriented task\",\n\t\t\t}),\n\t\t\tstatus: StringEnum(TODO_STATUSES, {\n\t\t\t\tdescription: \"The task's current status\",\n\t\t\t}),\n\t\t}),\n\t\t{\n\t\t\tmaxItems: MAX_TODO_ITEMS,\n\t\t\tdescription: \"The complete current todo list; send an empty list to clear it\",\n\t\t},\n\t),\n});\n\nexport default function todoWidgetExtension(pi: ExtensionAPI): void {\n\tlet activeSession: ExtensionContext[\"sessionManager\"] | undefined;\n\tlet items: TodoItem[] = [];\n\n\tconst ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;\n\n\tconst publish = (ctx: ExtensionContext): void => {\n\t\tif (!ownsSession(ctx) || ctx.mode !== \"tui\") return;\n\t\tif (items.length === 0) {\n\t\t\tctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\t\treturn;\n\t\t}\n\n\t\tconst snapshot = cloneItems(items);\n\t\tctx.ui.setWidget(\n\t\t\tWIDGET_KEY,\n\t\t\t(_tui, theme) => ({\n\t\t\t\trender: (width) => renderTodoWidget(snapshot, theme, width),\n\t\t\t\tinvalidate: () => {},\n\t\t\t}),\n\t\t\tWIDGET_OPTIONS,\n\t\t);\n\t};\n\n\tpi.registerTool({\n\t\tname: TOOL_NAME,\n\t\tlabel: \"Todo List\",\n\t\tdescription:\n\t\t\t\"Replace the current session todo list with the complete supplied list. Call update_todo_list whenever actual task state changes; keep at most one item in_progress and send an empty list to clear it.\",\n\t\tpromptSnippet: \"Maintain the complete session todo list as multi-step work progresses\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use update_todo_list to track work with multiple meaningful steps; skip it for simple, single-step tasks.\",\n\t\t\t\"Use update_todo_list to keep the list aligned with actual work: mark a task in_progress before starting it, mark it completed as soon as it finishes, and revise the list before continuing when the plan changes.\",\n\t\t\t\"Before a progress report or final response, call update_todo_list to reconcile every item with actual work; do not report completion while the list is stale.\",\n\t\t\t\"On every update_todo_list call, send the complete current list, keep at most one task in_progress, and send an empty list when no tracked work remains.\",\n\t\t],\n\t\tparameters: TodoParameters,\n\t\tasync execute(_toolCallId, params, signal, _onUpdate, ctx) {\n\t\t\tsignal?.throwIfAborted();\n\t\t\tif (!ownsSession(ctx)) {\n\t\t\t\tthrow new Error(\"Cannot update the todo list because the session changed.\");\n\t\t\t}\n\t\t\tvalidateItems(params.items);\n\n\t\t\titems = cloneItems(params.items);\n\t\t\tpublish(ctx);\n\n\t\t\tconst details: TodoDetails = {\n\t\t\t\tversion: TODO_DETAILS_VERSION,\n\t\t\t\titems: cloneItems(items),\n\t\t\t};\n\t\t\tif (items.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Todo list cleared.\" }],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\t\t\tconst inProgress = items.some((item) => item.status === \"in_progress\");\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\ttext: `Todo list updated: ${completed} of ${items.length} complete${inProgress ? \"; 1 in progress\" : \"\"}.`,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t});\n\n\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"context\", (event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tconst messages = reconcileTodoContext(event.messages, items);\n\t\tif (messages !== event.messages) return { messages };\n\t});\n\n\tpi.on(\"session_tree\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"session_shutdown\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tif (ctx.mode === \"tui\") ctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\titems = [];\n\t\tactiveSession = undefined;\n\t});\n}\n\nexport function renderTodoWidget(\n\titems: readonly TodoItem[],\n\ttheme: Theme,\n\twidth: number,\n): string[] {\n\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\tconst divider = theme.fg(\"borderMuted\", \"\u2500\".repeat(Math.max(0, width)));\n\tconst lines = [divider, theme.fg(\"muted\", `Todo \u00B7 ${completed}/${items.length} complete`)];\n\n\tconst renderWidth = Math.max(0, width);\n\tfor (const item of items) {\n\t\tconst text = sanitizeTodoText(item.text);\n\t\tlet prefix: string;\n\t\tlet styledText: string;\n\t\tswitch (item.status) {\n\t\t\tcase \"completed\":\n\t\t\t\tprefix = theme.fg(\"success\", \"\u2713 \");\n\t\t\t\tstyledText = theme.fg(\"muted\", theme.strikethrough(text));\n\t\t\t\tbreak;\n\t\t\tcase \"in_progress\":\n\t\t\t\tprefix = theme.fg(\"accent\", \"\u25B6 \");\n\t\t\t\tstyledText = theme.fg(\"accent\", theme.bold(text));\n\t\t\t\tbreak;\n\t\t\tcase \"pending\":\n\t\t\t\tprefix = theme.fg(\"dim\", \"\u25CB \");\n\t\t\t\tstyledText = theme.fg(\"text\", text);\n\t\t\t\tbreak;\n\t\t}\n\n\t\tif (renderWidth <= 2) {\n\t\t\tlines.push(prefix);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst wrappedText = wrapTextWithAnsi(styledText, renderWidth - 2);\n\t\tlines.push(...wrappedText.map((line, index) => `${index === 0 ? prefix : \" \"}${line}`));\n\t}\n\n\treturn lines.map((line) => truncateToWidth(line, renderWidth, \"\"));\n}\n\nexport function reconcileTodoContext(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n): ContextEvent[\"messages\"] {\n\tconst existing = messages.filter(isTodoContextMessage);\n\tconst withoutExisting = messages.filter((message) => !isTodoContextMessage(message));\n\tconst content =\n\t\titems.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)\n\t\t\t? todoContextContent(items)\n\t\t\t: undefined;\n\tif (\n\t\texisting.length === 1 &&\n\t\tmessages.at(-1) === existing[0] &&\n\t\texisting[0]?.content === content\n\t) {\n\t\treturn messages;\n\t}\n\tif (existing.length === 0 && content === undefined) return messages;\n\n\tif (content === undefined) return withoutExisting;\n\treturn [\n\t\t...withoutExisting,\n\t\t{\n\t\t\trole: \"custom\",\n\t\t\tcustomType: TODO_CONTEXT_MESSAGE_TYPE,\n\t\t\tcontent,\n\t\t\tdisplay: false,\n\t\t\tdetails: { version: TODO_CONTEXT_VERSION },\n\t\t\ttimestamp: 0,\n\t\t},\n\t];\n}\n\nexport function sanitizeTodoText(value: string): string {\n\tlet text = \"\";\n\tfor (const character of stripTerminalSequences(value).replace(BIDI_CONTROLS, \"\")) {\n\t\tconst codePoint = character.codePointAt(0) ?? 0;\n\t\tconst isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);\n\t\ttext += isControl ? \" \" : character;\n\t}\n\treturn text.replace(/\\s+/gu, \" \").trim();\n}\n\nfunction todoContextContent(items: readonly TodoItem[]): string {\n\treturn `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\nCurrent todo list as JSON data:\n${JSON.stringify(items)}`;\n}\n\nfunction hasModelVisibleTodoState(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n): boolean {\n\tconst currentResults = new Map<string, string>();\n\tfor (const message of messages) {\n\t\tif (\n\t\t\tmessage.role === \"toolResult\" &&\n\t\t\t!message.isError &&\n\t\t\t(message.toolName === TOOL_NAME || message.toolName === LEGACY_TOOL_NAME) &&\n\t\t\tisTodoDetails(message.details) &&\n\t\t\ttodoItemsEqual(message.details.items, items)\n\t\t) {\n\t\t\tcurrentResults.set(message.toolCallId, message.toolName);\n\t\t}\n\t}\n\tif (currentResults.size === 0) return false;\n\n\treturn messages.some(\n\t\t(message) =>\n\t\t\tmessage.role === \"assistant\" &&\n\t\t\tmessage.content.some(\n\t\t\t\t(content) =>\n\t\t\t\t\tcontent.type === \"toolCall\" &&\n\t\t\t\t\tcurrentResults.get(content.id) === content.name &&\n\t\t\t\t\tisTodoToolArguments(content.arguments) &&\n\t\t\t\t\ttodoItemsEqual(content.arguments.items, items),\n\t\t\t),\n\t);\n}\n\nfunction isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\treturn isTodoItems((value as Record<string, unknown>).items);\n}\n\nfunction isTodoContextMessage(\n\tmessage: ContextEvent[\"messages\"][number],\n): message is ContextEvent[\"messages\"][number] & { content: string } {\n\treturn message.role === \"custom\" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;\n}\n\nfunction validateItems(items: readonly TodoItem[]): void {\n\tfor (const [index, item] of items.entries()) {\n\t\tif (item.text.trim().length === 0) {\n\t\t\tthrow new Error(`Todo item ${index + 1} must contain non-whitespace text.`);\n\t\t}\n\t}\n\n\tconst currentCount = items.filter((item) => item.status === \"in_progress\").length;\n\tif (currentCount > 1) {\n\t\tthrow new Error(\"Todo list can contain at most one in_progress item.\");\n\t}\n}\n\nfunction reconstructItems(entries: readonly SessionEntry[]): TodoItem[] {\n\tlet restored: TodoItem[] = [];\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst message = entry.message;\n\t\tif (\n\t\t\tmessage.role !== \"toolResult\" ||\n\t\t\t(message.toolName !== TOOL_NAME && message.toolName !== LEGACY_TOOL_NAME)\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (!isTodoDetails(message.details)) continue;\n\t\trestored = cloneItems(message.details.items);\n\t}\n\treturn restored;\n}\n\nfunction isTodoDetails(value: unknown): value is TodoDetails {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst record = value as Record<string, unknown>;\n\treturn record.version === TODO_DETAILS_VERSION && isTodoItems(record.items);\n}\n\nfunction isTodoItems(value: unknown): value is TodoItem[] {\n\tif (!Array.isArray(value) || value.length > MAX_TODO_ITEMS) return false;\n\n\tlet currentCount = 0;\n\tfor (const item of value) {\n\t\tif (typeof item !== \"object\" || item === null || Array.isArray(item)) return false;\n\t\tconst candidate = item as Record<string, unknown>;\n\t\tif (\n\t\t\ttypeof candidate.text !== \"string\" ||\n\t\t\tcandidate.text.length === 0 ||\n\t\t\tcandidate.text.length > MAX_TODO_TEXT_LENGTH ||\n\t\t\tcandidate.text.trim().length === 0 ||\n\t\t\t!TODO_STATUSES.includes(candidate.status as TodoStatus)\n\t\t) {\n\t\t\treturn false;\n\t\t}\n\t\tif (candidate.status === \"in_progress\") currentCount += 1;\n\t}\n\treturn currentCount <= 1;\n}\n\nfunction todoItemsEqual(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {\n\treturn (\n\t\tleft.length === right.length &&\n\t\tleft.every(\n\t\t\t(item, index) => item.text === right[index]?.text && item.status === right[index]?.status,\n\t\t)\n\t);\n}\n\nfunction cloneItems(items: readonly TodoItem[]): TodoItem[] {\n\treturn items.map((item) => ({ text: item.text, status: item.status }));\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-todo",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Pi extension that gives coding agents a persistent session todo widget.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"todo",
|
|
13
|
+
"task-list",
|
|
14
|
+
"coding-agent"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./dist/index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"piExtension": {
|
|
28
|
+
"lifecycle": "stable"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node scripts/build-runtime.mjs",
|
|
32
|
+
"check": "npm run build && biome check . && npm run typecheck",
|
|
33
|
+
"format": "biome check --write .",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "2.5.10",
|
|
45
|
+
"@earendil-works/pi-ai": "0.84.3",
|
|
46
|
+
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
47
|
+
"@earendil-works/pi-tui": "0.84.3",
|
|
48
|
+
"@types/node": "26.2.0",
|
|
49
|
+
"esbuild": "0.28.2",
|
|
50
|
+
"typebox": "1.3.18",
|
|
51
|
+
"typescript": "7.0.2"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/narumiruna/pi-extensions.git",
|
|
56
|
+
"directory": "packages/pi-todo"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./todo-widget.js";
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ContextEvent,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
SessionEntry,
|
|
7
|
+
Theme,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
export const TOOL_NAME = "update_todo_list";
|
|
13
|
+
export const WIDGET_KEY = "todo";
|
|
14
|
+
export const TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
|
|
15
|
+
export const TODO_CONTEXT_VERSION = 1;
|
|
16
|
+
export const TODO_DETAILS_VERSION = 1;
|
|
17
|
+
export const MAX_TODO_ITEMS = 50;
|
|
18
|
+
export const MAX_TODO_TEXT_LENGTH = 300;
|
|
19
|
+
|
|
20
|
+
const WIDGET_OPTIONS = { placement: "aboveEditor" } as const;
|
|
21
|
+
const LEGACY_TOOL_NAME = "todo_widget";
|
|
22
|
+
const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
23
|
+
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
24
|
+
|
|
25
|
+
type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
26
|
+
|
|
27
|
+
export interface TodoItem {
|
|
28
|
+
text: string;
|
|
29
|
+
status: TodoStatus;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TodoDetails {
|
|
33
|
+
version: typeof TODO_DETAILS_VERSION;
|
|
34
|
+
items: TodoItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TodoParameters = Type.Object({
|
|
38
|
+
items: Type.Array(
|
|
39
|
+
Type.Object({
|
|
40
|
+
text: Type.String({
|
|
41
|
+
minLength: 1,
|
|
42
|
+
maxLength: MAX_TODO_TEXT_LENGTH,
|
|
43
|
+
description: "A concise, action-oriented task",
|
|
44
|
+
}),
|
|
45
|
+
status: StringEnum(TODO_STATUSES, {
|
|
46
|
+
description: "The task's current status",
|
|
47
|
+
}),
|
|
48
|
+
}),
|
|
49
|
+
{
|
|
50
|
+
maxItems: MAX_TODO_ITEMS,
|
|
51
|
+
description: "The complete current todo list; send an empty list to clear it",
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
57
|
+
let activeSession: ExtensionContext["sessionManager"] | undefined;
|
|
58
|
+
let items: TodoItem[] = [];
|
|
59
|
+
|
|
60
|
+
const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
|
|
61
|
+
|
|
62
|
+
const publish = (ctx: ExtensionContext): void => {
|
|
63
|
+
if (!ownsSession(ctx) || ctx.mode !== "tui") return;
|
|
64
|
+
if (items.length === 0) {
|
|
65
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const snapshot = cloneItems(items);
|
|
70
|
+
ctx.ui.setWidget(
|
|
71
|
+
WIDGET_KEY,
|
|
72
|
+
(_tui, theme) => ({
|
|
73
|
+
render: (width) => renderTodoWidget(snapshot, theme, width),
|
|
74
|
+
invalidate: () => {},
|
|
75
|
+
}),
|
|
76
|
+
WIDGET_OPTIONS,
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
pi.registerTool({
|
|
81
|
+
name: TOOL_NAME,
|
|
82
|
+
label: "Todo List",
|
|
83
|
+
description:
|
|
84
|
+
"Replace the current session todo list with the complete supplied list. Call update_todo_list whenever actual task state changes; keep at most one item in_progress and send an empty list to clear it.",
|
|
85
|
+
promptSnippet: "Maintain the complete session todo list as multi-step work progresses",
|
|
86
|
+
promptGuidelines: [
|
|
87
|
+
"Use update_todo_list to track work with multiple meaningful steps; skip it for simple, single-step tasks.",
|
|
88
|
+
"Use update_todo_list to keep the list aligned with actual work: mark a task in_progress before starting it, mark it completed as soon as it finishes, and revise the list before continuing when the plan changes.",
|
|
89
|
+
"Before a progress report or final response, call update_todo_list to reconcile every item with actual work; do not report completion while the list is stale.",
|
|
90
|
+
"On every update_todo_list call, send the complete current list, keep at most one task in_progress, and send an empty list when no tracked work remains.",
|
|
91
|
+
],
|
|
92
|
+
parameters: TodoParameters,
|
|
93
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
if (!ownsSession(ctx)) {
|
|
96
|
+
throw new Error("Cannot update the todo list because the session changed.");
|
|
97
|
+
}
|
|
98
|
+
validateItems(params.items);
|
|
99
|
+
|
|
100
|
+
items = cloneItems(params.items);
|
|
101
|
+
publish(ctx);
|
|
102
|
+
|
|
103
|
+
const details: TodoDetails = {
|
|
104
|
+
version: TODO_DETAILS_VERSION,
|
|
105
|
+
items: cloneItems(items),
|
|
106
|
+
};
|
|
107
|
+
if (items.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: "Todo list cleared." }],
|
|
110
|
+
details,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const completed = items.filter((item) => item.status === "completed").length;
|
|
115
|
+
const inProgress = items.some((item) => item.status === "in_progress");
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `Todo list updated: ${completed} of ${items.length} complete${inProgress ? "; 1 in progress" : ""}.`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
details,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("session_start", (_event, ctx) => {
|
|
129
|
+
activeSession = ctx.sessionManager;
|
|
130
|
+
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
131
|
+
publish(ctx);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.on("context", (event, ctx) => {
|
|
135
|
+
if (!ownsSession(ctx)) return;
|
|
136
|
+
const messages = reconcileTodoContext(event.messages, items);
|
|
137
|
+
if (messages !== event.messages) return { messages };
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
141
|
+
if (!ownsSession(ctx)) return;
|
|
142
|
+
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
143
|
+
publish(ctx);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
147
|
+
if (!ownsSession(ctx)) return;
|
|
148
|
+
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
149
|
+
items = [];
|
|
150
|
+
activeSession = undefined;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function renderTodoWidget(
|
|
155
|
+
items: readonly TodoItem[],
|
|
156
|
+
theme: Theme,
|
|
157
|
+
width: number,
|
|
158
|
+
): string[] {
|
|
159
|
+
const completed = items.filter((item) => item.status === "completed").length;
|
|
160
|
+
const divider = theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
|
|
161
|
+
const lines = [divider, theme.fg("muted", `Todo · ${completed}/${items.length} complete`)];
|
|
162
|
+
|
|
163
|
+
const renderWidth = Math.max(0, width);
|
|
164
|
+
for (const item of items) {
|
|
165
|
+
const text = sanitizeTodoText(item.text);
|
|
166
|
+
let prefix: string;
|
|
167
|
+
let styledText: string;
|
|
168
|
+
switch (item.status) {
|
|
169
|
+
case "completed":
|
|
170
|
+
prefix = theme.fg("success", "✓ ");
|
|
171
|
+
styledText = theme.fg("muted", theme.strikethrough(text));
|
|
172
|
+
break;
|
|
173
|
+
case "in_progress":
|
|
174
|
+
prefix = theme.fg("accent", "▶ ");
|
|
175
|
+
styledText = theme.fg("accent", theme.bold(text));
|
|
176
|
+
break;
|
|
177
|
+
case "pending":
|
|
178
|
+
prefix = theme.fg("dim", "○ ");
|
|
179
|
+
styledText = theme.fg("text", text);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (renderWidth <= 2) {
|
|
184
|
+
lines.push(prefix);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wrappedText = wrapTextWithAnsi(styledText, renderWidth - 2);
|
|
189
|
+
lines.push(...wrappedText.map((line, index) => `${index === 0 ? prefix : " "}${line}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function reconcileTodoContext(
|
|
196
|
+
messages: ContextEvent["messages"],
|
|
197
|
+
items: readonly TodoItem[],
|
|
198
|
+
): ContextEvent["messages"] {
|
|
199
|
+
const existing = messages.filter(isTodoContextMessage);
|
|
200
|
+
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
201
|
+
const content =
|
|
202
|
+
items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)
|
|
203
|
+
? todoContextContent(items)
|
|
204
|
+
: undefined;
|
|
205
|
+
if (
|
|
206
|
+
existing.length === 1 &&
|
|
207
|
+
messages.at(-1) === existing[0] &&
|
|
208
|
+
existing[0]?.content === content
|
|
209
|
+
) {
|
|
210
|
+
return messages;
|
|
211
|
+
}
|
|
212
|
+
if (existing.length === 0 && content === undefined) return messages;
|
|
213
|
+
|
|
214
|
+
if (content === undefined) return withoutExisting;
|
|
215
|
+
return [
|
|
216
|
+
...withoutExisting,
|
|
217
|
+
{
|
|
218
|
+
role: "custom",
|
|
219
|
+
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
220
|
+
content,
|
|
221
|
+
display: false,
|
|
222
|
+
details: { version: TODO_CONTEXT_VERSION },
|
|
223
|
+
timestamp: 0,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function sanitizeTodoText(value: string): string {
|
|
229
|
+
let text = "";
|
|
230
|
+
for (const character of stripTerminalSequences(value).replace(BIDI_CONTROLS, "")) {
|
|
231
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
232
|
+
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
233
|
+
text += isControl ? " " : character;
|
|
234
|
+
}
|
|
235
|
+
return text.replace(/\s+/gu, " ").trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function todoContextContent(items: readonly TodoItem[]): string {
|
|
239
|
+
return `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]
|
|
240
|
+
Current todo list as JSON data:
|
|
241
|
+
${JSON.stringify(items)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function hasModelVisibleTodoState(
|
|
245
|
+
messages: ContextEvent["messages"],
|
|
246
|
+
items: readonly TodoItem[],
|
|
247
|
+
): boolean {
|
|
248
|
+
const currentResults = new Map<string, string>();
|
|
249
|
+
for (const message of messages) {
|
|
250
|
+
if (
|
|
251
|
+
message.role === "toolResult" &&
|
|
252
|
+
!message.isError &&
|
|
253
|
+
(message.toolName === TOOL_NAME || message.toolName === LEGACY_TOOL_NAME) &&
|
|
254
|
+
isTodoDetails(message.details) &&
|
|
255
|
+
todoItemsEqual(message.details.items, items)
|
|
256
|
+
) {
|
|
257
|
+
currentResults.set(message.toolCallId, message.toolName);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (currentResults.size === 0) return false;
|
|
261
|
+
|
|
262
|
+
return messages.some(
|
|
263
|
+
(message) =>
|
|
264
|
+
message.role === "assistant" &&
|
|
265
|
+
message.content.some(
|
|
266
|
+
(content) =>
|
|
267
|
+
content.type === "toolCall" &&
|
|
268
|
+
currentResults.get(content.id) === content.name &&
|
|
269
|
+
isTodoToolArguments(content.arguments) &&
|
|
270
|
+
todoItemsEqual(content.arguments.items, items),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {
|
|
276
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
277
|
+
return isTodoItems((value as Record<string, unknown>).items);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isTodoContextMessage(
|
|
281
|
+
message: ContextEvent["messages"][number],
|
|
282
|
+
): message is ContextEvent["messages"][number] & { content: string } {
|
|
283
|
+
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validateItems(items: readonly TodoItem[]): void {
|
|
287
|
+
for (const [index, item] of items.entries()) {
|
|
288
|
+
if (item.text.trim().length === 0) {
|
|
289
|
+
throw new Error(`Todo item ${index + 1} must contain non-whitespace text.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const currentCount = items.filter((item) => item.status === "in_progress").length;
|
|
294
|
+
if (currentCount > 1) {
|
|
295
|
+
throw new Error("Todo list can contain at most one in_progress item.");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function reconstructItems(entries: readonly SessionEntry[]): TodoItem[] {
|
|
300
|
+
let restored: TodoItem[] = [];
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
if (entry.type !== "message") continue;
|
|
303
|
+
const message = entry.message;
|
|
304
|
+
if (
|
|
305
|
+
message.role !== "toolResult" ||
|
|
306
|
+
(message.toolName !== TOOL_NAME && message.toolName !== LEGACY_TOOL_NAME)
|
|
307
|
+
) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (!isTodoDetails(message.details)) continue;
|
|
311
|
+
restored = cloneItems(message.details.items);
|
|
312
|
+
}
|
|
313
|
+
return restored;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isTodoDetails(value: unknown): value is TodoDetails {
|
|
317
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
318
|
+
const record = value as Record<string, unknown>;
|
|
319
|
+
return record.version === TODO_DETAILS_VERSION && isTodoItems(record.items);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isTodoItems(value: unknown): value is TodoItem[] {
|
|
323
|
+
if (!Array.isArray(value) || value.length > MAX_TODO_ITEMS) return false;
|
|
324
|
+
|
|
325
|
+
let currentCount = 0;
|
|
326
|
+
for (const item of value) {
|
|
327
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return false;
|
|
328
|
+
const candidate = item as Record<string, unknown>;
|
|
329
|
+
if (
|
|
330
|
+
typeof candidate.text !== "string" ||
|
|
331
|
+
candidate.text.length === 0 ||
|
|
332
|
+
candidate.text.length > MAX_TODO_TEXT_LENGTH ||
|
|
333
|
+
candidate.text.trim().length === 0 ||
|
|
334
|
+
!TODO_STATUSES.includes(candidate.status as TodoStatus)
|
|
335
|
+
) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (candidate.status === "in_progress") currentCount += 1;
|
|
339
|
+
}
|
|
340
|
+
return currentCount <= 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function todoItemsEqual(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {
|
|
344
|
+
return (
|
|
345
|
+
left.length === right.length &&
|
|
346
|
+
left.every(
|
|
347
|
+
(item, index) => item.text === right[index]?.text && item.status === right[index]?.status,
|
|
348
|
+
)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function cloneItems(items: readonly TodoItem[]): TodoItem[] {
|
|
353
|
+
return items.map((item) => ({ text: item.text, status: item.status }));
|
|
354
|
+
}
|