@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.
@@ -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
- if (action === "undo" && result.code === "ok" && result.refillPrompt !== undefined) {
75
- active.reporter.refillPrompt(result.refillPrompt);
76
- }
77
- const history = active.controller.history();
78
- if (history.locked) active.reporter.setRecoveryRequired(result.message ?? result.code);
79
- else active.reporter.setReady(history.undoCount, history.redoCount);
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
- if (runtime === undefined) return { action: "handled" as const };
137
- return runtime.controller.prepareInput(event.text, { streaming: event.streamingBehavior !== undefined });
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
- const history = active.controller.history();
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
- event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/controller.ts CHANGED
@@ -80,9 +80,10 @@ export interface OperationResult {
80
80
  readonly refillPrompt?: string;
81
81
  }
82
82
 
83
- export interface InputEventResult {
84
- readonly action: "continue" | "handled";
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
  }