@davideasden/pi-undo 0.1.1 → 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/README.md CHANGED
@@ -1,84 +1,215 @@
1
1
  # pi-undo
2
2
 
3
- `pi-undo` Pi 0.80.10 提供持久化的 `/undo` `/redo`。一次操作对应一个完整 Agent run,并同时恢复 Pi session tree 的逻辑位置和工作区文件。
3
+ Persistent undo and redo for [Pi](https://github.com/badlogic/pi-mono) that restores both the agent session and workspace files without requiring users to manage Git.
4
4
 
5
- 实现使用 Pi session JSONL、私有 snapshot store WAL journal。它不会在项目中创建 `.git`,也不会要求用户执行 `git commit`、`git stash`、`git reset` 或维护隐藏分支。
5
+ Each history entry represents one completed agent run. When you undo or redo, `pi-undo` moves Pi's session tree to the matching point and restores the corresponding workspace state.
6
6
 
7
- ## 安装与本地试用
7
+ ## Features
8
8
 
9
- 安装本地 package:
9
+ - Persistent `/undo` and `/redo` across Pi restarts.
10
+ - Restores the Pi session and workspace files together.
11
+ - Preserves redo state with a safety snapshot taken immediately before undo.
12
+ - Integrates with Pi's native `/tree` navigation.
13
+ - Supports ordinary directories, Git repositories, nested repositories, and initialized submodules.
14
+ - Uses durable journals and restart recovery for interrupted operations.
15
+ - Detects conflicting external file changes and fails closed instead of overwriting unknown content.
16
+ - Does not require `git commit`, `git stash`, `git reset`, hidden branches, or any other user-managed Git workflow.
17
+
18
+ ## Requirements
19
+
20
+ - Pi `0.80.10` or a compatible release.
21
+ - Node.js `22.19.0` or later.
22
+ - Git available on `PATH`.
23
+
24
+ Git is used internally to create private, content-addressed snapshots. You do not need to initialize a repository or run Git commands yourself.
25
+
26
+ ## Installation
27
+
28
+ Install the published Pi package from npm:
29
+
30
+ ```bash
31
+ pi install npm:@davideasden/pi-undo
32
+ ```
33
+
34
+ Restart Pi after installation if the extension is not loaded in the current session.
35
+
36
+ To install a local checkout instead:
10
37
 
11
38
  ```bash
12
39
  pi install ./path/to/pi-undo
13
40
  ```
14
41
 
15
- 开发时也可以直接加载 extension
42
+ During development, you can load the extension directly:
16
43
 
17
44
  ```bash
18
45
  pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
19
46
  ```
20
47
 
21
- 要求 Node.js 22.19 或更高版本,并且系统可执行 `git`。
48
+ ## Usage
49
+
50
+ Work with Pi normally. `pi-undo` records a boundary after each completed agent run.
51
+
52
+ ### Diff
53
+
54
+ ```text
55
+ /diff
56
+ ```
57
+
58
+ `/diff` reviews the files changed by the most recent completed agent run. In TUI mode, select a file to open a colored, scrollable before-and-after comparison. Binary files are listed but do not receive a line-by-line diff.
59
+
60
+ Use a one-based history position to inspect an earlier applied run, where `1` is the most recent:
61
+
62
+ ```text
63
+ /diff 2
64
+ ```
65
+
66
+ Print, JSON, and RPC modes report a one-line file and line-count summary instead of opening the interactive viewer.
67
+
68
+ ### Undo
69
+
70
+ ```text
71
+ /undo
72
+ ```
73
+
74
+ `/undo` returns to the previous completed agent run on the current branch. Before restoring that checkpoint, it captures the current workspace as a redo safety snapshot.
22
75
 
23
- ## 使用方式
76
+ After a successful undo, `pi-undo` tries to put the original prompt back into an empty TUI editor. It never overwrites text already present in the editor. RPC mode only reports the refill request, while print and JSON modes do not promise editor refill behavior.
24
77
 
25
- - `/undo`:回退当前 branch 上最后一个完整 Agent run。执行前会保存当前工作区作为 redo safety snapshot。
26
- - `/redo`:恢复最近一次 undo 前保存的工作区状态,而不是简单覆盖成 checkpoint 的原始 after snapshot。
27
- - `/tree`:仍使用 Pi 原生命令。package 在导航前预检目标 boundary 并保存 rescue snapshot,导航完成后恢复该 boundary 对应的文件状态。
78
+ ### Redo
28
79
 
29
- 如果 Agent 正在 streaming,`/undo` 和 `/redo` 会先请求 abort,再等待 Agent idle;等待超时或工具仍未停止时不会恢复文件。streaming 中的普通 `/tree` 会被取消,用户可在 idle 后重试。
80
+ ```text
81
+ /redo
82
+ ```
30
83
 
31
- undo 成功后,TUI 编辑器为空时会尝试回填原始 prompt;编辑器已有文字时不会覆盖用户输入。RPC 只报告回填请求,print/json 模式不承诺编辑器回填。
84
+ `/redo` restores the session and workspace captured immediately before the corresponding undo. It does not simply reapply the checkpoint's original after-snapshot, so edits preserved by the redo safety snapshot are restored correctly.
32
85
 
33
- ## 数据与 Git 边界
86
+ ### Tree Navigation
34
87
 
35
- 私有数据位于 Pi session 目录下:
88
+ Continue to use Pi's native command:
89
+
90
+ ```text
91
+ /tree
92
+ ```
93
+
94
+ Before Pi moves to another session-tree boundary, `pi-undo` validates the target and records a rescue snapshot. After navigation, it restores the workspace associated with the selected boundary.
95
+
96
+ If the agent is streaming, `/undo` and `/redo` request an abort and wait for the agent to become idle. If the wait times out or tools are still running, no files are restored. Native `/tree` navigation is cancelled while streaming and can be retried when Pi is idle.
97
+
98
+ The footer shows the available history, for example:
99
+
100
+ ```text
101
+ undo:2 redo:1
102
+ ```
103
+
104
+ ## How It Works
105
+
106
+ Private state is stored alongside the Pi session:
36
107
 
37
108
  ```text
38
109
  <sessionDir>/.pi-undo/
39
110
  ```
40
111
 
41
- snapshot 使用独立 Git object database 和临时 index,但不会写入用户仓库的 `HEAD`、index、refs、reflog、stash、config 或其他真实 Git metadata。
112
+ For every completed agent run, `pi-undo` captures the session boundary and a workspace manifest. Snapshots use a private Git object database and temporary index. They do not use the repository's normal history.
113
+
114
+ Undo, redo, and tree restoration follow the same high-level flow:
115
+
116
+ 1. Validate the current session, workspace topology, and target manifest.
117
+ 2. Write a durable write-ahead log (WAL) before changing files.
118
+ 3. Capture a safety or rescue snapshot when required.
119
+ 4. Move the Pi session to the target logical boundary.
120
+ 5. Restore workspace paths with fingerprint checks and no-clobber installation.
121
+ 6. Verify the resulting session and workspace before committing the journal.
42
122
 
43
- restore 对普通文件和 symlink 使用原路径同目录、同文件系统的 quarantine。目标安装采用 no-clobber 语义:外部进程在 restore 期间重建路径时,`pi-undo` 不会覆盖该路径,并会保留 transaction mutation WAL 与仍可验证的 source/target artifact,供重启恢复或人工诊断。
123
+ Ordinary files and symbolic links are restored through same-directory, same-filesystem quarantine artifacts. For regular files, source capture uses a hard link and checks the path fingerprint and inode identity again immediately before removing the original path. This narrows the external-concurrency window, but cannot make the final check and unlink operation atomic in pure Node.js.
44
124
 
45
- 普通文件的 source capture 先创建 hard link,再在删除原路径前重新检查路径 fingerprint 和 inode identity。这是纯 Node.js 能提供的 CAS-before-unlink best-effort;它显著缩小并发窗口,但不能原子合并最后一次检查与 `unlink`。
125
+ ## Safety and Recovery
46
126
 
47
- 普通目录、outer repository、nested repository 和已初始化 submodule 都按 root forest 独立捕获和恢复。nested repository/submodule 只恢复内部工作区文件:
127
+ `pi-undo` does not modify the user's Git `HEAD`, index, refs, reflogs, stash, configuration, or other repository metadata. Nested repositories and initialized submodules are captured as independent roots, but only their working files are restored. The extension does not switch their real `HEAD`, run `git submodule update`, or recreate deleted `.git` metadata.
48
128
 
49
- - 不切换真实 HEAD;
50
- - 不运行 `git submodule update`;
51
- - 不修改真实 index、refs、reflog 或 `.git`;
52
- - 不重建被删除的真实 `.git`。
129
+ If Pi or the process stops during a restore, the next session startup reads the transaction journal:
53
130
 
54
- ## 持久化与故障恢复
131
+ - Without a trusted cursor marker, it restores the rollback manifest and marks the transaction aborted.
132
+ - With a trusted cursor marker, it restores the target manifest, completes cursor durability, and commits the transaction.
133
+ - If the journal, session identity, logical leaf, manifest, cursor, or workspace contents conflict, it enters `recovery required` and blocks further history mutation.
55
134
 
56
- 每次 undo、redo tree restore 都先写 WAL journal。Pi 重启后:
135
+ When the footer reports `recovery_required`, first back up the workspace and Pi session JSONL. Transaction diagnostics are stored in:
136
+
137
+ ```text
138
+ <sessionDir>/.pi-undo/transactions/
139
+ ```
57
140
 
58
- - 没有可信 cursor marker:恢复 rollback manifest,并把操作终止为 `ABORTED`;
59
- - 有可信 cursor marker:重新恢复 target manifest、补齐 cursor durability,并把操作完成为 `COMMITTED`;
60
- - journal、session identity、logical leaf、manifest 或 cursor payload 不一致:进入 `recovery required`,停止新的 undo/redo、tree mutation 和 Agent boundary。
141
+ The relevant transaction may contain `descriptor.json`, `restore-plan.json`, `state.json`, and mutation journal data. Do not delete the `.pi-undo` directory without a backup: unresolved quarantine artifacts may contain the only preserved copy of a file version.
61
142
 
62
- 故障 journal 会保留在 `<sessionDir>/.pi-undo/transactions/`,用于诊断和人工恢复。不要在未备份的情况下删除该目录。footer 显示 `recovery required` 时,应先保存工作区和 session JSONL,再检查对应 transaction 的 `descriptor.json`、`restore-plan.json` 和 `state.json`。
143
+ ## Limitations
63
144
 
64
- ## 明确限制
145
+ - Git-ignored files are not included in snapshots and are not created or deleted during restore.
146
+ - Empty directories are not represented in snapshots.
147
+ - Real `.git` metadata is never restored.
148
+ - The workspace lock coordinates `pi-undo` instances, but cannot prevent editors, watchers, or other processes from writing files concurrently.
149
+ - Quarantine and repeated fingerprint checks reduce, but cannot eliminate, the final check-to-unlink race with arbitrary external processes.
150
+ - When ownership or contents cannot be proven, `pi-undo` preserves the available versions and enters `recovery required` instead of guessing, deleting, or overwriting.
151
+ - `--no-session` mode has no durable session cursor. Undo and redo can work in-process, but crash persistence is not guaranteed.
152
+ - Uninitialized gitlinks are not initialized automatically. A broken nested repository causes capture to fail instead of being silently skipped.
153
+ - Workspace files and Pi JSONL are not an operating-system-level ACID transaction. Snapshots, WAL records, cursor markers, verification, and idempotent recovery are used to converge to the old or new state.
65
154
 
66
- - Git ignored 文件不进入 snapshot,也不会被 restore 创建或删除。
67
- - 空目录不属于 snapshot。
68
- - package 不恢复真实 `.git` metadata。
69
- - package 锁只能协调其他 `pi-undo` 实例,不能阻止外部编辑器、watcher 或其他进程同时写文件;无法证明安全时会 fail closed。
70
- - quarantine 不能消除任意外部进程带来的最终 `check -> unlink` TOCTOU 窗口。无法证明 original、target 或 artifact 的内容与归属时,package 会保留现场并进入 `recovery required`,不会猜测、强制删除或覆盖未知内容。
71
- - `--no-session` 模式没有可耐久验证的 session cursor,只提供进程内能力,不能承诺进程崩溃后的 undo/redo 持久化。
72
- - uninitialized gitlink 不会被自动初始化;broken nested root 会使 capture 失败,而不是静默遗漏。
73
- - package 不对工作区文件与 Pi JSONL 提供操作系统级 ACID 事务;它通过 snapshot、WAL、cursor marker 和幂等 set-state recovery 收敛到旧状态或新状态。
155
+ ## Development
74
156
 
75
- ## 开发验证
157
+ Clone the repository and install dependencies:
158
+
159
+ ```bash
160
+ git clone https://github.com/DavidEasden/pi-undo.git
161
+ cd pi-undo
162
+ npm install
163
+ ```
164
+
165
+ The development dependency for Pi points to:
166
+
167
+ ```text
168
+ resources/pi-0.80.10/packages/coding-agent
169
+ ```
170
+
171
+ For type checking and the real `AgentSession` integration tests, place the Pi `0.80.10` source tree at that path, install its workspace dependencies, and build the required workspace packages. The published package does not include `resources/`.
172
+
173
+ Useful project paths:
174
+
175
+ ```text
176
+ extensions/pi-undo.ts Pi extension entry point
177
+ src/ Snapshot, journal, restore, and runtime implementation
178
+ test/ Unit, integration, recovery, and fault-injection tests
179
+ ```
180
+
181
+ ## Testing
182
+
183
+ Run the complete test suite:
76
184
 
77
185
  ```bash
78
186
  npm test
187
+ ```
188
+
189
+ Run tests in watch mode:
190
+
191
+ ```bash
192
+ npm run test:watch
193
+ ```
194
+
195
+ Run the Pi runtime and extension integration tests:
196
+
197
+ ```bash
79
198
  npm run test:integration
199
+ ```
200
+
201
+ Run TypeScript type checking:
202
+
203
+ ```bash
80
204
  npm run typecheck
205
+ ```
206
+
207
+ Inspect the npm package contents before publishing:
208
+
209
+ ```bash
81
210
  npm run pack:dry-run
82
211
  ```
83
212
 
84
- 真实 Pi `AgentSession` 集成测试需要先安装并构建 `resources/pi-0.80.10` 的 workspace packages。
213
+ ## License
214
+
215
+ [MIT](LICENSE)
@@ -8,12 +8,15 @@ import type {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import type { UndoController } from "../src/controller.ts";
11
+ import { browseDiff } from "../src/diff-ui.ts";
12
+ import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
11
13
  import { createPiUndoRuntime } from "../src/pi-runtime.ts";
12
14
  import { StatusReporter } from "../src/status-reporter.ts";
13
15
 
14
16
  export interface PiUndoRuntime {
15
17
  readonly controller: UndoController;
16
18
  readonly reporter: StatusReporter;
19
+ readonly diffSource?: DiffSource;
17
20
  readonly recovery?: { readonly files?: number; readonly opId?: string };
18
21
  setCommandContext?(context: ExtensionCommandContext | undefined): void;
19
22
  isInternalNavigation?(): boolean;
@@ -24,13 +27,32 @@ export type PiUndoRuntimeFactory = (
24
27
  pi: ExtensionAPI,
25
28
  ) => Promise<PiUndoRuntime>;
26
29
 
30
+ type DeferredImage = NonNullable<InputEvent["images"]>[number];
31
+
32
+ interface DeferredPrompt {
33
+ readonly text: string;
34
+ readonly images?: readonly DeferredImage[];
35
+ }
36
+
27
37
  export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
28
38
  return (pi) => {
29
39
  let runtime: PiUndoRuntime | undefined;
40
+ let runtimeContext: ExtensionContext | undefined;
30
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;
31
47
 
32
48
  const initialize = async (context: ExtensionContext): Promise<void> => {
33
49
  const currentGeneration = ++generation;
50
+ runtimeContext = context;
51
+ deferredPrompts = [];
52
+ replaying = undefined;
53
+ acceptedReplay = undefined;
54
+ activeCommands = new Set<symbol>();
55
+ activeAction = undefined;
34
56
  try {
35
57
  const next = await runtimeFactory(context, pi);
36
58
  if (currentGeneration !== generation) return;
@@ -46,6 +68,57 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
46
68
  }
47
69
  };
48
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
+
49
122
  const runCommand = async (
50
123
  action: "undo" | "redo",
51
124
  context: ExtensionCommandContext,
@@ -56,6 +129,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
56
129
  context.ui.notify("pi-undo session unavailable", "warning");
57
130
  return;
58
131
  }
132
+ const commandToken = Symbol(action);
133
+ const commandSet = activeCommands;
134
+ commandSet.add(commandToken);
135
+ activeAction = action;
59
136
  active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
60
137
  active.setCommandContext?.(context);
61
138
  let result;
@@ -65,15 +142,56 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
65
142
  : await active.controller.redo();
66
143
  } finally {
67
144
  active.setCommandContext?.(undefined);
145
+ commandSet.delete(commandToken);
146
+ if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
68
147
  }
69
148
  if (commandGeneration !== generation || runtime !== active) return;
70
149
  active.reporter.result(result);
71
- if (action === "undo" && result.code === "ok" && result.refillPrompt !== undefined) {
72
- active.reporter.refillPrompt(result.refillPrompt);
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);
156
+ };
157
+
158
+ const runDiff = async (args: string, context: ExtensionCommandContext): Promise<void> => {
159
+ const active = runtime;
160
+ if (active === undefined || active.diffSource === undefined) {
161
+ context.ui.notify("pi-undo session unavailable", "warning");
162
+ return;
73
163
  }
74
- const history = active.controller.history();
75
- if (history.locked) active.reporter.setRecoveryRequired(result.message ?? result.code);
76
- else active.reporter.setReady(history.undoCount, history.redoCount);
164
+ const checkpoints = active.controller.listCheckpoints();
165
+ if (checkpoints.length === 0) {
166
+ context.ui.notify("No recorded Agent runs to diff", "info");
167
+ return;
168
+ }
169
+ const trimmed = args.trim();
170
+ const position = trimmed === "" ? 1 : Number(trimmed);
171
+ if (!Number.isInteger(position) || position < 1 || position > checkpoints.length) {
172
+ context.ui.notify(`Invalid run number; recorded runs: ${checkpoints.length}`, "warning");
173
+ return;
174
+ }
175
+ // 1 = 最近一次 run(栈顶)。
176
+ const checkpoint = checkpoints[checkpoints.length - position]!;
177
+ let diffs;
178
+ try {
179
+ diffs = await computeCheckpointDiff(active.diffSource, checkpoint);
180
+ } catch (error) {
181
+ context.ui.notify(`Unable to load diff: ${sanitizeDisplayText(errorMessage(error), 120)}`, "error");
182
+ return;
183
+ }
184
+ if (runtime !== active) return;
185
+ if (diffs.length === 0) {
186
+ context.ui.notify("This run changed no files", "info");
187
+ return;
188
+ }
189
+ const label = runLabel(checkpoint.rawPrompt, position);
190
+ if (context.mode !== "tui") {
191
+ context.ui.notify(`${label} — ${formatDiffSummary(diffs)}`, "info");
192
+ return;
193
+ }
194
+ await browseDiff(context, label, diffs);
77
195
  };
78
196
 
79
197
  pi.registerCommand("undo", {
@@ -84,21 +202,75 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
84
202
  description: "Redo the last undone Agent run",
85
203
  handler: async (_args: string, context: ExtensionCommandContext) => runCommand("redo", context),
86
204
  });
205
+ pi.registerCommand("diff", {
206
+ description: "Review files changed by an Agent run (latest, or /diff N)",
207
+ handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
208
+ });
87
209
 
88
210
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
89
- pi.on("input", async (event: InputEvent) => {
90
- if (runtime === undefined) return { action: "handled" as const };
91
- 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
+ }
92
266
  });
93
- pi.on("before_agent_start", async () => { await runtime?.controller.beforeAgentStart(); });
94
267
  pi.on("agent_settled", async () => {
95
268
  const active = runtime;
269
+ const settledGeneration = generation;
96
270
  if (active === undefined) return;
97
271
  await active.controller.agentSettled();
98
- if (runtime !== active) return;
99
- const history = active.controller.history();
100
- if (history.locked) active.reporter.setRecoveryRequired("session state ambiguous");
101
- else active.reporter.setReady(history.undoCount, history.redoCount);
272
+ if (runtime !== active || generation !== settledGeneration) return;
273
+ resumeDeferredPrompts(active, settledGeneration, "session state ambiguous");
102
274
  });
103
275
  pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
104
276
  if (runtime === undefined) return { cancel: true };
@@ -106,7 +278,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
106
278
  const active = runtime;
107
279
  const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId });
