@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,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
+ }