@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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-rewind — Claude Code style checkpointing and /rewind for pi.
|
|
3
|
+
*
|
|
4
|
+
* What it does
|
|
5
|
+
* - Snapshots the working tree before every user prompt that starts a turn
|
|
6
|
+
* (Claude Code's checkpoint model), stored on a shadow git repository under
|
|
7
|
+
* ~/.pi/agent/rewind/<project>/git — the project's own git history, index
|
|
8
|
+
* and refs are never touched.
|
|
9
|
+
* - Catches the files that snapshot cannot see: anything an `edit`/`write`
|
|
10
|
+
* tool touches outside the project root (or inside it but `.gitignore`d,
|
|
11
|
+
* or under a nested repo) is copied into a pre-image blob right before the
|
|
12
|
+
* tool runs, so a rewind restores those too.
|
|
13
|
+
* - `/rewind` (or Esc twice at an empty prompt) opens the rewind menu:
|
|
14
|
+
* restore code and conversation, restore conversation, restore code,
|
|
15
|
+
* summarize from here, never mind.
|
|
16
|
+
* - Conversation restore uses pi's native session-tree navigation, which
|
|
17
|
+
* drops the selected user message and puts its text back into the editor —
|
|
18
|
+
* exactly what Claude Code does.
|
|
19
|
+
*
|
|
20
|
+
* Esc + Esc
|
|
21
|
+
* pi's built-in double-escape action (`doubleEscapeAction` in settings.json,
|
|
22
|
+
* default "tree") is replaced by this extension. Set it to "none" so the
|
|
23
|
+
* built-in tree navigator no longer fires:
|
|
24
|
+
*
|
|
25
|
+
* { "doubleEscapeAction": "none" }
|
|
26
|
+
*
|
|
27
|
+
* The extension then counts two raw Esc presses within 500 ms while the agent
|
|
28
|
+
* is idle and the editor is empty, and dispatches /rewind. Escapes are never
|
|
29
|
+
* consumed, so Esc still aborts streaming, aborts a running `!` bash command
|
|
30
|
+
* and cancels dialogs exactly as before. When the setting is not "none" the
|
|
31
|
+
* extension warns once at session start.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type {
|
|
35
|
+
ExtensionAPI,
|
|
36
|
+
ExtensionCommandContext,
|
|
37
|
+
ExtensionContext,
|
|
38
|
+
} from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import { readFileSync } from "node:fs";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
MAX_CHECKPOINTS_PER_SESSION,
|
|
45
|
+
type RewindStore,
|
|
46
|
+
attachEntryId,
|
|
47
|
+
captureExternalFile,
|
|
48
|
+
createCheckpoint,
|
|
49
|
+
gc,
|
|
50
|
+
listCheckpoints,
|
|
51
|
+
openStore,
|
|
52
|
+
pruneSession,
|
|
53
|
+
pruneStaleSessions,
|
|
54
|
+
} from "./checkpoints.ts";
|
|
55
|
+
import { runRewindFlow } from "./flow.ts";
|
|
56
|
+
import { pickFromList } from "./picker.ts";
|
|
57
|
+
|
|
58
|
+
/** Claude Code / pi both use a 500 ms double-escape window. */
|
|
59
|
+
const DOUBLE_ESCAPE_WINDOW_MS = 500;
|
|
60
|
+
const STATUS_KEY = "rewind";
|
|
61
|
+
|
|
62
|
+
interface RewindState {
|
|
63
|
+
store: RewindStore | null;
|
|
64
|
+
sessionId: string | null;
|
|
65
|
+
/** Reason the store could not be opened; reported once, then silent. */
|
|
66
|
+
disabledReason: string | null;
|
|
67
|
+
/** Latest context, used by the raw-input handler (it receives no ctx). */
|
|
68
|
+
ctx: ExtensionContext | null;
|
|
69
|
+
/** True while an extension dialog is open (ui_prompt_start/end). */
|
|
70
|
+
dialogOpen: boolean;
|
|
71
|
+
/** Timestamp of the previous bare Esc press, 0 when the window expired. */
|
|
72
|
+
lastEscapeAt: number;
|
|
73
|
+
/** Serializes every git operation that touches the shadow index. */
|
|
74
|
+
queue: Promise<unknown>;
|
|
75
|
+
/** Guards against repeating the doubleEscapeAction warning. */
|
|
76
|
+
warnedAboutSetting: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function (pi: ExtensionAPI) {
|
|
80
|
+
const state: RewindState = {
|
|
81
|
+
store: null,
|
|
82
|
+
sessionId: null,
|
|
83
|
+
disabledReason: null,
|
|
84
|
+
ctx: null,
|
|
85
|
+
dialogOpen: false,
|
|
86
|
+
lastEscapeAt: 0,
|
|
87
|
+
queue: Promise.resolve(),
|
|
88
|
+
warnedAboutSetting: false,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Run git work one job at a time: two concurrent `git add` calls on the
|
|
92
|
+
* same shadow index would corrupt it. */
|
|
93
|
+
function serialized<T>(task: () => Promise<T>): Promise<T> {
|
|
94
|
+
const run = state.queue.then(task, task);
|
|
95
|
+
state.queue = run.then(
|
|
96
|
+
() => undefined,
|
|
97
|
+
() => undefined,
|
|
98
|
+
);
|
|
99
|
+
return run;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
103
|
+
if (!ctx.hasUI) return;
|
|
104
|
+
if (!state.store || !state.sessionId) {
|
|
105
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const count = listCheckpoints(state.store, state.sessionId).length;
|
|
109
|
+
const theme = ctx.ui.theme;
|
|
110
|
+
ctx.ui.setStatus(
|
|
111
|
+
STATUS_KEY,
|
|
112
|
+
`${theme.fg("dim", "◆")} ${theme.fg("muted", `${count} checkpoint${count === 1 ? "" : "s"}`)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
// Session lifecycle
|
|
118
|
+
// ===========================================================================
|
|
119
|
+
|
|
120
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
121
|
+
state.ctx = ctx;
|
|
122
|
+
state.dialogOpen = false;
|
|
123
|
+
state.lastEscapeAt = 0;
|
|
124
|
+
|
|
125
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
126
|
+
state.sessionId = sessionId;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
state.store = openStore(ctx.cwd, getAgentDir());
|
|
130
|
+
state.disabledReason = null;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
state.store = null;
|
|
133
|
+
state.disabledReason = error instanceof Error ? error.message : String(error);
|
|
134
|
+
ctx.ui.notify(`Rewind unavailable: ${state.disabledReason}`, "warning");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Esc+Esc replaces pi's built-in double-escape action; warn when the
|
|
139
|
+
// setting still points at the tree navigator.
|
|
140
|
+
warnIfDoubleEscapeStillBuiltIn(ctx);
|
|
141
|
+
|
|
142
|
+
const store = state.store;
|
|
143
|
+
const resumeCheckpointCount = listCheckpoints(store, sessionId).length;
|
|
144
|
+
|
|
145
|
+
// Snapshot the state the session starts from. Not awaited: a cold shadow
|
|
146
|
+
// index on a large repo can take seconds and must not delay startup.
|
|
147
|
+
serialized(async () => {
|
|
148
|
+
await createCheckpoint(store, { sessionId, kind: "session-start" });
|
|
149
|
+
await pruneSession(store, sessionId, MAX_CHECKPOINTS_PER_SESSION);
|
|
150
|
+
if (ctx.hasUI) updateStatus(ctx);
|
|
151
|
+
}).catch(() => undefined);
|
|
152
|
+
|
|
153
|
+
// Retention sweep + gc in the background.
|
|
154
|
+
serialized(async () => {
|
|
155
|
+
const swept = await pruneStaleSessions(store);
|
|
156
|
+
if (swept > 0) await gc(store);
|
|
157
|
+
if (resumeCheckpointCount > 0 && ctx.hasUI) updateStatus(ctx);
|
|
158
|
+
}).catch(() => undefined);
|
|
159
|
+
|
|
160
|
+
attachInputListener(ctx);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.on("session_shutdown", async () => {
|
|
164
|
+
// Let in-flight checkpoint work finish before pi tears the session down.
|
|
165
|
+
await state.queue.catch(() => undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ===========================================================================
|
|
169
|
+
// Checkpoints: one per user prompt, taken before the turn runs
|
|
170
|
+
// ===========================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `message_end` for the user message is the moment pi records a prompt, and
|
|
174
|
+
* it happens before any tool of that turn executes — so the snapshot really
|
|
175
|
+
* is "the code as it was when you sent this". It also covers follow-up and
|
|
176
|
+
* steered prompts, which `before_agent_start` never sees.
|
|
177
|
+
*
|
|
178
|
+
* The user entry itself only lands in the session tree a little later (pi
|
|
179
|
+
* appends it while the assistant message is recorded), so the conversation
|
|
180
|
+
* target is attached on the assistant message / turn end instead. Matching by
|
|
181
|
+
* prompt text keeps multiple pending prompts apart.
|
|
182
|
+
*/
|
|
183
|
+
pi.on("message_end", async (event, ctx) => {
|
|
184
|
+
state.ctx = ctx;
|
|
185
|
+
const role = event.message?.role;
|
|
186
|
+
if (role !== "user" && role !== "assistant") return;
|
|
187
|
+
if (!state.store || !state.sessionId) return;
|
|
188
|
+
|
|
189
|
+
if (role === "user") {
|
|
190
|
+
const prompt = messageText(event.message.content);
|
|
191
|
+
const store = state.store;
|
|
192
|
+
const sessionId = state.sessionId;
|
|
193
|
+
// Awaited on purpose: the snapshot must be complete before this turn's
|
|
194
|
+
// first tool can touch the working tree.
|
|
195
|
+
await serialized(async () => {
|
|
196
|
+
await createCheckpoint(store, { sessionId, kind: "prompt", prompt });
|
|
197
|
+
await pruneSession(store, sessionId, MAX_CHECKPOINTS_PER_SESSION);
|
|
198
|
+
if (ctx.hasUI) updateStatus(ctx);
|
|
199
|
+
}).catch(() => undefined);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
resolveEntryIds(ctx);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
207
|
+
state.ctx = ctx;
|
|
208
|
+
resolveEntryIds(ctx);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function resolveEntryIds(ctx: ExtensionContext): void {
|
|
212
|
+
const store = state.store;
|
|
213
|
+
const sessionId = state.sessionId;
|
|
214
|
+
if (!store || !sessionId) return;
|
|
215
|
+
|
|
216
|
+
const all = listCheckpoints(store, sessionId);
|
|
217
|
+
const unresolved = all.filter((cp) => cp.kind === "prompt" && !cp.entryId);
|
|
218
|
+
if (unresolved.length === 0) return;
|
|
219
|
+
|
|
220
|
+
const usedEntryIds = new Set(
|
|
221
|
+
all.filter((cp) => cp.entryId).map((cp) => cp.entryId as string),
|
|
222
|
+
);
|
|
223
|
+
const branch = ctx.sessionManager.getBranch();
|
|
224
|
+
|
|
225
|
+
for (const checkpoint of unresolved) {
|
|
226
|
+
for (let i = branch.length - 1; i >= 0; i -= 1) {
|
|
227
|
+
const entry = branch[i];
|
|
228
|
+
if (entry.type !== "message") continue;
|
|
229
|
+
if (entry.message.role !== "user") continue;
|
|
230
|
+
if (usedEntryIds.has(entry.id)) continue;
|
|
231
|
+
if (messageText(entry.message.content) !== checkpoint.prompt) continue;
|
|
232
|
+
attachEntryId(store, sessionId, checkpoint.id, entry.id);
|
|
233
|
+
usedEntryIds.add(entry.id);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ===========================================================================
|
|
240
|
+
// Pre-images: files the worktree snapshot cannot see
|
|
241
|
+
// ===========================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fires after `tool_execution_start` and before the tool runs, which is the
|
|
245
|
+
* only moment the old bytes are still on disk. Only the built-in file editors
|
|
246
|
+
* are tracked — a `bash` command (`sed -i`, `cat >`) can touch anything and
|
|
247
|
+
* is not parseable, so inside the project root those changes are still caught
|
|
248
|
+
* by the worktree snapshot while edits *outside* the root are not.
|
|
249
|
+
*/
|
|
250
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
251
|
+
state.ctx = ctx;
|
|
252
|
+
if (!state.store || !state.sessionId) return;
|
|
253
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
254
|
+
const path = (event.input as { path?: unknown } | undefined)?.path;
|
|
255
|
+
if (typeof path !== "string" || path === "") return;
|
|
256
|
+
|
|
257
|
+
const store = state.store;
|
|
258
|
+
const sessionId = state.sessionId;
|
|
259
|
+
// Serialized with the checkpoint writes: both rewrite the metadata file.
|
|
260
|
+
// Awaited, so the copy is complete before the tool can change the file.
|
|
261
|
+
await serialized(async () => {
|
|
262
|
+
captureExternalFile(store, sessionId, path);
|
|
263
|
+
}).catch(() => undefined);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ===========================================================================
|
|
267
|
+
// /rewind
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
|
|
270
|
+
pi.registerCommand("rewind", {
|
|
271
|
+
description: "Rewind code and/or conversation to an earlier prompt (Claude Code style)",
|
|
272
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
273
|
+
state.ctx = ctx;
|
|
274
|
+
// Ignore Esc presses while the menu dialogs are open.
|
|
275
|
+
state.dialogOpen = true;
|
|
276
|
+
state.lastEscapeAt = 0;
|
|
277
|
+
try {
|
|
278
|
+
if (!state.store || !state.sessionId) {
|
|
279
|
+
ctx.ui.notify(
|
|
280
|
+
state.disabledReason
|
|
281
|
+
? `Rewind unavailable: ${state.disabledReason}`
|
|
282
|
+
: "Rewind is not ready yet",
|
|
283
|
+
"warning",
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Late resolution attempt for prompts whose entry id is still missing.
|
|
288
|
+
resolveEntryIds(ctx);
|
|
289
|
+
await runRewindFlow(ctx, {
|
|
290
|
+
store: state.store,
|
|
291
|
+
sessionId: state.sessionId,
|
|
292
|
+
// Scrollable picker: pi's built-in select renders every row, which
|
|
293
|
+
// pushes the cursor out of the viewport on long sessions.
|
|
294
|
+
pick: pickFromList,
|
|
295
|
+
});
|
|
296
|
+
if (ctx.hasUI) updateStatus(ctx);
|
|
297
|
+
} finally {
|
|
298
|
+
state.dialogOpen = false;
|
|
299
|
+
state.lastEscapeAt = 0;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ===========================================================================
|
|
305
|
+
// Esc + Esc
|
|
306
|
+
// ===========================================================================
|
|
307
|
+
|
|
308
|
+
pi.on("ui_prompt_start", async () => {
|
|
309
|
+
state.dialogOpen = true;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
pi.on("ui_prompt_end", async () => {
|
|
313
|
+
state.dialogOpen = false;
|
|
314
|
+
state.lastEscapeAt = 0;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
let inputUnsubscribe: (() => void) | null = null;
|
|
318
|
+
|
|
319
|
+
function attachInputListener(ctx: ExtensionContext): void {
|
|
320
|
+
if (inputUnsubscribe) return;
|
|
321
|
+
inputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
322
|
+
// A bare Esc is exactly "\x1b"; arrow keys and other sequences are longer
|
|
323
|
+
// and must not count towards a double press.
|
|
324
|
+
if (data !== "\x1b") {
|
|
325
|
+
state.lastEscapeAt = 0;
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
// Never consume: Esc keeps aborting streams, `!` bash runs and dialogs.
|
|
329
|
+
if (state.dialogOpen) return undefined;
|
|
330
|
+
const current = state.ctx;
|
|
331
|
+
if (!current || !current.isIdle()) return undefined;
|
|
332
|
+
if (current.ui.getEditorText().trim() !== "") return undefined;
|
|
333
|
+
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
if (now - state.lastEscapeAt <= DOUBLE_ESCAPE_WINDOW_MS) {
|
|
336
|
+
state.lastEscapeAt = 0;
|
|
337
|
+
dispatchRewind();
|
|
338
|
+
// Consume this second press. Two reasons: the selector opened by
|
|
339
|
+
// dispatchRewind() is focused synchronously, so an unconsumed Esc would
|
|
340
|
+
// land on it and cancel the menu it just opened; and consuming keeps
|
|
341
|
+
// the press from pi's built-in double-escape action, so /rewind takes
|
|
342
|
+
// over even when `doubleEscapeAction` is still "tree" or "fork".
|
|
343
|
+
return { consume: true };
|
|
344
|
+
}
|
|
345
|
+
// The first press is passed through untouched, so a lone Esc still
|
|
346
|
+
// aborts streams, aborts `!` bash runs and cancels dialogs.
|
|
347
|
+
state.lastEscapeAt = now;
|
|
348
|
+
return undefined;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Dispatch the /rewind command through pi's own command path, so the handler
|
|
354
|
+
* receives a command context (with navigateTree) — a shortcut or raw-input
|
|
355
|
+
* handler only ever gets a plain context.
|
|
356
|
+
*/
|
|
357
|
+
function dispatchRewind(): void {
|
|
358
|
+
try {
|
|
359
|
+
pi.sendUserMessage("/rewind", { expandPromptTemplates: true });
|
|
360
|
+
} catch (error) {
|
|
361
|
+
state.ctx?.ui.notify(
|
|
362
|
+
`Could not open /rewind: ${error instanceof Error ? error.message : String(error)}`,
|
|
363
|
+
"warning",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function warnIfDoubleEscapeStillBuiltIn(ctx: ExtensionContext): void {
|
|
369
|
+
if (state.warnedAboutSetting || !ctx.hasUI) return;
|
|
370
|
+
state.warnedAboutSetting = true;
|
|
371
|
+
try {
|
|
372
|
+
const settingsPath = join(getAgentDir(), "settings.json");
|
|
373
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
|
374
|
+
doubleEscapeAction?: string;
|
|
375
|
+
};
|
|
376
|
+
const action = settings.doubleEscapeAction ?? "tree";
|
|
377
|
+
if (action !== "none") {
|
|
378
|
+
ctx.ui.notify(
|
|
379
|
+
`Esc+Esc still opens pi's ${action} view. Set "doubleEscapeAction": "none" in ${settingsPath} to let /rewind take over.`,
|
|
380
|
+
"warning",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// No readable settings file — nothing to warn about.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Plain text of a message's content (string or content parts). */
|
|
390
|
+
function messageText(content: unknown): string {
|
|
391
|
+
if (typeof content === "string") return content;
|
|
392
|
+
if (!Array.isArray(content)) return "";
|
|
393
|
+
const parts: string[] = [];
|
|
394
|
+
for (const part of content) {
|
|
395
|
+
if (part && typeof part === "object" && "text" in part && typeof part.text === "string") {
|
|
396
|
+
parts.push(part.text);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return parts.join("\n");
|
|
400
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-rewind picker — the scrollable checkpoint selector behind `/rewind`.
|
|
3
|
+
*
|
|
4
|
+
* Why not `ctx.ui.select()`: pi's built-in extension selector appends one row
|
|
5
|
+
* per option and never scrolls (`ExtensionSelectorComponent.updateList()`), so
|
|
6
|
+
* a session with more checkpoints than the terminal has rows produces a dialog
|
|
7
|
+
* taller than the viewport — and pi-tui paints only the **last**
|
|
8
|
+
* `terminal.rows` lines of the document, which cuts the dialog's top away:
|
|
9
|
+
* the title, and the `→` cursor row it opens on. The user is left with a list
|
|
10
|
+
* whose selection arrow is somewhere off screen.
|
|
11
|
+
*
|
|
12
|
+
* This picker keeps the dialog inside the viewport instead: the list window is
|
|
13
|
+
* sized to the terminal (`pickerListRows` in viewport.ts) and pi-tui's
|
|
14
|
+
* `SelectList` windows the rows around the selection, so the arrow is always
|
|
15
|
+
* visible and the list scrolls. `SelectList` also draws a `(12/37)` position
|
|
16
|
+
* line while scrolling, and it is theme-aware and mouse-aware for free.
|
|
17
|
+
*
|
|
18
|
+
* Movement keys are handled here rather than inside `SelectList` for two
|
|
19
|
+
* reasons: `SelectList` wraps around at both ends (`up` on the newest
|
|
20
|
+
* checkpoint jumps to the oldest) while pi's own dialogs clamp, and it has no
|
|
21
|
+
* page/home/end handling. Keys therefore clamp, `PageUp`/`PageDown` move a
|
|
22
|
+
* window at a time, `Home`/`End` jump to the ends, and `j`/`k` work like they
|
|
23
|
+
* do in pi's built-in selector. `Enter`/`Esc` are left to `SelectList`, so
|
|
24
|
+
* their configured bindings apply.
|
|
25
|
+
*
|
|
26
|
+
* Hosts that cannot render extension components (RPC, print) make
|
|
27
|
+
* `ctx.ui.custom()` resolve `undefined`; the caller (`pickValue` in flow.ts)
|
|
28
|
+
* then falls back to `ctx.ui.select()`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import {
|
|
33
|
+
DynamicBorder,
|
|
34
|
+
getSelectListTheme,
|
|
35
|
+
keyHint,
|
|
36
|
+
keyText,
|
|
37
|
+
rawKeyHint,
|
|
38
|
+
} from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import {
|
|
40
|
+
type Component,
|
|
41
|
+
Container,
|
|
42
|
+
Key,
|
|
43
|
+
SelectList,
|
|
44
|
+
Text,
|
|
45
|
+
type TuiMouseEvent,
|
|
46
|
+
matchesKey,
|
|
47
|
+
truncateToWidth,
|
|
48
|
+
} from "@earendil-works/pi-tui";
|
|
49
|
+
|
|
50
|
+
import type { PickOutcome, PickerItem } from "./flow.ts";
|
|
51
|
+
import { pickerListRows } from "./viewport.ts";
|
|
52
|
+
|
|
53
|
+
export async function pickFromList(
|
|
54
|
+
ctx: ExtensionCommandContext,
|
|
55
|
+
title: string,
|
|
56
|
+
items: PickerItem[],
|
|
57
|
+
): Promise<PickOutcome> {
|
|
58
|
+
const picked = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
|
|
59
|
+
const rows = pickerListRows(tui.terminal?.rows);
|
|
60
|
+
const list = new SelectList(items, Math.min(items.length, rows), getSelectListTheme());
|
|
61
|
+
list.onSelect = (item) => done(item.value);
|
|
62
|
+
list.onCancel = () => done(null);
|
|
63
|
+
|
|
64
|
+
const container = new Container();
|
|
65
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
66
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
67
|
+
container.addChild(list);
|
|
68
|
+
container.addChild(new Line(() => hintLine(theme, items.length > rows)));
|
|
69
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
70
|
+
|
|
71
|
+
/** Where the cursor is; `SelectList` exposes the selected item, not its index. */
|
|
72
|
+
const cursor = (): number => {
|
|
73
|
+
const item = list.getSelectedItem();
|
|
74
|
+
return item ? items.indexOf(item) : 0;
|
|
75
|
+
};
|
|
76
|
+
const moveTo = (index: number): void => {
|
|
77
|
+
list.setSelectedIndex(Math.max(0, Math.min(items.length - 1, index)));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
render: (width: number) => container.render(width),
|
|
82
|
+
invalidate: () => container.invalidate(),
|
|
83
|
+
handleInput: (data: string) => {
|
|
84
|
+
if (keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
85
|
+
moveTo(cursor() - 1);
|
|
86
|
+
} else if (keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
87
|
+
moveTo(cursor() + 1);
|
|
88
|
+
} else if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
89
|
+
moveTo(cursor() - rows);
|
|
90
|
+
} else if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
91
|
+
moveTo(cursor() + rows);
|
|
92
|
+
} else if (matchesKey(data, Key.home)) {
|
|
93
|
+
moveTo(0);
|
|
94
|
+
} else if (matchesKey(data, Key.end)) {
|
|
95
|
+
moveTo(items.length - 1);
|
|
96
|
+
} else {
|
|
97
|
+
list.handleInput(data);
|
|
98
|
+
}
|
|
99
|
+
tui.requestRender();
|
|
100
|
+
},
|
|
101
|
+
handleMouse: (event: TuiMouseEvent) => container.handleMouse(event),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// `undefined` is what a host that cannot render custom components returns;
|
|
106
|
+
// `null` is this component's own cancel value.
|
|
107
|
+
if (picked === undefined) return { status: "unsupported" };
|
|
108
|
+
if (picked === null) return { status: "cancelled" };
|
|
109
|
+
return { status: "picked", value: picked };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Footer hint. The paging keys only matter once the list does not fit. */
|
|
113
|
+
function hintLine(theme: Theme, pageable: boolean): string {
|
|
114
|
+
const parts = [rawKeyHint("↑↓", "navigate")];
|
|
115
|
+
if (pageable) {
|
|
116
|
+
// Both page keys in one hint: `keyHint` would print the key name twice.
|
|
117
|
+
const keys = `${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")}`;
|
|
118
|
+
parts.push(`${theme.fg("dim", keys)}${theme.fg("muted", " page")}`);
|
|
119
|
+
}
|
|
120
|
+
parts.push(keyHint("tui.select.confirm", "select"), keyHint("tui.select.cancel", "cancel"));
|
|
121
|
+
return ` ${parts.join(" ")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A single line that truncates to the render width instead of wrapping into two. */
|
|
125
|
+
class Line implements Component {
|
|
126
|
+
constructor(private readonly text: () => string) {}
|
|
127
|
+
|
|
128
|
+
render(width: number): string[] {
|
|
129
|
+
return [truncateToWidth(this.text(), width, "…")];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
invalidate(): void {
|
|
133
|
+
// Nothing cached: the text is rebuilt from the live theme on every render.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the picker layout budget.
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test ~/.pi/agent/extensions/rewind/viewport.test.ts
|
|
5
|
+
*
|
|
6
|
+
* The point of these cases is the invariant, not the arithmetic: whatever the
|
|
7
|
+
* terminal height, the dialog (list + its own chrome) plus the rows pi paints
|
|
8
|
+
* below it must fit the viewport. That is what keeps the selection arrow on
|
|
9
|
+
* screen for a session with more checkpoints than the terminal has rows.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_TERMINAL_ROWS,
|
|
17
|
+
PICKER_CHROME_ROWS,
|
|
18
|
+
PICKER_MAX_LIST_ROWS,
|
|
19
|
+
PICKER_TAIL_RESERVE_ROWS,
|
|
20
|
+
pickerListRows,
|
|
21
|
+
resolveTerminalRows,
|
|
22
|
+
} from "./viewport.ts";
|
|
23
|
+
|
|
24
|
+
describe("resolveTerminalRows", () => {
|
|
25
|
+
it("falls back for values a non-TTY host reports", () => {
|
|
26
|
+
assert.equal(resolveTerminalRows(undefined), DEFAULT_TERMINAL_ROWS);
|
|
27
|
+
assert.equal(resolveTerminalRows(Number.NaN), DEFAULT_TERMINAL_ROWS);
|
|
28
|
+
assert.equal(resolveTerminalRows(0), DEFAULT_TERMINAL_ROWS);
|
|
29
|
+
assert.equal(resolveTerminalRows(-3), DEFAULT_TERMINAL_ROWS);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps real heights, floored to whole rows", () => {
|
|
33
|
+
assert.equal(resolveTerminalRows(41), 41);
|
|
34
|
+
assert.equal(resolveTerminalRows(41.9), 41);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("pickerListRows", () => {
|
|
39
|
+
it("leaves the chrome and the rows below the editor alone", () => {
|
|
40
|
+
assert.equal(pickerListRows(24), 24 - PICKER_CHROME_ROWS - PICKER_TAIL_RESERVE_ROWS);
|
|
41
|
+
assert.equal(pickerListRows(30), 30 - PICKER_CHROME_ROWS - PICKER_TAIL_RESERVE_ROWS);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("caps tall terminals so the picker stays a picker", () => {
|
|
45
|
+
assert.equal(pickerListRows(120), PICKER_MAX_LIST_ROWS);
|
|
46
|
+
assert.equal(pickerListRows(1_000), PICKER_MAX_LIST_ROWS);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never returns less than one row, however short the terminal", () => {
|
|
50
|
+
assert.equal(pickerListRows(1), 1);
|
|
51
|
+
assert.equal(pickerListRows(8), 1);
|
|
52
|
+
assert.equal(pickerListRows(12), 2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("falls back to the assumed height when the size is unknown", () => {
|
|
56
|
+
assert.equal(pickerListRows(undefined), pickerListRows(DEFAULT_TERMINAL_ROWS));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("degrades to a single row on terminals shorter than the dialog chrome", () => {
|
|
60
|
+
for (let rows = 1; rows < PICKER_CHROME_ROWS + PICKER_TAIL_RESERVE_ROWS; rows += 1) {
|
|
61
|
+
assert.equal(pickerListRows(rows), 1, `rows=${rows}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps the whole dialog inside the viewport at every height", () => {
|
|
66
|
+
// The dialog is the list plus its own chrome; the tail reserve stands in for
|
|
67
|
+
// the footer and the widgets pi paints below the editor container.
|
|
68
|
+
// Below this the chrome + tail reserve alone already fill the screen, so
|
|
69
|
+
// there is nothing to fit — see the single-row case above.
|
|
70
|
+
const minimumRows = PICKER_CHROME_ROWS + PICKER_TAIL_RESERVE_ROWS + 1;
|
|
71
|
+
for (let rows = minimumRows; rows <= 200; rows += 1) {
|
|
72
|
+
const painted = pickerListRows(rows) + PICKER_CHROME_ROWS + PICKER_TAIL_RESERVE_ROWS;
|
|
73
|
+
assert.ok(painted <= rows, `rows=${rows} would paint ${painted} lines`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-rewind picker layout — how many checkpoint rows the dialog may show.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: pi-tui renders only the **last** `terminal.rows` lines of
|
|
5
|
+
* the document (`TuiMainScreen.render()`: `viewportStart = max(0, height -
|
|
6
|
+
* terminal.rows)`), so a dialog taller than the terminal loses its top. pi's
|
|
7
|
+
* built-in extension selector renders one row per option and never scrolls
|
|
8
|
+
* (`ExtensionSelectorComponent.updateList()`), which means a long checkpoint
|
|
9
|
+
* list pushes the title — and the `→` cursor row the dialog opens on — off the
|
|
10
|
+
* top of the screen, leaving the user with no visible cursor.
|
|
11
|
+
*
|
|
12
|
+
* The picker therefore sizes its window to the terminal and scrolls instead.
|
|
13
|
+
* The rows it must leave alone are named here, in one place:
|
|
14
|
+
*
|
|
15
|
+
* - `PICKER_CHROME_ROWS`: the dialog's own non-list lines — top border,
|
|
16
|
+
* title, key hint, bottom border, plus `SelectList`'s `(12/37)` line that
|
|
17
|
+
* only appears while the list is scrolled. One row of slack is included so
|
|
18
|
+
* a surprising extra line (a truncated hint that still wraps) cannot push
|
|
19
|
+
* the title out of view.
|
|
20
|
+
* - `PICKER_TAIL_RESERVE_ROWS`: what pi renders **below** the editor
|
|
21
|
+
* container, which is where the custom component lives — the footer
|
|
22
|
+
* (`⚡️ model | Ctx …`), the extra statusline rows, and any
|
|
23
|
+
* `placement: belowEditor` widget such as pi-subagents' fleet line. Those
|
|
24
|
+
* rows are painted after the dialog and would otherwise cover its bottom.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Lines the picker dialog spends outside the list. */
|
|
28
|
+
export const PICKER_CHROME_ROWS = 6;
|
|
29
|
+
/** Lines to leave for what pi paints below the editor container. */
|
|
30
|
+
export const PICKER_TAIL_RESERVE_ROWS = 4;
|
|
31
|
+
/** Upper bound, so the picker does not take over a tall terminal. */
|
|
32
|
+
export const PICKER_MAX_LIST_ROWS = 20;
|
|
33
|
+
/** Assumed height when the host cannot report a terminal size. */
|
|
34
|
+
export const DEFAULT_TERMINAL_ROWS = 24;
|
|
35
|
+
|
|
36
|
+
/** Rows the picker dialog can show, for a terminal height that may be unknown. */
|
|
37
|
+
export function pickerListRows(terminalRows: number | undefined): number {
|
|
38
|
+
const available = resolveTerminalRows(terminalRows) - PICKER_CHROME_ROWS - PICKER_TAIL_RESERVE_ROWS;
|
|
39
|
+
return Math.max(1, Math.min(PICKER_MAX_LIST_ROWS, available));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Terminal height with the non-TTY cases folded into a usable number. */
|
|
43
|
+
export function resolveTerminalRows(terminalRows: number | undefined): number {
|
|
44
|
+
if (typeof terminalRows !== "number" || !Number.isFinite(terminalRows) || terminalRows <= 0) {
|
|
45
|
+
return DEFAULT_TERMINAL_ROWS;
|
|
46
|
+
}
|
|
47
|
+
return Math.floor(terminalRows);
|
|
48
|
+
}
|