108
280
  if (result === undefined) {
109
- event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
281
+ event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
110
282
  }
111
283
  return result;
112
284
  });
@@ -119,6 +291,12 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
119
291
  });
120
292
  pi.on("session_shutdown", async () => {
121
293
  generation += 1;
294
+ deferredPrompts = [];
295
+ replaying = undefined;
296
+ acceptedReplay = undefined;
297
+ activeCommands = new Set<symbol>();
298
+ activeAction = undefined;
299
+ runtimeContext = undefined;
122
300
  await runtime?.controller.cancelTree?.();
123
301
  runtime?.reporter.clear();
124
302
  runtime = undefined;
@@ -126,8 +304,35 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
126
304
  };
127
305
  }
128
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
+
129
328
  function errorMessage(error: unknown): string {
130
329
  return error instanceof Error ? error.message : String(error);
131
330
  }
132
331
 
332
+ function runLabel(rawPrompt: string, position: number): string {
333
+ const singleLine = sanitizeDisplayText(rawPrompt, 1_000);
334
+ const preview = singleLine.length > 48 ? `${singleLine.slice(0, 47)}…` : singleLine;
335
+ return preview === "" ? `Run #${position}` : `Run #${position}: ${preview}`;
336
+ }
337
+
133
338
  export default createPiUndoExtension(createPiUndoRuntime);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,7 +42,8 @@
