@davideasden/pi-undo 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/pi-undo.ts +168 -15
- package/package.json +1 -1
- package/src/controller.ts +8 -3
package/extensions/pi-undo.ts
CHANGED
|
@@ -27,13 +27,32 @@ export type PiUndoRuntimeFactory = (
|
|
|
27
27
|
pi: ExtensionAPI,
|
|
28
28
|
) => Promise<PiUndoRuntime>;
|
|
29
29
|
|
|
30
|
+
type DeferredImage = NonNullable<InputEvent["images"]>[number];
|
|
31
|
+
|
|
32
|
+
interface DeferredPrompt {
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly images?: readonly DeferredImage[];
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
|
|
31
38
|
return (pi) => {
|
|
32
39
|
let runtime: PiUndoRuntime | undefined;
|
|
40
|
+
let runtimeContext: ExtensionContext | undefined;
|
|
33
41
|
let generation = 0;
|
|
42
|
+
let deferredPrompts: DeferredPrompt[] = [];
|
|
43
|
+
let replaying: DeferredPrompt | undefined;
|
|
44
|
+
let acceptedReplay: DeferredPrompt | undefined;
|
|
45
|
+
let activeCommands = new Set<symbol>();
|
|
46
|
+
let activeAction: "undo" | "redo" | undefined;
|
|
34
47
|
|
|
35
48
|
const initialize = async (context: ExtensionContext): Promise<void> => {
|
|
36
49
|
const currentGeneration = ++generation;
|
|
50
|
+
runtimeContext = context;
|
|
51
|
+
deferredPrompts = [];
|
|
52
|
+
replaying = undefined;
|
|
53
|
+
acceptedReplay = undefined;
|
|
54
|
+
activeCommands = new Set<symbol>();
|
|
55
|
+
activeAction = undefined;
|
|
37
56
|
try {
|
|
38
57
|
const next = await runtimeFactory(context, pi);
|
|
39
58
|
if (currentGeneration !== generation) return;
|
|
@@ -49,6 +68,57 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
49
68
|
}
|
|
50
69
|
};
|
|
51
70
|
|
|
71
|
+
const dispatchDeferredPrompt = (active: PiUndoRuntime, expectedGeneration: number): void => {
|
|
72
|
+
if (
|
|
73
|
+
expectedGeneration !== generation || runtime !== active || activeCommands.size > 0 ||
|
|
74
|
+
replaying !== undefined || deferredPrompts.length === 0 || active.controller.history().locked
|
|
75
|
+
) return;
|
|
76
|
+
const prompt = deferredPrompts[0]!;
|
|
77
|
+
replaying = prompt;
|
|
78
|
+
acceptedReplay = undefined;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (expectedGeneration !== generation || runtime !== active || replaying !== prompt) return;
|
|
81
|
+
try {
|
|
82
|
+
pi.sendUserMessage(prompt.images === undefined || prompt.images.length === 0
|
|
83
|
+
? prompt.text
|
|
84
|
+
: [{ type: "text" as const, text: prompt.text }, ...prompt.images]);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
replaying = undefined;
|
|
87
|
+
acceptedReplay = undefined;
|
|
88
|
+
deferredPrompts.shift();
|
|
89
|
+
restoreEditorText(runtimeContext, prompt.text);
|
|
90
|
+
runtimeContext?.ui.notify(`Unable to replay queued prompt: ${errorMessage(error)}`, "warning");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const restoreDeferredPrompts = (context: ExtensionContext | undefined): void => {
|
|
96
|
+
if (deferredPrompts.length === 0) return;
|
|
97
|
+
const prompts = deferredPrompts.splice(0);
|
|
98
|
+
replaying = undefined;
|
|
99
|
+
acceptedReplay = undefined;
|
|
100
|
+
restoreEditorText(context, prompts.map((prompt) => prompt.text).join("\n\n"));
|
|
101
|
+
if (prompts.some((prompt) => (prompt.images?.length ?? 0) > 0)) {
|
|
102
|
+
context?.ui.notify("Queued prompt text restored; image attachments must be reattached", "warning");
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const resumeDeferredPrompts = (
|
|
107
|
+
active: PiUndoRuntime,
|
|
108
|
+
expectedGeneration: number,
|
|
109
|
+
lockedReason: string,
|
|
110
|
+
): void => {
|
|
111
|
+
if (expectedGeneration !== generation || runtime !== active) return;
|
|
112
|
+
const history = active.controller.history();
|
|
113
|
+
if (history.locked) {
|
|
114
|
+
active.reporter.setRecoveryRequired(lockedReason);
|
|
115
|
+
restoreDeferredPrompts(runtimeContext);
|
|
116
|
+
} else {
|
|
117
|
+
active.reporter.setReady(history.undoCount, history.redoCount);
|
|
118
|
+
dispatchDeferredPrompt(active, expectedGeneration);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
52
122
|
const runCommand = async (
|
|
53
123
|
action: "undo" | "redo",
|
|
54
124
|
context: ExtensionCommandContext,
|
|
@@ -59,6 +129,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
59
129
|
context.ui.notify("pi-undo session unavailable", "warning");
|
|
60
130
|
return;
|
|
61
131
|
}
|
|
132
|
+
const commandToken = Symbol(action);
|
|
133
|
+
const commandSet = activeCommands;
|
|
134
|
+
commandSet.add(commandToken);
|
|
135
|
+
activeAction = action;
|
|
62
136
|
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
63
137
|
active.setCommandContext?.(context);
|
|
64
138
|
let result;
|
|
@@ -68,15 +142,17 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
68
142
|
: await active.controller.redo();
|
|
69
143
|
} finally {
|
|
70
144
|
active.setCommandContext?.(undefined);
|
|
145
|
+
commandSet.delete(commandToken);
|
|
146
|
+
if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
|
|
71
147
|
}
|
|
72
148
|
if (commandGeneration !== generation || runtime !== active) return;
|
|
73
149
|
active.reporter.result(result);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
|
|
151
|
+
if (
|
|
152
|
+
action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
|
|
153
|
+
!hasDeferredPrompt
|
|
154
|
+
) active.reporter.refillPrompt(result.refillPrompt);
|
|
155
|
+
resumeDeferredPrompts(active, commandGeneration, result.message ?? result.code);
|
|
80
156
|
};
|
|
81
157
|
|
|
82
158
|
const runDiff = async (args: string, context: ExtensionCommandContext): Promise<void> => {
|
|
@@ -132,19 +208,69 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
132
208
|
});
|
|
133
209
|
|
|
134
210
|
pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
|
|
135
|
-
pi.on("input", async (event: InputEvent) => {
|
|
136
|
-
|
|
137
|
-
|
|
211
|
+
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
|
212
|
+
const active = runtime;
|
|
213
|
+
if (active === undefined) return { action: "handled" as const };
|
|
214
|
+
const result = await active.controller.prepareInput(event.text, {
|
|
215
|
+
streaming: event.streamingBehavior !== undefined,
|
|
216
|
+
});
|
|
217
|
+
const replay = replaying;
|
|
218
|
+
if (result.action === "defer") {
|
|
219
|
+
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
220
|
+
replaying = undefined;
|
|
221
|
+
acceptedReplay = undefined;
|
|
222
|
+
active.reporter.setPhase(`${activeAction ?? "operation"} queued:${deferredPrompts.length}`);
|
|
223
|
+
return { action: "handled" as const };
|
|
224
|
+
}
|
|
225
|
+
if (event.text.trimStart().startsWith("/")) {
|
|
226
|
+
restoreEditorText(context, event.text);
|
|
227
|
+
context.ui.notify("Command input preserved until undo/redo completes", "info");
|
|
228
|
+
return { action: "handled" as const };
|
|
229
|
+
}
|
|
230
|
+
deferredPrompts.push({
|
|
231
|
+
text: event.text,
|
|
232
|
+
...(event.images === undefined ? {} : { images: event.images.map((image) => ({ ...image })) }),
|
|
233
|
+
});
|
|
234
|
+
active.reporter.setPhase(`${activeAction ?? "operation"} queued:${deferredPrompts.length}`);
|
|
235
|
+
return { action: "handled" as const };
|
|
236
|
+
}
|
|
237
|
+
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
238
|
+
if (result.action === "continue") {
|
|
239
|
+
acceptedReplay = replay;
|
|
240
|
+
} else {
|
|
241
|
+
removeDeferredPrompt(deferredPrompts, replay);
|
|
242
|
+
replaying = undefined;
|
|
243
|
+
acceptedReplay = undefined;
|
|
244
|
+
restoreEditorText(context, replay.text);
|
|
245
|
+
if ((replay.images?.length ?? 0) > 0) {
|
|
246
|
+
context.ui.notify("Queued prompt text restored; image attachments must be reattached", "warning");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
});
|
|
252
|
+
pi.on("before_agent_start", async () => {
|
|
253
|
+
const active = runtime;
|
|
254
|
+
const startGeneration = generation;
|
|
255
|
+
if (active === undefined) return;
|
|
256
|
+
await active.controller.beforeAgentStart();
|
|
257
|
+
const replay = replaying;
|
|
258
|
+
if (
|
|
259
|
+
runtime === active && generation === startGeneration && replay !== undefined &&
|
|
260
|
+
acceptedReplay === replay
|
|
261
|
+
) {
|
|
262
|
+
removeDeferredPrompt(deferredPrompts, replay);
|
|
263
|
+
replaying = undefined;
|
|
264
|
+
acceptedReplay = undefined;
|
|
265
|
+
}
|
|
138
266
|
});
|
|
139
|
-
pi.on("before_agent_start", async () => { await runtime?.controller.beforeAgentStart(); });
|
|
140
267
|
pi.on("agent_settled", async () => {
|
|
141
268
|
const active = runtime;
|
|
269
|
+
const settledGeneration = generation;
|
|
142
270
|
if (active === undefined) return;
|
|
143
271
|
await active.controller.agentSettled();
|
|
144
|
-
if (runtime !== active) return;
|
|
145
|
-
|
|
146
|
-
if (history.locked) active.reporter.setRecoveryRequired("session state ambiguous");
|
|
147
|
-
else active.reporter.setReady(history.undoCount, history.redoCount);
|
|
272
|
+
if (runtime !== active || generation !== settledGeneration) return;
|
|
273
|
+
resumeDeferredPrompts(active, settledGeneration, "session state ambiguous");
|
|
148
274
|
});
|
|
149
275
|
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
150
276
|
if (runtime === undefined) return { cancel: true };
|
|
@@ -152,7 +278,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
152
278
|
const active = runtime;
|
|
153
279
|
const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId });
|
|
154
280
|
if (result === undefined) {
|
|
155
|
-
|
|
281
|
+
event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
|
|
156
282
|
}
|
|
157
283
|
return result;
|
|
158
284
|
});
|
|
@@ -165,6 +291,12 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
165
291
|
});
|
|
166
292
|
pi.on("session_shutdown", async () => {
|
|
167
293
|
generation += 1;
|
|
294
|
+
deferredPrompts = [];
|
|
295
|
+
replaying = undefined;
|
|
296
|
+
acceptedReplay = undefined;
|
|
297
|
+
activeCommands = new Set<symbol>();
|
|
298
|
+
activeAction = undefined;
|
|
299
|
+
runtimeContext = undefined;
|
|
168
300
|
await runtime?.controller.cancelTree?.();
|
|
169
301
|
runtime?.reporter.clear();
|
|
170
302
|
runtime = undefined;
|
|
@@ -172,6 +304,27 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
172
304
|
};
|
|
173
305
|
}
|
|
174
306
|
|
|
307
|
+
function samePrompt(
|
|
308
|
+
text: string,
|
|
309
|
+
images: readonly DeferredImage[] | undefined,
|
|
310
|
+
prompt: DeferredPrompt,
|
|
311
|
+
): boolean {
|
|
312
|
+
if (text !== prompt.text || (images?.length ?? 0) !== (prompt.images?.length ?? 0)) return false;
|
|
313
|
+
return (images ?? []).every((image, index) => JSON.stringify(image) === JSON.stringify(prompt.images?.[index]));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function removeDeferredPrompt(prompts: DeferredPrompt[], prompt: DeferredPrompt): void {
|
|
317
|
+
const index = prompts.indexOf(prompt);
|
|
318
|
+
if (index >= 0) prompts.splice(index, 1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function restoreEditorText(context: ExtensionContext | undefined, text: string): void {
|
|
322
|
+
if (context === undefined || text.length === 0) return;
|
|
323
|
+
const current = context.ui.getEditorText();
|
|
324
|
+
if (current === text || current.startsWith(`${text}\n\n`)) return;
|
|
325
|
+
context.ui.setEditorText(current.length === 0 ? text : `${text}\n\n${current}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
175
328
|
function errorMessage(error: unknown): string {
|
|
176
329
|
return error instanceof Error ? error.message : String(error);
|
|
177
330
|
}
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -80,9 +80,10 @@ export interface OperationResult {
|
|
|
80
80
|
readonly refillPrompt?: string;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export
|
|
84
|
-
readonly action: "continue"
|
|
85
|
-
}
|
|
83
|
+
export type InputEventResult =
|
|
84
|
+
| { readonly action: "continue" }
|
|
85
|
+
| { readonly action: "handled" }
|
|
86
|
+
| { readonly action: "defer" };
|
|
86
87
|
|
|
87
88
|
export interface InputContext {
|
|
88
89
|
readonly streaming: boolean;
|
|
@@ -163,6 +164,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
163
164
|
private locked = false;
|
|
164
165
|
private historyPaused = false;
|
|
165
166
|
private operationInFlight = false;
|
|
167
|
+
private promptDeferralInFlight = false;
|
|
166
168
|
private lastSafetyManifestId: ManifestId | null = null;
|
|
167
169
|
|
|
168
170
|
constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
|
|
@@ -182,6 +184,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
|
|
187
|
+
if (this.promptDeferralInFlight) return { action: "defer" };
|
|
185
188
|
if (this.locked || this.operationInFlight) return { action: "handled" };
|
|
186
189
|
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
187
190
|
try {
|
|
@@ -382,6 +385,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
382
385
|
): Promise<OperationResult> {
|
|
383
386
|
if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
|
|
384
387
|
this.operationInFlight = true;
|
|
388
|
+
this.promptDeferralInFlight = true;
|
|
385
389
|
this.lastSafetyManifestId = null;
|
|
386
390
|
let lease: { release(): Promise<void> } | undefined;
|
|
387
391
|
try {
|
|
@@ -454,6 +458,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
454
458
|
if (lease !== undefined) {
|
|
455
459
|
await lease.release().catch(() => { this.locked = true; });
|
|
456
460
|
}
|
|
461
|
+
this.promptDeferralInFlight = false;
|
|
457
462
|
this.operationInFlight = false;
|
|
458
463
|
}
|
|
459
464
|
}
|