@narumitw/pi-todo 0.1.0 → 0.1.2
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 +8 -4
- package/dist/index.ts +81 -8
- package/dist/index.ts.map +2 -2
- package/package.json +1 -1
- package/src/todo-widget.ts +115 -15
package/README.md
CHANGED
|
@@ -78,9 +78,12 @@ Branch reconstruction also accepts valid results stored under the previous `todo
|
|
|
78
78
|
|
|
79
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
80
|
|
|
81
|
-
If compaction or branch
|
|
81
|
+
If leading compaction or branch summaries remove that matching call/result pair, the extension inserts one hidden, non-persistent state-only fallback immediately after those summaries.
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
That restored message remains fixed for the current leading-summary epoch, including after a later valid todo update or clear.
|
|
84
|
+
The extension stores branch-local boundary metadata in the session so reload and branch navigation retain the established prefix without making the hidden fallback itself persistent model context.
|
|
85
|
+
The later tool call and result supersede the restored state at the conversation tail without rewriting the earlier provider prefix.
|
|
86
|
+
An ordinary context without a leading summary does not synthesize a fallback, and a new summary epoch restores only the then-current list when needed.
|
|
84
87
|
|
|
85
88
|
In TUI mode, updates appear immediately in a widget above the editor.
|
|
86
89
|
|
|
@@ -117,8 +120,9 @@ packages/pi-todo/
|
|
|
117
120
|
│ ├── index.ts # Thin authoritative extension forwarder
|
|
118
121
|
│ └── todo-widget.ts # Tool, lifecycle, state reconstruction, and rendering
|
|
119
122
|
├── test/
|
|
120
|
-
│ ├── build-runtime.test.ts
|
|
121
|
-
│
|
|
123
|
+
│ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
|
|
124
|
+
│ ├── todo-cache-contract.test.ts # Normalized provider-prefix coverage
|
|
125
|
+
│ └── todo-widget.test.ts # Extension behavior coverage
|
|
122
126
|
├── LICENSE
|
|
123
127
|
├── README.md
|
|
124
128
|
├── package.json
|
package/dist/index.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
// src/todo-widget.ts
|
|
5
5
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import {
|
|
7
|
+
buildSessionContext
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
9
|
import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
10
|
import { Type } from "typebox";
|
|
8
11
|
var TOOL_NAME = "update_todo_list";
|
|
@@ -10,6 +13,8 @@ var WIDGET_KEY = "todo";
|
|
|
10
13
|
var TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
|
|
11
14
|
var TODO_CONTEXT_VERSION = 1;
|
|
12
15
|
var TODO_DETAILS_VERSION = 1;
|
|
16
|
+
var TODO_RESTORED_BOUNDARY_ENTRY_TYPE = "todo-restored-context-boundary";
|
|
17
|
+
var TODO_RESTORED_BOUNDARY_VERSION = 1;
|
|
13
18
|
var MAX_TODO_ITEMS = 50;
|
|
14
19
|
var MAX_TODO_TEXT_LENGTH = 300;
|
|
15
20
|
var WIDGET_OPTIONS = { placement: "aboveEditor" };
|
|
@@ -37,6 +42,7 @@ var TodoParameters = Type.Object({
|
|
|
37
42
|
function todoWidgetExtension(pi) {
|
|
38
43
|
let activeSession;
|
|
39
44
|
let items = [];
|
|
45
|
+
let restoredBoundary;
|
|
40
46
|
const ownsSession = (ctx) => ctx.sessionManager === activeSession;
|
|
41
47
|
const publish = (ctx) => {
|
|
42
48
|
if (!ownsSession(ctx) || ctx.mode !== "tui") return;
|
|
@@ -98,25 +104,43 @@ function todoWidgetExtension(pi) {
|
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
});
|
|
107
|
+
const restoreBranchState = (ctx) => {
|
|
108
|
+
const branch = ctx.sessionManager.getBranch();
|
|
109
|
+
items = reconstructItems(branch);
|
|
110
|
+
restoredBoundary = reconstructRestoredTodoBoundary(branch);
|
|
111
|
+
};
|
|
101
112
|
pi.on("session_start", (_event, ctx) => {
|
|
102
113
|
activeSession = ctx.sessionManager;
|
|
103
|
-
|
|
114
|
+
restoreBranchState(ctx);
|
|
104
115
|
publish(ctx);
|
|
105
116
|
});
|
|
106
117
|
pi.on("context", (event, ctx) => {
|
|
107
118
|
if (!ownsSession(ctx)) return;
|
|
108
|
-
const
|
|
119
|
+
const summaryEpoch = leadingSummaryEpoch(event.messages);
|
|
120
|
+
if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = void 0;
|
|
121
|
+
const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
|
|
122
|
+
if (restoredBoundary === void 0 && summaryEpoch) {
|
|
123
|
+
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
124
|
+
if (isTodoContextMessage(boundaryMessage)) {
|
|
125
|
+
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
126
|
+
pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
|
|
127
|
+
version: TODO_RESTORED_BOUNDARY_VERSION,
|
|
128
|
+
...restoredBoundary
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
109
132
|
if (messages !== event.messages) return { messages };
|
|
110
133
|
});
|
|
111
134
|
pi.on("session_tree", (_event, ctx) => {
|
|
112
135
|
if (!ownsSession(ctx)) return;
|
|
113
|
-
|
|
136
|
+
restoreBranchState(ctx);
|
|
114
137
|
publish(ctx);
|
|
115
138
|
});
|
|
116
139
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
117
140
|
if (!ownsSession(ctx)) return;
|
|
118
141
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, void 0);
|
|
119
142
|
items = [];
|
|
143
|
+
restoredBoundary = void 0;
|
|
120
144
|
activeSession = void 0;
|
|
121
145
|
});
|
|
122
146
|
}
|
|
@@ -152,17 +176,19 @@ function renderTodoWidget(items, theme, width) {
|
|
|
152
176
|
}
|
|
153
177
|
return lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
154
178
|
}
|
|
155
|
-
function reconcileTodoContext(messages, items) {
|
|
179
|
+
function reconcileTodoContext(messages, items, restoredBoundaryContent) {
|
|
156
180
|
const existing = messages.filter(isTodoContextMessage);
|
|
157
181
|
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
158
|
-
const
|
|
159
|
-
|
|
182
|
+
const summaryBoundary = leadingSummaryBoundary(withoutExisting);
|
|
183
|
+
const currentContent = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
|
|
184
|
+
const content = summaryBoundary > 0 ? restoredBoundaryContent ?? currentContent : void 0;
|
|
185
|
+
if (content !== void 0 && existing.length === 1 && messages[summaryBoundary] === existing[0] && existing[0]?.content === content && hasTodoContextVersion(existing[0])) {
|
|
160
186
|
return messages;
|
|
161
187
|
}
|
|
162
188
|
if (existing.length === 0 && content === void 0) return messages;
|
|
163
189
|
if (content === void 0) return withoutExisting;
|
|
164
190
|
return [
|
|
165
|
-
...withoutExisting,
|
|
191
|
+
...withoutExisting.slice(0, summaryBoundary),
|
|
166
192
|
{
|
|
167
193
|
role: "custom",
|
|
168
194
|
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
@@ -170,7 +196,8 @@ function reconcileTodoContext(messages, items) {
|
|
|
170
196
|
display: false,
|
|
171
197
|
details: { version: TODO_CONTEXT_VERSION },
|
|
172
198
|
timestamp: 0
|
|
173
|
-
}
|
|
199
|
+
},
|
|
200
|
+
...withoutExisting.slice(summaryBoundary)
|
|
174
201
|
];
|
|
175
202
|
}
|
|
176
203
|
function sanitizeTodoText(value) {
|
|
@@ -187,6 +214,36 @@ function todoContextContent(items) {
|
|
|
187
214
|
Current todo list as JSON data:
|
|
188
215
|
${JSON.stringify(items)}`;
|
|
189
216
|
}
|
|
217
|
+
function reconstructRestoredTodoBoundary(entries) {
|
|
218
|
+
const summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);
|
|
219
|
+
if (!summaryEpoch) return void 0;
|
|
220
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
221
|
+
const entry = entries[index];
|
|
222
|
+
if (entry?.type !== "custom" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;
|
|
226
|
+
return { summaryEpoch, content: entry.data.content };
|
|
227
|
+
}
|
|
228
|
+
return void 0;
|
|
229
|
+
}
|
|
230
|
+
function isRestoredTodoBoundaryData(value, summaryEpoch) {
|
|
231
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
232
|
+
const data = value;
|
|
233
|
+
if (data.version !== TODO_RESTORED_BOUNDARY_VERSION || data.summaryEpoch !== summaryEpoch || typeof data.content !== "string") {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]
|
|
237
|
+
Current todo list as JSON data:
|
|
238
|
+
`;
|
|
239
|
+
if (!data.content.startsWith(prefix)) return false;
|
|
240
|
+
try {
|
|
241
|
+
const restoredItems = JSON.parse(data.content.slice(prefix.length));
|
|
242
|
+
return isTodoItems(restoredItems) && restoredItems.length > 0 && todoContextContent(restoredItems) === data.content;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
190
247
|
function hasModelVisibleTodoState(messages, items) {
|
|
191
248
|
const currentResults = /* @__PURE__ */ new Map();
|
|
192
249
|
for (const message of messages) {
|
|
@@ -208,6 +265,22 @@ function isTodoToolArguments(value) {
|
|
|
208
265
|
function isTodoContextMessage(message) {
|
|
209
266
|
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
210
267
|
}
|
|
268
|
+
function hasTodoContextVersion(message) {
|
|
269
|
+
return typeof message.details === "object" && message.details !== null && !Array.isArray(message.details) && message.details.version === TODO_CONTEXT_VERSION;
|
|
270
|
+
}
|
|
271
|
+
function leadingSummaryEpoch(messages) {
|
|
272
|
+
const boundary = leadingSummaryBoundary(messages);
|
|
273
|
+
return boundary === 0 ? void 0 : JSON.stringify(messages.slice(0, boundary));
|
|
274
|
+
}
|
|
275
|
+
function leadingSummaryBoundary(messages) {
|
|
276
|
+
let index = 0;
|
|
277
|
+
while (index < messages.length) {
|
|
278
|
+
const role = messages[index]?.role;
|
|
279
|
+
if (role !== "compactionSummary" && role !== "branchSummary") break;
|
|
280
|
+
index += 1;
|
|
281
|
+
}
|
|
282
|
+
return index;
|
|
283
|
+
}
|
|
211
284
|
function validateItems(items) {
|
|
212
285
|
for (const [index, item] of items.entries()) {
|
|
213
286
|
if (item.text.trim().length === 0) {
|
package/dist/index.ts.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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": ";;;;AAAA,SAAS,kBAAkB;
|
|
4
|
+
"sourcesContent": ["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport {\n\tbuildSessionContext,\n\ttype ContextEvent,\n\ttype ExtensionAPI,\n\ttype ExtensionContext,\n\ttype SessionEntry,\n\ttype Theme,\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 TODO_RESTORED_BOUNDARY_ENTRY_TYPE = \"todo-restored-context-boundary\";\nconst TODO_RESTORED_BOUNDARY_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\tlet restoredBoundary: { summaryEpoch: string; content: string } | undefined;\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\tconst restoreBranchState = (ctx: ExtensionContext): void => {\n\t\tconst branch = ctx.sessionManager.getBranch();\n\t\titems = reconstructItems(branch);\n\t\trestoredBoundary = reconstructRestoredTodoBoundary(branch);\n\t};\n\n\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\trestoreBranchState(ctx);\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"context\", (event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tconst summaryEpoch = leadingSummaryEpoch(event.messages);\n\t\tif (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;\n\t\tconst messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);\n\t\tif (restoredBoundary === undefined && summaryEpoch) {\n\t\t\tconst boundaryMessage = messages[leadingSummaryBoundary(messages)];\n\t\t\tif (isTodoContextMessage(boundaryMessage)) {\n\t\t\t\trestoredBoundary = { summaryEpoch, content: boundaryMessage.content };\n\t\t\t\tpi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {\n\t\t\t\t\tversion: TODO_RESTORED_BOUNDARY_VERSION,\n\t\t\t\t\t...restoredBoundary,\n\t\t\t\t});\n\t\t\t}\n\t\t}\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\trestoreBranchState(ctx);\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\trestoredBoundary = undefined;\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\trestoredBoundaryContent?: string,\n): ContextEvent[\"messages\"] {\n\tconst existing = messages.filter(isTodoContextMessage);\n\tconst withoutExisting = messages.filter((message) => !isTodoContextMessage(message));\n\tconst summaryBoundary = leadingSummaryBoundary(withoutExisting);\n\tconst currentContent =\n\t\titems.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)\n\t\t\t? todoContextContent(items)\n\t\t\t: undefined;\n\tconst content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;\n\tif (\n\t\tcontent !== undefined &&\n\t\texisting.length === 1 &&\n\t\tmessages[summaryBoundary] === existing[0] &&\n\t\texisting[0]?.content === content &&\n\t\thasTodoContextVersion(existing[0])\n\t) {\n\t\treturn messages;\n\t}\n\tif (existing.length === 0 && content === undefined) return messages;\n\tif (content === undefined) return withoutExisting;\n\n\treturn [\n\t\t...withoutExisting.slice(0, summaryBoundary),\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\t...withoutExisting.slice(summaryBoundary),\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 reconstructRestoredTodoBoundary(\n\tentries: readonly SessionEntry[],\n): { summaryEpoch: string; content: string } | undefined {\n\tconst summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);\n\tif (!summaryEpoch) return undefined;\n\tfor (let index = entries.length - 1; index >= 0; index -= 1) {\n\t\tconst entry = entries[index];\n\t\tif (entry?.type !== \"custom\" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;\n\t\treturn { summaryEpoch, content: entry.data.content };\n\t}\n\treturn undefined;\n}\n\nfunction isRestoredTodoBoundaryData(\n\tvalue: unknown,\n\tsummaryEpoch: string,\n): value is { version: number; summaryEpoch: string; content: string } {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst data = value as Record<string, unknown>;\n\tif (\n\t\tdata.version !== TODO_RESTORED_BOUNDARY_VERSION ||\n\t\tdata.summaryEpoch !== summaryEpoch ||\n\t\ttypeof data.content !== \"string\"\n\t) {\n\t\treturn false;\n\t}\n\tconst prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\\nCurrent todo list as JSON data:\\n`;\n\tif (!data.content.startsWith(prefix)) return false;\n\ttry {\n\t\tconst restoredItems: unknown = JSON.parse(data.content.slice(prefix.length));\n\t\treturn (\n\t\t\tisTodoItems(restoredItems) &&\n\t\t\trestoredItems.length > 0 &&\n\t\t\ttodoContextContent(restoredItems) === data.content\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\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\ntype TodoContextMessage = Extract<ContextEvent[\"messages\"][number], { role: \"custom\" }> & {\n\tcontent: string;\n};\n\nfunction isTodoContextMessage(\n\tmessage: ContextEvent[\"messages\"][number],\n): message is TodoContextMessage {\n\treturn message.role === \"custom\" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;\n}\n\nfunction hasTodoContextVersion(message: TodoContextMessage): boolean {\n\treturn (\n\t\ttypeof message.details === \"object\" &&\n\t\tmessage.details !== null &&\n\t\t!Array.isArray(message.details) &&\n\t\t(message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION\n\t);\n}\n\nfunction leadingSummaryEpoch(messages: ContextEvent[\"messages\"]): string | undefined {\n\tconst boundary = leadingSummaryBoundary(messages);\n\treturn boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));\n}\n\nfunction leadingSummaryBoundary(messages: ContextEvent[\"messages\"]): number {\n\tlet index = 0;\n\twhile (index < messages.length) {\n\t\tconst role = messages[index]?.role;\n\t\tif (role !== \"compactionSummary\" && role !== \"branchSummary\") break;\n\t\tindex += 1;\n\t}\n\treturn index;\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
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
package/src/todo-widget.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import {
|
|
3
|
+
buildSessionContext,
|
|
4
|
+
type ContextEvent,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type SessionEntry,
|
|
8
|
+
type Theme,
|
|
8
9
|
} from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
11
|
import { Type } from "typebox";
|
|
@@ -14,6 +15,8 @@ export const WIDGET_KEY = "todo";
|
|
|
14
15
|
export const TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
|
|
15
16
|
export const TODO_CONTEXT_VERSION = 1;
|
|
16
17
|
export const TODO_DETAILS_VERSION = 1;
|
|
18
|
+
export const TODO_RESTORED_BOUNDARY_ENTRY_TYPE = "todo-restored-context-boundary";
|
|
19
|
+
const TODO_RESTORED_BOUNDARY_VERSION = 1;
|
|
17
20
|
export const MAX_TODO_ITEMS = 50;
|
|
18
21
|
export const MAX_TODO_TEXT_LENGTH = 300;
|
|
19
22
|
|
|
@@ -56,6 +59,7 @@ const TodoParameters = Type.Object({
|
|
|
56
59
|
export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
57
60
|
let activeSession: ExtensionContext["sessionManager"] | undefined;
|
|
58
61
|
let items: TodoItem[] = [];
|
|
62
|
+
let restoredBoundary: { summaryEpoch: string; content: string } | undefined;
|
|
59
63
|
|
|
60
64
|
const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
|
|
61
65
|
|
|
@@ -125,21 +129,39 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
125
129
|
},
|
|
126
130
|
});
|
|
127
131
|
|
|
132
|
+
const restoreBranchState = (ctx: ExtensionContext): void => {
|
|
133
|
+
const branch = ctx.sessionManager.getBranch();
|
|
134
|
+
items = reconstructItems(branch);
|
|
135
|
+
restoredBoundary = reconstructRestoredTodoBoundary(branch);
|
|
136
|
+
};
|
|
137
|
+
|
|
128
138
|
pi.on("session_start", (_event, ctx) => {
|
|
129
139
|
activeSession = ctx.sessionManager;
|
|
130
|
-
|
|
140
|
+
restoreBranchState(ctx);
|
|
131
141
|
publish(ctx);
|
|
132
142
|
});
|
|
133
143
|
|
|
134
144
|
pi.on("context", (event, ctx) => {
|
|
135
145
|
if (!ownsSession(ctx)) return;
|
|
136
|
-
const
|
|
146
|
+
const summaryEpoch = leadingSummaryEpoch(event.messages);
|
|
147
|
+
if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;
|
|
148
|
+
const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
|
|
149
|
+
if (restoredBoundary === undefined && summaryEpoch) {
|
|
150
|
+
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
151
|
+
if (isTodoContextMessage(boundaryMessage)) {
|
|
152
|
+
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
153
|
+
pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
|
|
154
|
+
version: TODO_RESTORED_BOUNDARY_VERSION,
|
|
155
|
+
...restoredBoundary,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
137
159
|
if (messages !== event.messages) return { messages };
|
|
138
160
|
});
|
|
139
161
|
|
|
140
162
|
pi.on("session_tree", (_event, ctx) => {
|
|
141
163
|
if (!ownsSession(ctx)) return;
|
|
142
|
-
|
|
164
|
+
restoreBranchState(ctx);
|
|
143
165
|
publish(ctx);
|
|
144
166
|
});
|
|
145
167
|
|
|
@@ -147,6 +169,7 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
147
169
|
if (!ownsSession(ctx)) return;
|
|
148
170
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
149
171
|
items = [];
|
|
172
|
+
restoredBoundary = undefined;
|
|
150
173
|
activeSession = undefined;
|
|
151
174
|
});
|
|
152
175
|
}
|
|
@@ -195,25 +218,30 @@ export function renderTodoWidget(
|
|
|
195
218
|
export function reconcileTodoContext(
|
|
196
219
|
messages: ContextEvent["messages"],
|
|
197
220
|
items: readonly TodoItem[],
|
|
221
|
+
restoredBoundaryContent?: string,
|
|
198
222
|
): ContextEvent["messages"] {
|
|
199
223
|
const existing = messages.filter(isTodoContextMessage);
|
|
200
224
|
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
201
|
-
const
|
|
225
|
+
const summaryBoundary = leadingSummaryBoundary(withoutExisting);
|
|
226
|
+
const currentContent =
|
|
202
227
|
items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)
|
|
203
228
|
? todoContextContent(items)
|
|
204
229
|
: undefined;
|
|
230
|
+
const content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;
|
|
205
231
|
if (
|
|
232
|
+
content !== undefined &&
|
|
206
233
|
existing.length === 1 &&
|
|
207
|
-
messages
|
|
208
|
-
existing[0]?.content === content
|
|
234
|
+
messages[summaryBoundary] === existing[0] &&
|
|
235
|
+
existing[0]?.content === content &&
|
|
236
|
+
hasTodoContextVersion(existing[0])
|
|
209
237
|
) {
|
|
210
238
|
return messages;
|
|
211
239
|
}
|
|
212
240
|
if (existing.length === 0 && content === undefined) return messages;
|
|
213
|
-
|
|
214
241
|
if (content === undefined) return withoutExisting;
|
|
242
|
+
|
|
215
243
|
return [
|
|
216
|
-
...withoutExisting,
|
|
244
|
+
...withoutExisting.slice(0, summaryBoundary),
|
|
217
245
|
{
|
|
218
246
|
role: "custom",
|
|
219
247
|
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
@@ -222,6 +250,7 @@ export function reconcileTodoContext(
|
|
|
222
250
|
details: { version: TODO_CONTEXT_VERSION },
|
|
223
251
|
timestamp: 0,
|
|
224
252
|
},
|
|
253
|
+
...withoutExisting.slice(summaryBoundary),
|
|
225
254
|
];
|
|
226
255
|
}
|
|
227
256
|
|
|
@@ -241,6 +270,49 @@ Current todo list as JSON data:
|
|
|
241
270
|
${JSON.stringify(items)}`;
|
|
242
271
|
}
|
|
243
272
|
|
|
273
|
+
function reconstructRestoredTodoBoundary(
|
|
274
|
+
entries: readonly SessionEntry[],
|
|
275
|
+
): { summaryEpoch: string; content: string } | undefined {
|
|
276
|
+
const summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);
|
|
277
|
+
if (!summaryEpoch) return undefined;
|
|
278
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
279
|
+
const entry = entries[index];
|
|
280
|
+
if (entry?.type !== "custom" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;
|
|
284
|
+
return { summaryEpoch, content: entry.data.content };
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isRestoredTodoBoundaryData(
|
|
290
|
+
value: unknown,
|
|
291
|
+
summaryEpoch: string,
|
|
292
|
+
): value is { version: number; summaryEpoch: string; content: string } {
|
|
293
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
294
|
+
const data = value as Record<string, unknown>;
|
|
295
|
+
if (
|
|
296
|
+
data.version !== TODO_RESTORED_BOUNDARY_VERSION ||
|
|
297
|
+
data.summaryEpoch !== summaryEpoch ||
|
|
298
|
+
typeof data.content !== "string"
|
|
299
|
+
) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\nCurrent todo list as JSON data:\n`;
|
|
303
|
+
if (!data.content.startsWith(prefix)) return false;
|
|
304
|
+
try {
|
|
305
|
+
const restoredItems: unknown = JSON.parse(data.content.slice(prefix.length));
|
|
306
|
+
return (
|
|
307
|
+
isTodoItems(restoredItems) &&
|
|
308
|
+
restoredItems.length > 0 &&
|
|
309
|
+
todoContextContent(restoredItems) === data.content
|
|
310
|
+
);
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
244
316
|
function hasModelVisibleTodoState(
|
|
245
317
|
messages: ContextEvent["messages"],
|
|
246
318
|
items: readonly TodoItem[],
|
|
@@ -277,12 +349,40 @@ function isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {
|
|
|
277
349
|
return isTodoItems((value as Record<string, unknown>).items);
|
|
278
350
|
}
|
|
279
351
|
|
|
352
|
+
type TodoContextMessage = Extract<ContextEvent["messages"][number], { role: "custom" }> & {
|
|
353
|
+
content: string;
|
|
354
|
+
};
|
|
355
|
+
|
|
280
356
|
function isTodoContextMessage(
|
|
281
357
|
message: ContextEvent["messages"][number],
|
|
282
|
-
): message is
|
|
358
|
+
): message is TodoContextMessage {
|
|
283
359
|
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
284
360
|
}
|
|
285
361
|
|
|
362
|
+
function hasTodoContextVersion(message: TodoContextMessage): boolean {
|
|
363
|
+
return (
|
|
364
|
+
typeof message.details === "object" &&
|
|
365
|
+
message.details !== null &&
|
|
366
|
+
!Array.isArray(message.details) &&
|
|
367
|
+
(message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function leadingSummaryEpoch(messages: ContextEvent["messages"]): string | undefined {
|
|
372
|
+
const boundary = leadingSummaryBoundary(messages);
|
|
373
|
+
return boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function leadingSummaryBoundary(messages: ContextEvent["messages"]): number {
|
|
377
|
+
let index = 0;
|
|
378
|
+
while (index < messages.length) {
|
|
379
|
+
const role = messages[index]?.role;
|
|
380
|
+
if (role !== "compactionSummary" && role !== "branchSummary") break;
|
|
381
|
+
index += 1;
|
|
382
|
+
}
|
|
383
|
+
return index;
|
|
384
|
+
}
|
|
385
|
+
|
|
286
386
|
function validateItems(items: readonly TodoItem[]): void {
|
|
287
387
|
for (const [index, item] of items.entries()) {
|
|
288
388
|
if (item.text.trim().length === 0) {
|