@bachi/pi-coder 1.0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Tests for the /rewind menu flow, driven by a scripted mock command context.
3
+ *
4
+ * Run with: node --test ~/.pi/agent/extensions/rewind/flow.test.ts
5
+ *
6
+ * The TUI dialogs cannot be exercised headlessly, so `ctx.ui.select` /
7
+ * `ctx.ui.confirm` are replaced with a queue of canned answers, and the
8
+ * scrollable picker (`deps.pick`, see picker.ts) with `scriptedPicker` below.
9
+ * Every side effect (notify text, navigateTree calls, files on disk) is
10
+ * asserted.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+
19
+ import {
20
+ type Checkpoint,
21
+ captureExternalFile,
22
+ createCheckpoint,
23
+ latestSafetyCheckpoint,
24
+ listCheckpoints,
25
+ openStore,
26
+ snapshotTree,
27
+ type RewindStore,
28
+ } from "./checkpoints.ts";
29
+ import {
30
+ NEVER_MIND_LABEL,
31
+ UNDO_LABEL,
32
+ buildActions,
33
+ buildPickerItems,
34
+ formatCheckpointLabel,
35
+ formatDiffPreview,
36
+ formatTime,
37
+ runRewindFlow,
38
+ snippetOf,
39
+ type PickOutcome,
40
+ type Picker,
41
+ type PickerItem,
42
+ } from "./flow.ts";
43
+
44
+ const created: string[] = [];
45
+
46
+ interface MockCtx {
47
+ ctx: any;
48
+ selects: string[];
49
+ confirms: boolean[];
50
+ notifications: Array<{ message: string; type?: string }>;
51
+ navigations: Array<{ targetId: string; summarize: boolean }>;
52
+ selectTitles: string[];
53
+ selectOptionLists: string[][];
54
+ confirmations: Array<{ title: string; message: string }>;
55
+ }
56
+
57
+ /** Mock command context whose dialogs answer from a script. */
58
+ function mockCtx(options: { selects?: string[]; confirms?: boolean[] } = {}): MockCtx {
59
+ const state: MockCtx = {
60
+ selects: [...(options.selects ?? [])],
61
+ confirms: [...(options.confirms ?? [])],
62
+ notifications: [],
63
+ navigations: [],
64
+ selectTitles: [],
65
+ selectOptionLists: [],
66
+ confirmations: [],
67
+ ctx: null as any,
68
+ };
69
+
70
+ state.ctx = {
71
+ hasUI: true,
72
+ ui: {
73
+ select: async (title: string, optionsList: string[]) => {
74
+ state.selectTitles.push(title);
75
+ state.selectOptionLists.push(optionsList);
76
+ return state.selects.length > 0 ? state.selects.shift() : undefined;
77
+ },
78
+ confirm: async (title: string, message: string) => {
79
+ state.confirmations.push({ title, message });
80
+ return state.confirms.length > 0 ? state.confirms.shift() : false;
81
+ },
82
+ notify: (message: string, type?: string) => state.notifications.push({ message, type }),
83
+ theme: { fg: (_slot: string, text: string) => text },
84
+ },
85
+ navigateTree: async (targetId: string, opts?: { summarize?: boolean }) => {
86
+ state.navigations.push({ targetId, summarize: Boolean(opts?.summarize) });
87
+ return { cancelled: false };
88
+ },
89
+ };
90
+
91
+ return state;
92
+ }
93
+
94
+ /**
95
+ * Scripted `deps.pick`: answers from a queue and records the rows it was
96
+ * offered. A bare string means "the user picked the row with this label" — the
97
+ * same thing a human does — so tests never hardcode the row-value encoding; an
98
+ * explicit `PickOutcome` stands in for the other statuses.
99
+ */
100
+ function scriptedPicker(answers: Array<PickOutcome | string>): {
101
+ pick: Picker;
102
+ offered: Array<{ title: string; items: PickerItem[] }>;
103
+ } {
104
+ const offered: Array<{ title: string; items: PickerItem[] }> = [];
105
+ const pick: Picker = async (_ctx, title, items) => {
106
+ offered.push({ title, items });
107
+ const answer = answers.shift();
108
+ if (answer === undefined) return { status: "cancelled" };
109
+ if (typeof answer !== "string") return answer;
110
+ const item = items.find((candidate) => candidate.label === answer);
111
+ assert.ok(item, `picker was not offered a row labelled ${answer}`);
112
+ return { status: "picked", value: item.value };
113
+ };
114
+ return { pick, offered };
115
+ }
116
+
117
+ function fixture(): { root: string; store: RewindStore } {
118
+ const base = mkdtempSync(join(tmpdir(), "pi-rewind-flow-"));
119
+ created.push(base);
120
+ const root = join(base, "project");
121
+ const agentDir = join(base, "agent");
122
+ mkdirSync(root, { recursive: true });
123
+ mkdirSync(agentDir, { recursive: true });
124
+ return { root, store: openStore(root, agentDir) };
125
+ }
126
+
127
+ function write(root: string, rel: string, content: string): void {
128
+ const full = join(root, rel);
129
+ mkdirSync(dirname(full), { recursive: true });
130
+ writeFileSync(full, content, "utf8");
131
+ }
132
+
133
+ /**
134
+ * Two checkpoints plus the edits the second prompt's turn made afterwards.
135
+ * Mirrors the real flow: a checkpoint is taken *before* the turn runs, so its
136
+ * tree is the pre-edit state while the worktree now holds the edits.
137
+ */
138
+ async function twoCheckpoints(
139
+ root: string,
140
+ store: RewindStore,
141
+ sessionId = "sess",
142
+ ): Promise<{ first: Checkpoint; second: Checkpoint }> {
143
+ write(root, "a.txt", "v1\n");
144
+ const first = await createCheckpoint(store, {
145
+ sessionId,
146
+ kind: "session-start",
147
+ timestamp: 1_700_000_000_000,
148
+ });
149
+ const second = await createCheckpoint(store, {
150
+ sessionId,
151
+ kind: "prompt",
152
+ prompt: "please change a.txt",
153
+ entryId: "entry-2",
154
+ timestamp: 1_700_000_060_000,
155
+ });
156
+ // The turn's tool then edits the file.
157
+ write(root, "a.txt", "v2\n");
158
+ return { first, second };
159
+ }
160
+
161
+ describe("formatting helpers", () => {
162
+ it("renders labels, snippets and times", () => {
163
+ const checkpoint: Checkpoint = {
164
+ id: "x",
165
+ sessionId: "s",
166
+ kind: "prompt",
167
+ prompt: "first line\nsecond line",
168
+ tree: "t",
169
+ commit: "c",
170
+ timestamp: new Date(2026, 8, 14, 9, 5).getTime(),
171
+ };
172
+ assert.equal(formatCheckpointLabel(checkpoint, 3), `#3 ${formatTime(checkpoint.timestamp)} first line`);
173
+ assert.equal(snippetOf("first line\nsecond line"), "first line");
174
+ assert.equal(snippetOf(""), "(empty prompt)");
175
+ assert.equal(snippetOf("x".repeat(80)).length, 60);
176
+ assert.match(snippetOf("x".repeat(80)), /…$/);
177
+
178
+ const sessionStart: Checkpoint = { ...checkpoint, kind: "session-start", prompt: "" };
179
+ assert.match(formatCheckpointLabel(sessionStart, 1), /Session start$/);
180
+ const safety: Checkpoint = { ...checkpoint, kind: "safety", prompt: "" };
181
+ assert.match(formatCheckpointLabel(safety, 2), /Before a rewind$/);
182
+ });
183
+
184
+ it("offers code actions only when there are file changes", () => {
185
+ const changed = { entries: [{ status: "M" as const, path: "a.txt" }], empty: false };
186
+ const unchanged = { entries: [], empty: true };
187
+
188
+ assert.deepEqual(
189
+ buildActions(changed, true).map((choice) => choice.label),
190
+ [
191
+ "Restore code and conversation",
192
+ "Restore conversation (keep current files)",
193
+ "Restore code (keep conversation)",
194
+ "Summarize from here (frees context)",
195
+ NEVER_MIND_LABEL,
196
+ ],
197
+ );
198
+ // Claude Code hides the code options when nothing would be reverted.
199
+ assert.deepEqual(
200
+ buildActions(unchanged, true).map((choice) => choice.label),
201
+ [
202
+ "Restore conversation (keep current files)",
203
+ "Summarize from here (frees context)",
204
+ NEVER_MIND_LABEL,
205
+ ],
206
+ );
207
+ // Without a conversation target only the code path remains.
208
+ assert.deepEqual(
209
+ buildActions(changed, false).map((choice) => choice.label),
210
+ ["Restore code (keep conversation)", NEVER_MIND_LABEL],
211
+ );
212
+ assert.deepEqual(buildActions(unchanged, false).map((choice) => choice.label), [NEVER_MIND_LABEL]);
213
+ });
214
+
215
+ it("previews a diff and truncates long ones", () => {
216
+ assert.equal(formatDiffPreview({ entries: [], empty: true }), "No file changes.");
217
+
218
+ const entries = Array.from({ length: 20 }, (_unused, i) => ({
219
+ status: i === 0 ? "A" : i === 1 ? "D" : "M",
220
+ path: `file${i}.txt`,
221
+ }));
222
+ const preview = formatDiffPreview({ entries, empty: false });
223
+ assert.match(preview, /20 files: \+1 ~18 -1/);
224
+ assert.match(preview, /\+ file0\.txt/);
225
+ assert.match(preview, /- file1\.txt/);
226
+ assert.match(preview, /… and 5 more/);
227
+ // summary + 15 listed paths + truncation note
228
+ assert.equal(preview.split("\n").length, 17);
229
+ });
230
+ });
231
+
232
+ describe("buildPickerItems", () => {
233
+ const checkpoint = (id: string, timestamp: number): Checkpoint => ({
234
+ id,
235
+ sessionId: "sess",
236
+ kind: "prompt",
237
+ prompt: `prompt ${id}`,
238
+ tree: "tree",
239
+ commit: "commit",
240
+ timestamp,
241
+ });
242
+
243
+ it("lists checkpoints newest first, between the undo and Never mind rows", () => {
244
+ const oldest = checkpoint("a", 1_700_000_000_000);
245
+ const middle = checkpoint("b", 1_700_000_060_000);
246
+ const newest = checkpoint("c", 1_700_000_120_000);
247
+
248
+ const items = buildPickerItems([oldest, middle, newest], true);
249
+
250
+ assert.equal(items[0].label, UNDO_LABEL);
251
+ assert.equal(items.at(-1)!.label, NEVER_MIND_LABEL);
252
+ assert.deepEqual(
253
+ items.slice(1, -1).map((item) => item.label),
254
+ [
255
+ formatCheckpointLabel(newest, 3),
256
+ formatCheckpointLabel(middle, 2),
257
+ formatCheckpointLabel(oldest, 1),
258
+ ],
259
+ );
260
+ // Values address the oldest → newest array the flow indexes into.
261
+ assert.deepEqual(items.slice(1, -1).map((item) => item.value), ["cp:2", "cp:1", "cp:0"]);
262
+ });
263
+
264
+ it("omits the undo row when there is nothing to undo", () => {
265
+ const only = checkpoint("a", 1);
266
+ const items = buildPickerItems([only], false);
267
+
268
+ assert.deepEqual(items.map((item) => item.label), [
269
+ formatCheckpointLabel(only, 1),
270
+ NEVER_MIND_LABEL,
271
+ ]);
272
+ });
273
+ });
274
+
275
+ describe("runRewindFlow", () => {
276
+ afterEach(() => {
277
+ while (created.length > 0) {
278
+ rmSync(created.pop()!, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ it("reports when the session has no checkpoints", async () => {
283
+ const { store } = fixture();
284
+ const mock = mockCtx();
285
+
286
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "empty" });
287
+
288
+ assert.equal(outcome, "no checkpoints");
289
+ assert.deepEqual(mock.selectTitles, []);
290
+ assert.deepEqual(mock.notifications, [{ message: "No checkpoints yet for this session", type: "warning" }]);
291
+ });
292
+
293
+ it("lists checkpoints newest first and backs out on Never mind", async () => {
294
+ const { root, store } = fixture();
295
+ await twoCheckpoints(root, store);
296
+ const mock = mockCtx({ selects: [`#2 ${formatTime(1_700_000_060_000)} please change a.txt`, NEVER_MIND_LABEL] });
297
+
298
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
299
+
300
+ assert.equal(outcome, "cancelled");
301
+ assert.equal(mock.selectTitles[0], "Rewind to a checkpoint");
302
+ assert.deepEqual(mock.selectOptionLists[0], [
303
+ `#2 ${formatTime(1_700_000_060_000)} please change a.txt`,
304
+ `#1 ${formatTime(1_700_000_000_000)} Session start`,
305
+ NEVER_MIND_LABEL,
306
+ ]);
307
+ assert.deepEqual(mock.navigations, []);
308
+ });
309
+
310
+ it("backs out when the picker is dismissed", async () => {
311
+ const { root, store } = fixture();
312
+ await twoCheckpoints(root, store);
313
+ const mock = mockCtx({ selects: [] });
314
+
315
+ assert.equal(await runRewindFlow(mock.ctx, { store, sessionId: "sess" }), "cancelled");
316
+ assert.deepEqual(mock.navigations, []);
317
+ });
318
+
319
+ it("restores code and conversation together", async () => {
320
+ const { root, store } = fixture();
321
+ const { second } = await twoCheckpoints(root, store);
322
+ write(root, "b.txt", "created after the checkpoint\n");
323
+ const treeBeforeRewind = await snapshotTree(store);
324
+
325
+ const mock = mockCtx({
326
+ selects: [
327
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
328
+ "Restore code and conversation",
329
+ ],
330
+ confirms: [true],
331
+ });
332
+
333
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
334
+
335
+ assert.equal(outcome, "restored code and conversation");
336
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
337
+ assert.equal(existsSync(join(root, "b.txt")), false);
338
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-2", summarize: false }]);
339
+ // The pre-restore state is kept as the undo net.
340
+ assert.equal(latestSafetyCheckpoint(store, "sess")?.tree, treeBeforeRewind);
341
+ assert.match(mock.notifications[0].message, /Restored files to checkpoint #2/);
342
+ });
343
+
344
+ it("restores the conversation only and leaves files alone", async () => {
345
+ const { root, store } = fixture();
346
+ const { second } = await twoCheckpoints(root, store);
347
+
348
+ const mock = mockCtx({
349
+ selects: [
350
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
351
+ "Restore conversation (keep current files)",
352
+ ],
353
+ });
354
+
355
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
356
+
357
+ assert.equal(outcome, "restored conversation");
358
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-2", summarize: false }]);
359
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v2\n");
360
+ assert.equal(latestSafetyCheckpoint(store, "sess"), undefined);
361
+ // Picker + action menu, nothing else.
362
+ assert.equal(mock.selectTitles.length, 2);
363
+ });
364
+
365
+ it("restores code only and keeps the conversation", async () => {
366
+ const { root, store } = fixture();
367
+ const { first } = await twoCheckpoints(root, store);
368
+
369
+ const mock = mockCtx({
370
+ selects: [`#1 ${formatTime(first.timestamp)} Session start`, "Restore code (keep conversation)"],
371
+ confirms: [true],
372
+ });
373
+
374
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
375
+
376
+ assert.equal(outcome, "restored code");
377
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
378
+ assert.deepEqual(mock.navigations, []);
379
+ });
380
+
381
+ it("summarizes from the selected point", async () => {
382
+ const { root, store } = fixture();
383
+ const { second } = await twoCheckpoints(root, store);
384
+
385
+ const mock = mockCtx({
386
+ selects: [
387
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
388
+ "Summarize from here (frees context)",
389
+ ],
390
+ });
391
+
392
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
393
+
394
+ assert.equal(outcome, "summarized");
395
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-2", summarize: true }]);
396
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v2\n");
397
+ });
398
+
399
+ it("does not touch files when the restore confirmation is declined", async () => {
400
+ const { root, store } = fixture();
401
+ const { second } = await twoCheckpoints(root, store);
402
+
403
+ const mock = mockCtx({
404
+ selects: [
405
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
406
+ "Restore code (keep conversation)",
407
+ ],
408
+ confirms: [false],
409
+ });
410
+
411
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
412
+
413
+ assert.equal(outcome, "cancelled");
414
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v2\n");
415
+ assert.deepEqual(mock.navigations, []);
416
+ assert.equal(latestSafetyCheckpoint(store, "sess"), undefined);
417
+ });
418
+
419
+ it("shows the changed files in the confirmation dialog", async () => {
420
+ const { root, store } = fixture();
421
+ const { second } = await twoCheckpoints(root, store);
422
+
423
+ const mock = mockCtx({
424
+ selects: [
425
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
426
+ "Restore code (keep conversation)",
427
+ ],
428
+ confirms: [false],
429
+ });
430
+
431
+ await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
432
+
433
+ // Second select is the action menu, titled with the diff summary.
434
+ assert.match(mock.selectTitles[1], /1 file: ~1/);
435
+ assert.deepEqual(mock.selectOptionLists[1], [
436
+ "Restore code and conversation",
437
+ "Restore conversation (keep current files)",
438
+ "Restore code (keep conversation)",
439
+ "Summarize from here (frees context)",
440
+ NEVER_MIND_LABEL,
441
+ ]);
442
+ });
443
+
444
+ it("offers only conversation actions when nothing changed on disk", async () => {
445
+ const { root, store } = fixture();
446
+ write(root, "a.txt", "stable\n");
447
+ const checkpoint = await createCheckpoint(store, {
448
+ sessionId: "sess",
449
+ kind: "prompt",
450
+ prompt: "a read-only question",
451
+ entryId: "entry-9",
452
+ });
453
+
454
+ const mock = mockCtx({
455
+ selects: [
456
+ `#1 ${formatTime(checkpoint.timestamp)} a read-only question`,
457
+ "Restore conversation (keep current files)",
458
+ ],
459
+ });
460
+
461
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
462
+
463
+ assert.equal(outcome, "restored conversation");
464
+ assert.deepEqual(mock.selectOptionLists[1], [
465
+ "Restore conversation (keep current files)",
466
+ "Summarize from here (frees context)",
467
+ NEVER_MIND_LABEL,
468
+ ]);
469
+ });
470
+
471
+ it("offers and performs an undo of the previous rewind", async () => {
472
+ const { root, store } = fixture();
473
+ const { second } = await twoCheckpoints(root, store);
474
+
475
+ // First rewind: roll the code back to the state before the prompt.
476
+ const firstMock = mockCtx({
477
+ selects: [
478
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
479
+ "Restore code (keep conversation)",
480
+ ],
481
+ confirms: [true],
482
+ });
483
+ await runRewindFlow(firstMock.ctx, { store, sessionId: "sess" });
484
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
485
+
486
+ // Second rewind: undo it.
487
+ const undoMock = mockCtx({ selects: [UNDO_LABEL], confirms: [true] });
488
+ const outcome = await runRewindFlow(undoMock.ctx, { store, sessionId: "sess" });
489
+
490
+ assert.equal(outcome, "undid last rewind");
491
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v2\n");
492
+ assert.equal(undoMock.selectOptionLists[0][0], UNDO_LABEL);
493
+ assert.match(undoMock.notifications[0].message, /Undid the last rewind/);
494
+ // The undo net is consumed.
495
+ assert.equal(latestSafetyCheckpoint(store, "sess"), undefined);
496
+ });
497
+
498
+ it("keeps the undo entry out of the checkpoint list", async () => {
499
+ const { root, store } = fixture();
500
+ const { second } = await twoCheckpoints(root, store);
501
+
502
+ const rewindMock = mockCtx({
503
+ selects: [
504
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
505
+ "Restore code (keep conversation)",
506
+ ],
507
+ confirms: [true],
508
+ });
509
+ await runRewindFlow(rewindMock.ctx, { store, sessionId: "sess" });
510
+
511
+ const listingMock = mockCtx({ selects: [NEVER_MIND_LABEL] });
512
+ await runRewindFlow(listingMock.ctx, { store, sessionId: "sess" });
513
+
514
+ const options = listingMock.selectOptionLists[0];
515
+ assert.equal(options[0], UNDO_LABEL);
516
+ assert.equal(options.filter((option) => option.includes("Before a rewind")).length, 0);
517
+ assert.equal(listCheckpoints(store, "sess").filter((cp) => cp.kind === "safety").length, 1);
518
+ });
519
+
520
+ it("reports a conversation rewind failure without losing the restored files", async () => {
521
+ const { root, store } = fixture();
522
+ const { second } = await twoCheckpoints(root, store);
523
+
524
+ const mock = mockCtx({
525
+ selects: [
526
+ `#2 ${formatTime(second.timestamp)} please change a.txt`,
527
+ "Restore code and conversation",
528
+ ],
529
+ confirms: [true],
530
+ });
531
+ mock.ctx.navigateTree = async () => {
532
+ throw new Error("navigation exploded");
533
+ };
534
+
535
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
536
+
537
+ assert.equal(outcome, "conversation rewind failed");
538
+ // Files were still rolled back.
539
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
540
+ assert.match(mock.notifications.at(-1)!.message, /Conversation rewind failed: navigation exploded/);
541
+ });
542
+
543
+ it("offers a code restore for files the worktree snapshot cannot see", async () => {
544
+ const { root, store } = fixture();
545
+ // The agent-dir sibling: exactly where ~/.pi/agent/themes lives in practice.
546
+ const outside = join(dirname(root), "agent", "themes", "verdigris.json");
547
+ const created = join(dirname(root), "agent", "themes", "scratch.json");
548
+ mkdirSync(dirname(outside), { recursive: true });
549
+ writeFileSync(outside, "border: old\n");
550
+ write(root, "a.txt", "untouched by the turn\n");
551
+
552
+ const checkpoint = await createCheckpoint(store, {
553
+ sessionId: "sess",
554
+ kind: "prompt",
555
+ prompt: "change the theme border",
556
+ entryId: "entry-1",
557
+ });
558
+ assert.equal(captureExternalFile(store, "sess", outside), true);
559
+ assert.equal(captureExternalFile(store, "sess", created), true);
560
+ writeFileSync(outside, "border: new\n");
561
+ writeFileSync(created, "made during the turn\n");
562
+
563
+ const mock = mockCtx({
564
+ selects: [
565
+ `#1 ${formatTime(checkpoint.timestamp)} change the theme border`,
566
+ "Restore code and conversation",
567
+ ],
568
+ confirms: [true],
569
+ });
570
+
571
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
572
+
573
+ assert.equal(outcome, "restored code and conversation");
574
+ assert.equal(readFileSync(outside, "utf8"), "border: old\n");
575
+ assert.equal(existsSync(created), false);
576
+ // The worktree diff is empty here — the pre-images are the only evidence.
577
+ assert.match(mock.selectTitles[1], /2 files: \+1 ~1/);
578
+ assert.deepEqual(mock.selectOptionLists[1], [
579
+ "Restore code and conversation",
580
+ "Restore conversation (keep current files)",
581
+ "Restore code (keep conversation)",
582
+ "Summarize from here (frees context)",
583
+ NEVER_MIND_LABEL,
584
+ ]);
585
+ assert.match(mock.confirmations[0].message, /themes\/verdigris\.json/);
586
+ assert.match(mock.confirmations[0].message, /themes\/scratch\.json/);
587
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-1", summarize: false }]);
588
+ assert.match(mock.notifications[0].message, /Restored files to checkpoint #1 \(2 files: \+1 ~1, removed 1 newer file\(s\)\)/);
589
+ });
590
+
591
+ it("undoes a rewind that changed an outside-project file", async () => {
592
+ const { root, store } = fixture();
593
+ const outside = join(dirname(root), "agent", "themes", "t.json");
594
+ mkdirSync(dirname(outside), { recursive: true });
595
+ writeFileSync(outside, "old\n");
596
+
597
+ const checkpoint = await createCheckpoint(store, {
598
+ sessionId: "sess",
599
+ kind: "prompt",
600
+ prompt: "tweak the theme",
601
+ entryId: "entry-1",
602
+ });
603
+ assert.equal(captureExternalFile(store, "sess", outside), true);
604
+ writeFileSync(outside, "new\n");
605
+
606
+ const rewindMock = mockCtx({
607
+ selects: [
608
+ `#1 ${formatTime(checkpoint.timestamp)} tweak the theme`,
609
+ "Restore code (keep conversation)",
610
+ ],
611
+ confirms: [true],
612
+ });
613
+ assert.equal(await runRewindFlow(rewindMock.ctx, { store, sessionId: "sess" }), "restored code");
614
+ assert.equal(readFileSync(outside, "utf8"), "old\n");
615
+
616
+ const undoMock = mockCtx({ selects: [UNDO_LABEL], confirms: [true] });
617
+ assert.equal(await runRewindFlow(undoMock.ctx, { store, sessionId: "sess" }), "undid last rewind");
618
+ assert.equal(readFileSync(outside, "utf8"), "new\n");
619
+ });
620
+
621
+ it("hides conversation actions for a checkpoint without an entry id", async () => {
622
+ const { root, store } = fixture();
623
+ write(root, "a.txt", "v1\n");
624
+ const checkpoint = await createCheckpoint(store, {
625
+ sessionId: "sess",
626
+ kind: "prompt",
627
+ prompt: "prompt without an entry yet",
628
+ });
629
+ write(root, "a.txt", "v2\n");
630
+
631
+ const mock = mockCtx({
632
+ selects: [
633
+ `#1 ${formatTime(checkpoint.timestamp)} prompt without an entry yet`,
634
+ "Restore code (keep conversation)",
635
+ ],
636
+ confirms: [true],
637
+ });
638
+
639
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess" });
640
+
641
+ assert.equal(outcome, "restored code");
642
+ assert.deepEqual(mock.selectOptionLists[1], ["Restore code (keep conversation)", NEVER_MIND_LABEL]);
643
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
644
+ });
645
+ });
646
+
647
+ /**
648
+ * The scrollable picker is the production path (index.ts passes pickFromList);
649
+ * `ctx.ui.select` is only the fallback for hosts that cannot render extension
650
+ * components. These cases pin the wiring between the two.
651
+ */
652
+ describe("picker wiring", () => {
653
+ afterEach(() => {
654
+ while (created.length > 0) {
655
+ rmSync(created.pop()!, { recursive: true, force: true });
656
+ }
657
+ });
658
+
659
+ it("drives the flow from the picker, leaving only the action menu to select", async () => {
660
+ const { root, store } = fixture();
661
+ const { first, second } = await twoCheckpoints(root, store);
662
+ write(root, "b.txt", "created after the checkpoint\n");
663
+
664
+ const { pick, offered } = scriptedPicker([formatCheckpointLabel(second, 2)]);
665
+ const mock = mockCtx({ selects: ["Restore code and conversation"], confirms: [true] });
666
+
667
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess", pick });
668
+
669
+ assert.equal(outcome, "restored code and conversation");
670
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
671
+ assert.equal(existsSync(join(root, "b.txt")), false);
672
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-2", summarize: false }]);
673
+ // The picker was offered the checkpoint rows (undo row absent: nothing to
674
+ // undo yet), and the built-in select was only used for the action menu.
675
+ assert.equal(offered.length, 1);
676
+ assert.equal(offered[0].title, "Rewind to a checkpoint");
677
+ assert.deepEqual(offered[0].items.map((item) => item.label), [
678
+ formatCheckpointLabel(second, 2),
679
+ formatCheckpointLabel(first, 1),
680
+ NEVER_MIND_LABEL,
681
+ ]);
682
+ assert.equal(mock.selectTitles.length, 1);
683
+ assert.match(mock.selectTitles[0], /^#2 /);
684
+ });
685
+
686
+ it("cancels without falling back when the picker is dismissed", async () => {
687
+ const { root, store } = fixture();
688
+ await twoCheckpoints(root, store);
689
+ const { pick } = scriptedPicker([]);
690
+ const mock = mockCtx();
691
+
692
+ assert.equal(await runRewindFlow(mock.ctx, { store, sessionId: "sess", pick }), "cancelled");
693
+ // A picker answer is final — the plain list is not consulted.
694
+ assert.deepEqual(mock.selectTitles, []);
695
+ assert.deepEqual(mock.navigations, []);
696
+ });
697
+
698
+ it("ignores a picker value that names no row", async () => {
699
+ const { root, store } = fixture();
700
+ await twoCheckpoints(root, store);
701
+ const { pick } = scriptedPicker([{ status: "picked", value: "cp:99" }]);
702
+ const mock = mockCtx();
703
+
704
+ assert.equal(await runRewindFlow(mock.ctx, { store, sessionId: "sess", pick }), "cancelled");
705
+ assert.deepEqual(mock.navigations, []);
706
+ assert.deepEqual(mock.selectTitles, []);
707
+ });
708
+
709
+ it("falls back to the plain list when the host cannot render the picker", async () => {
710
+ const { root, store } = fixture();
711
+ const { first, second } = await twoCheckpoints(root, store);
712
+ const { pick } = scriptedPicker([{ status: "unsupported" }]);
713
+ const mock = mockCtx({
714
+ selects: [
715
+ formatCheckpointLabel(second, 2),
716
+ "Restore conversation (keep current files)",
717
+ ],
718
+ });
719
+
720
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess", pick });
721
+
722
+ assert.equal(outcome, "restored conversation");
723
+ assert.deepEqual(mock.navigations, [{ targetId: "entry-2", summarize: false }]);
724
+ // Same rows, same order — `ctx.ui.select` just cannot scroll them.
725
+ assert.deepEqual(mock.selectOptionLists[0], [
726
+ formatCheckpointLabel(second, 2),
727
+ formatCheckpointLabel(first, 1),
728
+ NEVER_MIND_LABEL,
729
+ ]);
730
+ });
731
+
732
+ it("undoes a rewind from the picker's undo row", async () => {
733
+ const { root, store } = fixture();
734
+ const { second } = await twoCheckpoints(root, store);
735
+
736
+ const rewind = scriptedPicker([formatCheckpointLabel(second, 2)]);
737
+ await runRewindFlow(
738
+ mockCtx({ selects: ["Restore code (keep conversation)"], confirms: [true] }).ctx,
739
+ {
740
+ store,
741
+ sessionId: "sess",
742
+ pick: rewind.pick,
743
+ },
744
+ );
745
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v1\n");
746
+
747
+ const undo = scriptedPicker([UNDO_LABEL]);
748
+ const mock = mockCtx({ confirms: [true] });
749
+ const outcome = await runRewindFlow(mock.ctx, { store, sessionId: "sess", pick: undo.pick });
750
+
751
+ assert.equal(outcome, "undid last rewind");
752
+ assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "v2\n");
753
+ assert.equal(undo.offered[0].items[0].label, UNDO_LABEL);
754
+ assert.match(mock.notifications[0].message, /Undid the last rewind/);
755
+ });
756
+ });