@narumitw/pi-todo 0.0.0 → 0.1.1
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 +7 -4
- package/dist/index.ts +36 -6
- package/dist/index.ts.map +2 -2
- package/package.json +2 -2
- package/src/todo-widget.ts +52 -7
package/README.md
CHANGED
|
@@ -78,9 +78,11 @@ 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 later tool call and result supersede the restored state at the conversation tail without rewriting the earlier provider prefix.
|
|
85
|
+
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
86
|
|
|
85
87
|
In TUI mode, updates appear immediately in a widget above the editor.
|
|
86
88
|
|
|
@@ -117,8 +119,9 @@ packages/pi-todo/
|
|
|
117
119
|
│ ├── index.ts # Thin authoritative extension forwarder
|
|
118
120
|
│ └── todo-widget.ts # Tool, lifecycle, state reconstruction, and rendering
|
|
119
121
|
├── test/
|
|
120
|
-
│ ├── build-runtime.test.ts
|
|
121
|
-
│
|
|
122
|
+
│ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
|
|
123
|
+
│ ├── todo-cache-contract.test.ts # Normalized provider-prefix coverage
|
|
124
|
+
│ └── todo-widget.test.ts # Extension behavior coverage
|
|
122
125
|
├── LICENSE
|
|
123
126
|
├── README.md
|
|
124
127
|
├── package.json
|
package/dist/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ var TodoParameters = Type.Object({
|
|
|
37
37
|
function todoWidgetExtension(pi) {
|
|
38
38
|
let activeSession;
|
|
39
39
|
let items = [];
|
|
40
|
+
let restoredBoundary;
|
|
40
41
|
const ownsSession = (ctx) => ctx.sessionManager === activeSession;
|
|
41
42
|
const publish = (ctx) => {
|
|
42
43
|
if (!ownsSession(ctx) || ctx.mode !== "tui") return;
|
|
@@ -101,11 +102,20 @@ function todoWidgetExtension(pi) {
|
|
|
101
102
|
pi.on("session_start", (_event, ctx) => {
|
|
102
103
|
activeSession = ctx.sessionManager;
|
|
103
104
|
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
105
|
+
restoredBoundary = void 0;
|
|
104
106
|
publish(ctx);
|
|
105
107
|
});
|
|
106
108
|
pi.on("context", (event, ctx) => {
|
|
107
109
|
if (!ownsSession(ctx)) return;
|
|
108
|
-
const
|
|
110
|
+
const summaryEpoch = leadingSummaryEpoch(event.messages);
|
|
111
|
+
if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = void 0;
|
|
112
|
+
const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
|
|
113
|
+
if (restoredBoundary === void 0 && summaryEpoch) {
|
|
114
|
+
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
115
|
+
if (isTodoContextMessage(boundaryMessage)) {
|
|
116
|
+
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
109
119
|
if (messages !== event.messages) return { messages };
|
|
110
120
|
});
|
|
111
121
|
pi.on("session_tree", (_event, ctx) => {
|
|
@@ -117,6 +127,7 @@ function todoWidgetExtension(pi) {
|
|
|
117
127
|
if (!ownsSession(ctx)) return;
|
|
118
128
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, void 0);
|
|
119
129
|
items = [];
|
|
130
|
+
restoredBoundary = void 0;
|
|
120
131
|
activeSession = void 0;
|
|
121
132
|
});
|
|
122
133
|
}
|
|
@@ -152,17 +163,19 @@ function renderTodoWidget(items, theme, width) {
|
|
|
152
163
|
}
|
|
153
164
|
return lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
154
165
|
}
|
|
155
|
-
function reconcileTodoContext(messages, items) {
|
|
166
|
+
function reconcileTodoContext(messages, items, restoredBoundaryContent) {
|
|
156
167
|
const existing = messages.filter(isTodoContextMessage);
|
|
157
168
|
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
158
|
-
const
|
|
159
|
-
|
|
169
|
+
const summaryBoundary = leadingSummaryBoundary(withoutExisting);
|
|
170
|
+
const currentContent = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
|
|
171
|
+
const content = summaryBoundary > 0 ? restoredBoundaryContent ?? currentContent : void 0;
|
|
172
|
+
if (content !== void 0 && existing.length === 1 && messages[summaryBoundary] === existing[0] && existing[0]?.content === content && hasTodoContextVersion(existing[0])) {
|
|
160
173
|
return messages;
|
|
161
174
|
}
|
|
162
175
|
if (existing.length === 0 && content === void 0) return messages;
|
|
163
176
|
if (content === void 0) return withoutExisting;
|
|
164
177
|
return [
|
|
165
|
-
...withoutExisting,
|
|
178
|
+
...withoutExisting.slice(0, summaryBoundary),
|
|
166
179
|
{
|
|
167
180
|
role: "custom",
|
|
168
181
|
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
@@ -170,7 +183,8 @@ function reconcileTodoContext(messages, items) {
|
|
|
170
183
|
display: false,
|
|
171
184
|
details: { version: TODO_CONTEXT_VERSION },
|
|
172
185
|
timestamp: 0
|
|
173
|
-
}
|
|
186
|
+
},
|
|
187
|
+
...withoutExisting.slice(summaryBoundary)
|
|
174
188
|
];
|
|
175
189
|
}
|
|
176
190
|
function sanitizeTodoText(value) {
|
|
@@ -208,6 +222,22 @@ function isTodoToolArguments(value) {
|
|
|
208
222
|
function isTodoContextMessage(message) {
|
|
209
223
|
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
210
224
|
}
|
|
225
|
+
function hasTodoContextVersion(message) {
|
|
226
|
+
return typeof message.details === "object" && message.details !== null && !Array.isArray(message.details) && message.details.version === TODO_CONTEXT_VERSION;
|
|
227
|
+
}
|
|
228
|
+
function leadingSummaryEpoch(messages) {
|
|
229
|
+
const boundary = leadingSummaryBoundary(messages);
|
|
230
|
+
return boundary === 0 ? void 0 : JSON.stringify(messages.slice(0, boundary));
|
|
231
|
+
}
|
|
232
|
+
function leadingSummaryBoundary(messages) {
|
|
233
|
+
let index = 0;
|
|
234
|
+
while (index < messages.length) {
|
|
235
|
+
const role = messages[index]?.role;
|
|
236
|
+
if (role !== "compactionSummary" && role !== "branchSummary") break;
|
|
237
|
+
index += 1;
|
|
238
|
+
}
|
|
239
|
+
return index;
|
|
240
|
+
}
|
|
211
241
|
function validateItems(items) {
|
|
212
242
|
for (const [index, item] of items.entries()) {
|
|
213
243
|
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;AAQ3B,SAAS,wBAAwB,iBAAiB,wBAAwB;AAC1E,SAAS,YAAY;AAEd,IAAM,YAAY;AAClB,IAAM,aAAa;AACnB,IAAM,4BAA4B;AAClC,IAAM,uBAAuB;AAC7B,IAAM,uBAAuB;AAC7B,IAAM,iBAAiB;AACvB,IAAM,uBAAuB;AAEpC,IAAM,iBAAiB,EAAE,WAAW,cAAc;AAClD,IAAM,mBAAmB;AACzB,IAAM,gBAAgB,CAAC,WAAW,eAAe,WAAW;AAC5D,IAAM,gBAAgB;AActB,IAAM,iBAAiB,KAAK,OAAO;AAAA,EAClC,OAAO,KAAK;AAAA,IACX,KAAK,OAAO;AAAA,MACX,MAAM,KAAK,OAAO;AAAA,QACjB,WAAW;AAAA,QACX,WAAW;AAAA,QACX,aAAa;AAAA,MACd,CAAC;AAAA,MACD,QAAQ,WAAW,eAAe;AAAA,QACjC,aAAa;AAAA,MACd,CAAC;AAAA,IACF,CAAC;AAAA,IACD;AAAA,MACC,UAAU;AAAA,MACV,aAAa;AAAA,IACd;AAAA,EACD;AACD,CAAC;AAEc,SAAR,oBAAqC,IAAwB;AACnE,MAAI;AACJ,MAAI,QAAoB,CAAC;
|
|
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\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\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\trestoredBoundary = undefined;\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}\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\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\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 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": ";;;;AAAA,SAAS,kBAAkB;AAQ3B,SAAS,wBAAwB,iBAAiB,wBAAwB;AAC1E,SAAS,YAAY;AAEd,IAAM,YAAY;AAClB,IAAM,aAAa;AACnB,IAAM,4BAA4B;AAClC,IAAM,uBAAuB;AAC7B,IAAM,uBAAuB;AAC7B,IAAM,iBAAiB;AACvB,IAAM,uBAAuB;AAEpC,IAAM,iBAAiB,EAAE,WAAW,cAAc;AAClD,IAAM,mBAAmB;AACzB,IAAM,gBAAgB,CAAC,WAAW,eAAe,WAAW;AAC5D,IAAM,gBAAgB;AActB,IAAM,iBAAiB,KAAK,OAAO;AAAA,EAClC,OAAO,KAAK;AAAA,IACX,KAAK,OAAO;AAAA,MACX,MAAM,KAAK,OAAO;AAAA,QACjB,WAAW;AAAA,QACX,WAAW;AAAA,QACX,aAAa;AAAA,MACd,CAAC;AAAA,MACD,QAAQ,WAAW,eAAe;AAAA,QACjC,aAAa;AAAA,MACd,CAAC;AAAA,IACF,CAAC;AAAA,IACD;AAAA,MACC,UAAU;AAAA,MACV,aAAa;AAAA,IACd;AAAA,EACD;AACD,CAAC;AAEc,SAAR,oBAAqC,IAAwB;AACnE,MAAI;AACJ,MAAI,QAAoB,CAAC;AACzB,MAAI;AAEJ,QAAM,cAAc,CAAC,QAAmC,IAAI,mBAAmB;AAE/E,QAAM,UAAU,CAAC,QAAgC;AAChD,QAAI,CAAC,YAAY,GAAG,KAAK,IAAI,SAAS,MAAO;AAC7C,QAAI,MAAM,WAAW,GAAG;AACvB,UAAI,GAAG,UAAU,YAAY,MAAS;AACtC;AAAA,IACD;AAEA,UAAM,WAAW,WAAW,KAAK;AACjC,QAAI,GAAG;AAAA,MACN;AAAA,MACA,CAAC,MAAM,WAAW;AAAA,QACjB,QAAQ,CAAC,UAAU,iBAAiB,UAAU,OAAO,KAAK;AAAA,QAC1D,YAAY,MAAM;AAAA,QAAC;AAAA,MACpB;AAAA,MACA;AAAA,IACD;AAAA,EACD;AAEA,KAAG,aAAa;AAAA,IACf,MAAM;AAAA,IACN,OAAO;AAAA,IACP,aACC;AAAA,IACD,eAAe;AAAA,IACf,kBAAkB;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACD;AAAA,IACA,YAAY;AAAA,IACZ,MAAM,QAAQ,aAAa,QAAQ,QAAQ,WAAW,KAAK;AAC1D,cAAQ,eAAe;AACvB,UAAI,CAAC,YAAY,GAAG,GAAG;AACtB,cAAM,IAAI,MAAM,0DAA0D;AAAA,MAC3E;AACA,oBAAc,OAAO,KAAK;AAE1B,cAAQ,WAAW,OAAO,KAAK;AAC/B,cAAQ,GAAG;AAEX,YAAM,UAAuB;AAAA,QAC5B,SAAS;AAAA,QACT,OAAO,WAAW,KAAK;AAAA,MACxB;AACA,UAAI,MAAM,WAAW,GAAG;AACvB,eAAO;AAAA,UACN,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,qBAAqB,CAAC;AAAA,UACtD;AAAA,QACD;AAAA,MACD;AAEA,YAAM,YAAY,MAAM,OAAO,CAAC,SAAS,KAAK,WAAW,WAAW,EAAE;AACtE,YAAM,aAAa,MAAM,KAAK,CAAC,SAAS,KAAK,WAAW,aAAa;AACrE,aAAO;AAAA,QACN,SAAS;AAAA,UACR;AAAA,YACC,MAAM;AAAA,YACN,MAAM,sBAAsB,SAAS,OAAO,MAAM,MAAM,YAAY,aAAa,oBAAoB,EAAE;AAAA,UACxG;AAAA,QACD;AAAA,QACA;AAAA,MACD;AAAA,IACD;AAAA,EACD,CAAC;AAED,KAAG,GAAG,iBAAiB,CAAC,QAAQ,QAAQ;AACvC,oBAAgB,IAAI;AACpB,YAAQ,iBAAiB,IAAI,eAAe,UAAU,CAAC;AACvD,uBAAmB;AACnB,YAAQ,GAAG;AAAA,EACZ,CAAC;AAED,KAAG,GAAG,WAAW,CAAC,OAAO,QAAQ;AAChC,QAAI,CAAC,YAAY,GAAG,EAAG;AACvB,UAAM,eAAe,oBAAoB,MAAM,QAAQ;AACvD,QAAI,kBAAkB,iBAAiB,aAAc,oBAAmB;AACxE,UAAM,WAAW,qBAAqB,MAAM,UAAU,OAAO,kBAAkB,OAAO;AACtF,QAAI,qBAAqB,UAAa,cAAc;AACnD,YAAM,kBAAkB,SAAS,uBAAuB,QAAQ,CAAC;AACjE,UAAI,qBAAqB,eAAe,GAAG;AAC1C,2BAAmB,EAAE,cAAc,SAAS,gBAAgB,QAAQ;AAAA,MACrE;AAAA,IACD;AACA,QAAI,aAAa,MAAM,SAAU,QAAO,EAAE,SAAS;AAAA,EACpD,CAAC;AAED,KAAG,GAAG,gBAAgB,CAAC,QAAQ,QAAQ;AACtC,QAAI,CAAC,YAAY,GAAG,EAAG;AACvB,YAAQ,iBAAiB,IAAI,eAAe,UAAU,CAAC;AACvD,YAAQ,GAAG;AAAA,EACZ,CAAC;AAED,KAAG,GAAG,oBAAoB,CAAC,QAAQ,QAAQ;AAC1C,QAAI,CAAC,YAAY,GAAG,EAAG;AACvB,QAAI,IAAI,SAAS,MAAO,KAAI,GAAG,UAAU,YAAY,MAAS;AAC9D,YAAQ,CAAC;AACT,uBAAmB;AACnB,oBAAgB;AAAA,EACjB,CAAC;AACF;AAEO,SAAS,iBACf,OACA,OACA,OACW;AACX,QAAM,YAAY,MAAM,OAAO,CAAC,SAAS,KAAK,WAAW,WAAW,EAAE;AACtE,QAAM,UAAU,MAAM,GAAG,eAAe,SAAI,OAAO,KAAK,IAAI,GAAG,KAAK,CAAC,CAAC;AACtE,QAAM,QAAQ,CAAC,SAAS,MAAM,GAAG,SAAS,aAAU,SAAS,IAAI,MAAM,MAAM,WAAW,CAAC;AAEzF,QAAM,cAAc,KAAK,IAAI,GAAG,KAAK;AACrC,aAAW,QAAQ,OAAO;AACzB,UAAM,OAAO,iBAAiB,KAAK,IAAI;AACvC,QAAI;AACJ,QAAI;AACJ,YAAQ,KAAK,QAAQ;AAAA,MACpB,KAAK;AACJ,iBAAS,MAAM,GAAG,WAAW,SAAI;AACjC,qBAAa,MAAM,GAAG,SAAS,MAAM,cAAc,IAAI,CAAC;AACxD;AAAA,MACD,KAAK;AACJ,iBAAS,MAAM,GAAG,UAAU,SAAI;AAChC,qBAAa,MAAM,GAAG,UAAU,MAAM,KAAK,IAAI,CAAC;AAChD;AAAA,MACD,KAAK;AACJ,iBAAS,MAAM,GAAG,OAAO,SAAI;AAC7B,qBAAa,MAAM,GAAG,QAAQ,IAAI;AAClC;AAAA,IACF;AAEA,QAAI,eAAe,GAAG;AACrB,YAAM,KAAK,MAAM;AACjB;AAAA,IACD;AAEA,UAAM,cAAc,iBAAiB,YAAY,cAAc,CAAC;AAChE,UAAM,KAAK,GAAG,YAAY,IAAI,CAAC,MAAM,UAAU,GAAG,UAAU,IAAI,SAAS,IAAI,GAAG,IAAI,EAAE,CAAC;AAAA,EACxF;AAEA,SAAO,MAAM,IAAI,CAAC,SAAS,gBAAgB,MAAM,aAAa,EAAE,CAAC;AAClE;AAEO,SAAS,qBACf,UACA,OACA,yBAC2B;AAC3B,QAAM,WAAW,SAAS,OAAO,oBAAoB;AACrD,QAAM,kBAAkB,SAAS,OAAO,CAAC,YAAY,CAAC,qBAAqB,OAAO,CAAC;AACnF,QAAM,kBAAkB,uBAAuB,eAAe;AAC9D,QAAM,iBACL,MAAM,SAAS,KAAK,CAAC,yBAAyB,iBAAiB,KAAK,IACjE,mBAAmB,KAAK,IACxB;AACJ,QAAM,UAAU,kBAAkB,IAAK,2BAA2B,iBAAkB;AACpF,MACC,YAAY,UACZ,SAAS,WAAW,KACpB,SAAS,eAAe,MAAM,SAAS,CAAC,KACxC,SAAS,CAAC,GAAG,YAAY,WACzB,sBAAsB,SAAS,CAAC,CAAC,GAChC;AACD,WAAO;AAAA,EACR;AACA,MAAI,SAAS,WAAW,KAAK,YAAY,OAAW,QAAO;AAC3D,MAAI,YAAY,OAAW,QAAO;AAElC,SAAO;AAAA,IACN,GAAG,gBAAgB,MAAM,GAAG,eAAe;AAAA,IAC3C;AAAA,MACC,MAAM;AAAA,MACN,YAAY;AAAA,MACZ;AAAA,MACA,SAAS;AAAA,MACT,SAAS,EAAE,SAAS,qBAAqB;AAAA,MACzC,WAAW;AAAA,IACZ;AAAA,IACA,GAAG,gBAAgB,MAAM,eAAe;AAAA,EACzC;AACD;AAEO,SAAS,iBAAiB,OAAuB;AACvD,MAAI,OAAO;AACX,aAAW,aAAa,uBAAuB,KAAK,EAAE,QAAQ,eAAe,EAAE,GAAG;AACjF,UAAM,YAAY,UAAU,YAAY,CAAC,KAAK;AAC9C,UAAM,YAAY,aAAa,MAAS,aAAa,OAAQ,aAAa;AAC1E,YAAQ,YAAY,MAAM;AAAA,EAC3B;AACA,SAAO,KAAK,QAAQ,SAAS,GAAG,EAAE,KAAK;AACxC;AAEA,SAAS,mBAAmB,OAAoC;AAC/D,SAAO,oBAAoB,oBAAoB;AAAA;AAAA,EAE9C,KAAK,UAAU,KAAK,CAAC;AACvB;AAEA,SAAS,yBACR,UACA,OACU;AACV,QAAM,iBAAiB,oBAAI,IAAoB;AAC/C,aAAW,WAAW,UAAU;AAC/B,QACC,QAAQ,SAAS,gBACjB,CAAC,QAAQ,YACR,QAAQ,aAAa,aAAa,QAAQ,aAAa,qBACxD,cAAc,QAAQ,OAAO,KAC7B,eAAe,QAAQ,QAAQ,OAAO,KAAK,GAC1C;AACD,qBAAe,IAAI,QAAQ,YAAY,QAAQ,QAAQ;AAAA,IACxD;AAAA,EACD;AACA,MAAI,eAAe,SAAS,EAAG,QAAO;AAEtC,SAAO,SAAS;AAAA,IACf,CAAC,YACA,QAAQ,SAAS,eACjB,QAAQ,QAAQ;AAAA,MACf,CAAC,YACA,QAAQ,SAAS,cACjB,eAAe,IAAI,QAAQ,EAAE,MAAM,QAAQ,QAC3C,oBAAoB,QAAQ,SAAS,KACrC,eAAe,QAAQ,UAAU,OAAO,KAAK;AAAA,IAC/C;AAAA,EACF;AACD;AAEA,SAAS,oBAAoB,OAAgD;AAC5E,MAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,EAAG,QAAO;AAChF,SAAO,YAAa,MAAkC,KAAK;AAC5D;AAMA,SAAS,qBACR,SACgC;AAChC,SAAO,QAAQ,SAAS,YAAY,QAAQ,eAAe;AAC5D;AAEA,SAAS,sBAAsB,SAAsC;AACpE,SACC,OAAO,QAAQ,YAAY,YAC3B,QAAQ,YAAY,QACpB,CAAC,MAAM,QAAQ,QAAQ,OAAO,KAC7B,QAAQ,QAAoC,YAAY;AAE3D;AAEA,SAAS,oBAAoB,UAAwD;AACpF,QAAM,WAAW,uBAAuB,QAAQ;AAChD,SAAO,aAAa,IAAI,SAAY,KAAK,UAAU,SAAS,MAAM,GAAG,QAAQ,CAAC;AAC/E;AAEA,SAAS,uBAAuB,UAA4C;AAC3E,MAAI,QAAQ;AACZ,SAAO,QAAQ,SAAS,QAAQ;AAC/B,UAAM,OAAO,SAAS,KAAK,GAAG;AAC9B,QAAI,SAAS,uBAAuB,SAAS,gBAAiB;AAC9D,aAAS;AAAA,EACV;AACA,SAAO;AACR;AAEA,SAAS,cAAc,OAAkC;AACxD,aAAW,CAAC,OAAO,IAAI,KAAK,MAAM,QAAQ,GAAG;AAC5C,QAAI,KAAK,KAAK,KAAK,EAAE,WAAW,GAAG;AAClC,YAAM,IAAI,MAAM,aAAa,QAAQ,CAAC,oCAAoC;AAAA,IAC3E;AAAA,EACD;AAEA,QAAM,eAAe,MAAM,OAAO,CAAC,SAAS,KAAK,WAAW,aAAa,EAAE;AAC3E,MAAI,eAAe,GAAG;AACrB,UAAM,IAAI,MAAM,qDAAqD;AAAA,EACtE;AACD;AAEA,SAAS,iBAAiB,SAA8C;AACvE,MAAI,WAAuB,CAAC;AAC5B,aAAW,SAAS,SAAS;AAC5B,QAAI,MAAM,SAAS,UAAW;AAC9B,UAAM,UAAU,MAAM;AACtB,QACC,QAAQ,SAAS,gBAChB,QAAQ,aAAa,aAAa,QAAQ,aAAa,kBACvD;AACD;AAAA,IACD;AACA,QAAI,CAAC,cAAc,QAAQ,OAAO,EAAG;AACrC,eAAW,WAAW,QAAQ,QAAQ,KAAK;AAAA,EAC5C;AACA,SAAO;AACR;AAEA,SAAS,cAAc,OAAsC;AAC5D,MAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,EAAG,QAAO;AAChF,QAAM,SAAS;AACf,SAAO,OAAO,YAAY,wBAAwB,YAAY,OAAO,KAAK;AAC3E;AAEA,SAAS,YAAY,OAAqC;AACzD,MAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,eAAgB,QAAO;AAEnE,MAAI,eAAe;AACnB,aAAW,QAAQ,OAAO;AACzB,QAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,MAAM,QAAQ,IAAI,EAAG,QAAO;AAC7E,UAAM,YAAY;AAClB,QACC,OAAO,UAAU,SAAS,YAC1B,UAAU,KAAK,WAAW,KAC1B,UAAU,KAAK,SAAS,wBACxB,UAAU,KAAK,KAAK,EAAE,WAAW,KACjC,CAAC,cAAc,SAAS,UAAU,MAAoB,GACrD;AACD,aAAO;AAAA,IACR;AACA,QAAI,UAAU,WAAW,cAAe,iBAAgB;AAAA,EACzD;AACA,SAAO,gBAAgB;AACxB;AAEA,SAAS,eAAe,MAA2B,OAAqC;AACvF,SACC,KAAK,WAAW,MAAM,UACtB,KAAK;AAAA,IACJ,CAAC,MAAM,UAAU,KAAK,SAAS,MAAM,KAAK,GAAG,QAAQ,KAAK,WAAW,MAAM,KAAK,GAAG;AAAA,EACpF;AAEF;AAEA,SAAS,WAAW,OAAwC;AAC3D,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,QAAQ,KAAK,OAAO,EAAE;AACtE;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Pi extension that gives coding agents a persistent session todo widget.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
|
55
|
-
"url": "
|
|
55
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
56
56
|
"directory": "packages/pi-todo"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/todo-widget.ts
CHANGED
|
@@ -56,6 +56,7 @@ const TodoParameters = Type.Object({
|
|
|
56
56
|
export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
57
57
|
let activeSession: ExtensionContext["sessionManager"] | undefined;
|
|
58
58
|
let items: TodoItem[] = [];
|
|
59
|
+
let restoredBoundary: { summaryEpoch: string; content: string } | undefined;
|
|
59
60
|
|
|
60
61
|
const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
|
|
61
62
|
|
|
@@ -128,12 +129,21 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
128
129
|
pi.on("session_start", (_event, ctx) => {
|
|
129
130
|
activeSession = ctx.sessionManager;
|
|
130
131
|
items = reconstructItems(ctx.sessionManager.getBranch());
|
|
132
|
+
restoredBoundary = undefined;
|
|
131
133
|
publish(ctx);
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
pi.on("context", (event, ctx) => {
|
|
135
137
|
if (!ownsSession(ctx)) return;
|
|
136
|
-
const
|
|
138
|
+
const summaryEpoch = leadingSummaryEpoch(event.messages);
|
|
139
|
+
if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;
|
|
140
|
+
const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
|
|
141
|
+
if (restoredBoundary === undefined && summaryEpoch) {
|
|
142
|
+
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
143
|
+
if (isTodoContextMessage(boundaryMessage)) {
|
|
144
|
+
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
137
147
|
if (messages !== event.messages) return { messages };
|
|
138
148
|
});
|
|
139
149
|
|
|
@@ -147,6 +157,7 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
147
157
|
if (!ownsSession(ctx)) return;
|
|
148
158
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
149
159
|
items = [];
|
|
160
|
+
restoredBoundary = undefined;
|
|
150
161
|
activeSession = undefined;
|
|
151
162
|
});
|
|
152
163
|
}
|
|
@@ -195,25 +206,30 @@ export function renderTodoWidget(
|
|
|
195
206
|
export function reconcileTodoContext(
|
|
196
207
|
messages: ContextEvent["messages"],
|
|
197
208
|
items: readonly TodoItem[],
|
|
209
|
+
restoredBoundaryContent?: string,
|
|
198
210
|
): ContextEvent["messages"] {
|
|
199
211
|
const existing = messages.filter(isTodoContextMessage);
|
|
200
212
|
const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
|
|
201
|
-
const
|
|
213
|
+
const summaryBoundary = leadingSummaryBoundary(withoutExisting);
|
|
214
|
+
const currentContent =
|
|
202
215
|
items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)
|
|
203
216
|
? todoContextContent(items)
|
|
204
217
|
: undefined;
|
|
218
|
+
const content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;
|
|
205
219
|
if (
|
|
220
|
+
content !== undefined &&
|
|
206
221
|
existing.length === 1 &&
|
|
207
|
-
messages
|
|
208
|
-
existing[0]?.content === content
|
|
222
|
+
messages[summaryBoundary] === existing[0] &&
|
|
223
|
+
existing[0]?.content === content &&
|
|
224
|
+
hasTodoContextVersion(existing[0])
|
|
209
225
|
) {
|
|
210
226
|
return messages;
|
|
211
227
|
}
|
|
212
228
|
if (existing.length === 0 && content === undefined) return messages;
|
|
213
|
-
|
|
214
229
|
if (content === undefined) return withoutExisting;
|
|
230
|
+
|
|
215
231
|
return [
|
|
216
|
-
...withoutExisting,
|
|
232
|
+
...withoutExisting.slice(0, summaryBoundary),
|
|
217
233
|
{
|
|
218
234
|
role: "custom",
|
|
219
235
|
customType: TODO_CONTEXT_MESSAGE_TYPE,
|
|
@@ -222,6 +238,7 @@ export function reconcileTodoContext(
|
|
|
222
238
|
details: { version: TODO_CONTEXT_VERSION },
|
|
223
239
|
timestamp: 0,
|
|
224
240
|
},
|
|
241
|
+
...withoutExisting.slice(summaryBoundary),
|
|
225
242
|
];
|
|
226
243
|
}
|
|
227
244
|
|
|
@@ -277,12 +294,40 @@ function isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {
|
|
|
277
294
|
return isTodoItems((value as Record<string, unknown>).items);
|
|
278
295
|
}
|
|
279
296
|
|
|
297
|
+
type TodoContextMessage = Extract<ContextEvent["messages"][number], { role: "custom" }> & {
|
|
298
|
+
content: string;
|
|
299
|
+
};
|
|
300
|
+
|
|
280
301
|
function isTodoContextMessage(
|
|
281
302
|
message: ContextEvent["messages"][number],
|
|
282
|
-
): message is
|
|
303
|
+
): message is TodoContextMessage {
|
|
283
304
|
return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
|
|
284
305
|
}
|
|
285
306
|
|
|
307
|
+
function hasTodoContextVersion(message: TodoContextMessage): boolean {
|
|
308
|
+
return (
|
|
309
|
+
typeof message.details === "object" &&
|
|
310
|
+
message.details !== null &&
|
|
311
|
+
!Array.isArray(message.details) &&
|
|
312
|
+
(message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function leadingSummaryEpoch(messages: ContextEvent["messages"]): string | undefined {
|
|
317
|
+
const boundary = leadingSummaryBoundary(messages);
|
|
318
|
+
return boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function leadingSummaryBoundary(messages: ContextEvent["messages"]): number {
|
|
322
|
+
let index = 0;
|
|
323
|
+
while (index < messages.length) {
|
|
324
|
+
const role = messages[index]?.role;
|
|
325
|
+
if (role !== "compactionSummary" && role !== "branchSummary") break;
|
|
326
|
+
index += 1;
|
|
327
|
+
}
|
|
328
|
+
return index;
|
|
329
|
+
}
|
|
330
|
+
|
|
286
331
|
function validateItems(items: readonly TodoItem[]): void {
|
|
287
332
|
for (const [index, item] of items.entries()) {
|
|
288
333
|
if (item.text.trim().length === 0) {
|