42
42
  "pack:dry-run": "npm pack --dry-run"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "^0.80.10"
45
+ "@earendil-works/pi-coding-agent": "^0.80.10",
46
+ "@earendil-works/pi-tui": "*"
46
47
  },
47
48
  "dependencies": {
48
49
  "proper-lockfile": "4.1.2"
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;
@@ -108,6 +109,8 @@ export interface HistoryState {
108
109
  }
109
110
 
110
111
  export interface UndoController {
112
+ /** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
113
+ listCheckpoints(): readonly CheckpointRecord[];
111
114
  prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
112
115
  beforeAgentStart(): Promise<void>;
113
116
  agentSettled(): Promise<void>;
@@ -161,6 +164,7 @@ export class UndoControllerImpl implements UndoController {
161
164
  private locked = false;
162
165
  private historyPaused = false;
163
166
  private operationInFlight = false;
167
+ private promptDeferralInFlight = false;
164
168
  private lastSafetyManifestId: ManifestId | null = null;
165
169
 
166
170
  constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
@@ -175,7 +179,12 @@ export class UndoControllerImpl implements UndoController {
175
179
  return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
176
180
  }
177
181
 
182
+ listCheckpoints(): readonly CheckpointRecord[] {
183
+ return [...this.undoStack];
184
+ }
185
+
178
186
  async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
187
+ if (this.promptDeferralInFlight) return { action: "defer" };
179
188
  if (this.locked || this.operationInFlight) return { action: "handled" };
180
189
  if (context.streaming || text.length === 0) return { action: "continue" };
181
190
  try {
@@ -376,6 +385,7 @@ export class UndoControllerImpl implements UndoController {
376
385
  ): Promise<OperationResult> {
377
386
  if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
378
387
  this.operationInFlight = true;
388
+ this.promptDeferralInFlight = true;
379
389
  this.lastSafetyManifestId = null;
380
390
  let lease: { release(): Promise<void> } | undefined;
381
391
  try {
@@ -448,6 +458,7 @@ export class UndoControllerImpl implements UndoController {
448
458
  if (lease !== undefined) {
449
459
  await lease.release().catch(() => { this.locked = true; });
450
460
  }
461
+ this.promptDeferralInFlight = false;
451
462
  this.operationInFlight = false;
452
463
  }
453
464
  }