@narumitw/pi-todo 0.1.1 → 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 +1 -0
- package/dist/index.ts +46 -3
- package/dist/index.ts.map +2 -2
- package/package.json +1 -1
- package/src/todo-widget.ts +64 -9
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ During ordinary turns, the model reads the complete list from the persisted `upd
|
|
|
81
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.
|
|
84
85
|
The later tool call and result supersede the restored state at the conversation tail without rewriting the earlier provider prefix.
|
|
85
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.
|
|
86
87
|
|
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" };
|
|
@@ -99,10 +104,14 @@ function todoWidgetExtension(pi) {
|
|
|
99
104
|
};
|
|
100
105
|
}
|
|
101
106
|
});
|
|
107
|
+
const restoreBranchState = (ctx) => {
|
|
108
|
+
const branch = ctx.sessionManager.getBranch();
|
|
109
|
+
items = reconstructItems(branch);
|
|
110
|
+
restoredBoundary = reconstructRestoredTodoBoundary(branch);
|
|
111
|
+
};
|
|
102
112
|
pi.on("session_start", (_event, ctx) => {
|
|
103
113
|
activeSession = ctx.sessionManager;
|
|
104
|
-
|
|
105
|
-
restoredBoundary = void 0;
|
|
114
|
+
restoreBranchState(ctx);
|
|
106
115
|
publish(ctx);
|
|
107
116
|
});
|
|
108
117
|
pi.on("context", (event, ctx) => {
|
|
@@ -114,13 +123,17 @@ function todoWidgetExtension(pi) {
|
|
|
114
123
|
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
115
124
|
if (isTodoContextMessage(boundaryMessage)) {
|
|
116
125
|
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
126
|
+
pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
|
|
127
|
+
version: TODO_RESTORED_BOUNDARY_VERSION,
|
|
128
|
+
...restoredBoundary
|
|
129
|
+
});
|
|
117
130
|
}
|
|
118
131
|
}
|
|
119
132
|
if (messages !== event.messages) return { messages };
|
|
120
133
|
});
|
|
121
134
|
pi.on("session_tree", (_event, ctx) => {
|
|
122
135
|
if (!ownsSession(ctx)) return;
|
|
123
|
-
|
|
136
|
+
restoreBranchState(ctx);
|
|
124
137
|
publish(ctx);
|
|
125
138
|
});
|
|
126
139
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
@@ -201,6 +214,36 @@ function todoContextContent(items) {
|
|
|
201
214
|
Current todo list as JSON data:
|
|
202
215
|
${JSON.stringify(items)}`;
|
|
203
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
|
+
}
|
|
204
247
|
function hasModelVisibleTodoState(messages, items) {
|
|
205
248
|
const currentResults = /* @__PURE__ */ new Map();
|
|
206
249
|
for (const message of messages) {
|
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\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;
|
|
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
|
|
|
@@ -126,10 +129,15 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
126
129
|
},
|
|
127
130
|
});
|
|
128
131
|
|
|
132
|
+
const restoreBranchState = (ctx: ExtensionContext): void => {
|
|
133
|
+
const branch = ctx.sessionManager.getBranch();
|
|
134
|
+
items = reconstructItems(branch);
|
|
135
|
+
restoredBoundary = reconstructRestoredTodoBoundary(branch);
|
|
136
|
+
};
|
|
137
|
+
|
|
129
138
|
pi.on("session_start", (_event, ctx) => {
|
|
130
139
|
activeSession = ctx.sessionManager;
|
|
131
|
-
|
|
132
|
-
restoredBoundary = undefined;
|
|
140
|
+
restoreBranchState(ctx);
|
|
133
141
|
publish(ctx);
|
|
134
142
|
});
|
|
135
143
|
|
|
@@ -142,6 +150,10 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
142
150
|
const boundaryMessage = messages[leadingSummaryBoundary(messages)];
|
|
143
151
|
if (isTodoContextMessage(boundaryMessage)) {
|
|
144
152
|
restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
|
|
153
|
+
pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
|
|
154
|
+
version: TODO_RESTORED_BOUNDARY_VERSION,
|
|
155
|
+
...restoredBoundary,
|
|
156
|
+
});
|
|
145
157
|
}
|
|
146
158
|
}
|
|
147
159
|
if (messages !== event.messages) return { messages };
|
|
@@ -149,7 +161,7 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
|
|
|
149
161
|
|
|
150
162
|
pi.on("session_tree", (_event, ctx) => {
|
|
151
163
|
if (!ownsSession(ctx)) return;
|
|
152
|
-
|
|
164
|
+
restoreBranchState(ctx);
|
|
153
165
|
publish(ctx);
|
|
154
166
|
});
|
|
155
167
|
|
|
@@ -258,6 +270,49 @@ Current todo list as JSON data:
|
|
|
258
270
|
${JSON.stringify(items)}`;
|
|
259
271
|
}
|
|
260
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
|
+
|
|
261
316
|
function hasModelVisibleTodoState(
|
|
262
317
|
messages: ContextEvent["messages"],
|
|
263
318
|
items: readonly TodoItem[],
|