pi-fast-resume 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/fast-resume.ts ADDED
@@ -0,0 +1,1072 @@
1
+ /**
2
+ * pi-fast-resume — Fast session picker for pi
3
+ *
4
+ * Reads only the first 16KB of each session file (header + first messages)
5
+ * instead of parsing the entire JSONL. Shows results instantly with
6
+ * incremental background loading.
7
+ *
8
+ * Mirrors the exact TUI layout and keybindings of pi's built-in /resume.
9
+ *
10
+ * Usage:
11
+ * /fast-resume [query] Open fast session picker (current project scope)
12
+ * Ctrl+Shift+F Open fast session picker via shortcut
13
+ *
14
+ * Hijack mode (on by default, opt-out via ~/.pi/agent/extensions/pi-fast-resume.json):
15
+ * { "hijackResume": false }
16
+ *
17
+ * When enabled, /resume and Ctrl+Shift+R open the fast picker instead.
18
+ * /fast-resume is not registered (no duplicate). pi -r is not affected.
19
+ *
20
+ * Keys in picker (identical to /resume):
21
+ * ↑/↓ Navigate
22
+ * Tab Toggle scope (Current Folder / All)
23
+ * Ctrl+S Toggle sort (Threaded / Recent / Fuzzy)
24
+ * Ctrl+N Toggle name filter (All / Named)
25
+ * Ctrl+P Toggle session path display
26
+ * Ctrl+D Delete session (with confirmation)
27
+ * Ctrl+R Rename session
28
+ * Enter Select session
29
+ * Esc Cancel
30
+ * typing Filter sessions by text search
31
+ *
32
+ * Search modes (identical to /resume):
33
+ * fuzzy words foo bar fuzzy-match each token
34
+ * exact phrase "node cve" case-insensitive substring
35
+ * regex re:<pattern> RegExp search (case-insensitive)
36
+ *
37
+ * Note on search depth: pi-fast-resume only reads the first 16KB of each
38
+ * session file, so search matches against id + name + firstMessage + cwd.
39
+ * Upstream /resume matches against all messages (allMessagesText). This
40
+ * tradeoff is by design — the 6ms load time depends on partial reads.
41
+ */
42
+
43
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
44
+ import {
45
+ DynamicBorder,
46
+ InteractiveMode,
47
+ keyHint,
48
+ keyText,
49
+ SessionManager,
50
+ Theme,
51
+ } from "@earendil-works/pi-coding-agent";
52
+ import {
53
+ Container,
54
+ type Component,
55
+ getKeybindings,
56
+ Input,
57
+ Key,
58
+ Spacer,
59
+ Text,
60
+ truncateToWidth,
61
+ visibleWidth,
62
+ } from "@earendil-works/pi-tui";
63
+ import { existsSync, readFileSync } from "node:fs";
64
+ import { unlink } from "node:fs/promises";
65
+ import { homedir } from "node:os";
66
+ import { join } from "node:path";
67
+ import { spawnSync } from "node:child_process";
68
+ import {
69
+ scanAllSessionDirs,
70
+ scanSessionDir,
71
+ loadSessionHeaders,
72
+ sortByModified,
73
+ sortByModifiedDesc,
74
+ canonicalizePath,
75
+ type SessionHeader,
76
+ type SessionFileMeta,
77
+ } from "./src/scanner.js";
78
+ import {
79
+ parseSearchQuery,
80
+ matchSession,
81
+ hasSessionName,
82
+ filterAndSortSessions,
83
+ buildSessionTree,
84
+ flattenSessionTree,
85
+ buildTreePrefix,
86
+ type FlatSessionNode,
87
+ type SortMode,
88
+ type NameFilter,
89
+ } from "./src/search.js";
90
+ import type { PickerScope } from "./src/picker-state.js";
91
+
92
+ const HOME = homedir();
93
+
94
+ // Config — read from ~/.pi/agent/extensions/pi-fast-resume.json
95
+ // Example: { "hijackResume": false } to disable hijack
96
+ // By default hijackResume is true — /resume opens the fast picker
97
+ interface FastResumeConfig {
98
+ hijackResume?: boolean;
99
+ }
100
+
101
+ const CONFIG_PATH = join(HOME, ".pi", "agent", "extensions", "pi-fast-resume.json");
102
+
103
+ function readConfig(): FastResumeConfig {
104
+ try {
105
+ if (!existsSync(CONFIG_PATH)) return {};
106
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
107
+ return JSON.parse(raw) as FastResumeConfig;
108
+ } catch {
109
+ return {};
110
+ }
111
+ }
112
+
113
+ export interface FastResumeResult {
114
+ sessionPath?: string;
115
+ cancelled: boolean;
116
+ }
117
+
118
+ type StatusMessage = { type: "info" | "error"; message: string };
119
+
120
+ // Helpers
121
+
122
+ function shortenPath(path: string): string {
123
+ if (!path) return path;
124
+ if (path.startsWith(HOME)) {
125
+ return `~${path.slice(HOME.length)}`;
126
+ }
127
+ return path;
128
+ }
129
+
130
+ function formatSessionDate(date: Date): string {
131
+ const now = new Date();
132
+ const diffMs = now.getTime() - date.getTime();
133
+ const diffMins = Math.floor(diffMs / 60000);
134
+ const diffHours = Math.floor(diffMs / 3600000);
135
+ const diffDays = Math.floor(diffMs / 86400000);
136
+ if (diffMins < 1) return "now";
137
+ if (diffMins < 60) return `${diffMins}m`;
138
+ if (diffHours < 24) return `${diffHours}h`;
139
+ if (diffDays < 7) return `${diffDays}d`;
140
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w`;
141
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo`;
142
+ return `${Math.floor(diffDays / 365)}y`;
143
+ }
144
+
145
+ async function deleteSessionFile(sessionPath: string): Promise<{ ok: boolean; method?: string; error?: string }> {
146
+ // Try `trash` first (if installed)
147
+ const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
148
+ const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
149
+
150
+ const getTrashErrorHint = () => {
151
+ const parts: string[] = [];
152
+ if (trashResult.error) {
153
+ parts.push(trashResult.error.message);
154
+ }
155
+ const stderr = trashResult.stderr?.trim();
156
+ if (stderr) {
157
+ parts.push(stderr.split("\n")[0] ?? stderr);
158
+ }
159
+ if (parts.length === 0) return null;
160
+ return `trash: ${parts.join(" · ").slice(0, 200)}`;
161
+ };
162
+
163
+ if (trashResult.status === 0 || !existsSync(sessionPath)) {
164
+ return { ok: true, method: "trash" };
165
+ }
166
+
167
+ // Fallback to permanent deletion
168
+ try {
169
+ await unlink(sessionPath);
170
+ return { ok: true, method: "unlink" };
171
+ } catch (err) {
172
+ const unlinkError = err instanceof Error ? err.message : String(err);
173
+ const trashErrorHint = getTrashErrorHint();
174
+ const error = trashErrorHint ? `${unlinkError} (${trashErrorHint})` : unlinkError;
175
+ return { ok: false, method: "unlink", error };
176
+ }
177
+ }
178
+
179
+ // Header — mirrors SessionSelectorHeader exactly:
180
+ // Line 1: Title (left) │ Scope + Name + Sort indicators (right)
181
+ // Line 2: Hint line 1 (scope toggle + search hints)
182
+ // Line 3: Hint line 2 (sort/named/delete/path/rename)
183
+
184
+ class FastResumeHeader implements Component {
185
+ private theme: Theme;
186
+ scope: PickerScope = "current";
187
+ sortMode: SortMode = "threaded";
188
+ nameFilter: NameFilter = "all";
189
+ loading = false;
190
+ loadProgress: { loaded: number; total: number } | null = null;
191
+ showPath = false;
192
+ confirmingDeletePath: string | null = null;
193
+ statusMessage: StatusMessage | null = null;
194
+ private statusTimeout: ReturnType<typeof setTimeout> | null = null;
195
+ showRenameHint = true;
196
+ private requestRender: () => void;
197
+
198
+ constructor(theme: Theme, requestRender: () => void) {
199
+ this.theme = theme;
200
+ this.requestRender = requestRender;
201
+ }
202
+
203
+ clearStatusTimeout(): void {
204
+ if (!this.statusTimeout) return;
205
+ clearTimeout(this.statusTimeout);
206
+ this.statusTimeout = null;
207
+ }
208
+
209
+ setStatusMessage(msg: StatusMessage | null, autoHideMs?: number): void {
210
+ this.clearStatusTimeout();
211
+ this.statusMessage = msg;
212
+ if (!msg || !autoHideMs) return;
213
+ this.statusTimeout = setTimeout(() => {
214
+ this.statusMessage = null;
215
+ this.statusTimeout = null;
216
+ this.requestRender();
217
+ }, autoHideMs);
218
+ }
219
+
220
+ invalidate(): void {}
221
+
222
+ render(width: number): string[] {
223
+ const t = this.theme;
224
+
225
+ // Title (left side)
226
+ const title = this.scope === "current"
227
+ ? t.bold("Resume Session (Current Folder)")
228
+ : t.bold("Resume Session (All)");
229
+
230
+ // Right side: scope indicators + name filter + sort mode
231
+ let scopeText: string;
232
+ if (this.loading) {
233
+ const progressText = this.loadProgress
234
+ ? `${this.loadProgress.loaded}/${this.loadProgress.total}`
235
+ : "...";
236
+ scopeText = `${t.fg("muted", "○ Current Folder | ")}${t.fg("accent", `Loading ${progressText}`)}`;
237
+ } else if (this.scope === "current") {
238
+ scopeText = `${t.fg("accent", "◉ Current Folder")}${t.fg("muted", " | ○ All")}`;
239
+ } else {
240
+ scopeText = `${t.fg("muted", "○ Current Folder | ")}${t.fg("accent", "◉ All")}`;
241
+ }
242
+
243
+ const sortLabel = this.sortMode === "threaded" ? "Threaded" : this.sortMode === "recent" ? "Recent" : "Fuzzy";
244
+ const sortText = t.fg("muted", "Sort: ") + t.fg("accent", sortLabel);
245
+
246
+ const nameLabel = this.nameFilter === "all" ? "All" : "Named";
247
+ const nameText = t.fg("muted", "Name: ") + t.fg("accent", nameLabel);
248
+
249
+ const rightText = truncateToWidth(`${scopeText} ${nameText} ${sortText}`, width, "");
250
+ const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
251
+ const left = truncateToWidth(title, availableLeft, "");
252
+ const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
253
+
254
+ // Hint lines — same logic as built-in SessionSelectorHeader
255
+ let hintLine1: string;
256
+ let hintLine2: string;
257
+
258
+ if (this.confirmingDeletePath !== null) {
259
+ const confirmHint = `Delete session? ${keyHint("tui.select.confirm", "confirm")} · ${keyHint("tui.select.cancel", "cancel")}`;
260
+ hintLine1 = t.fg("error", truncateToWidth(confirmHint, width, "…"));
261
+ hintLine2 = "";
262
+ } else if (this.statusMessage) {
263
+ const color = this.statusMessage.type === "error" ? "error" : "accent";
264
+ hintLine1 = t.fg(color, truncateToWidth(this.statusMessage.message, width, "…"));
265
+ hintLine2 = "";
266
+ } else {
267
+ const pathState = this.showPath ? "(on)" : "(off)";
268
+ const sep = t.fg("muted", " · ");
269
+ const hint1 = keyHint("tui.input.tab", "scope") + sep + t.fg("muted", 're:<pattern> regex · "phrase" exact');
270
+ const hint2Parts = [
271
+ keyHint("app.session.toggleSort", "sort"),
272
+ keyHint("app.session.toggleNamedFilter", "named"),
273
+ keyHint("app.session.delete", "delete"),
274
+ keyHint("app.session.togglePath", `path ${pathState}`),
275
+ ];
276
+ if (this.showRenameHint) {
277
+ hint2Parts.push(keyHint("app.session.rename", "rename"));
278
+ }
279
+ hintLine1 = truncateToWidth(hint1, width, "…");
280
+ hintLine2 = truncateToWidth(hint2Parts.join(sep), width, "…");
281
+ }
282
+
283
+ return [
284
+ `${left}${" ".repeat(spacing)}${rightText}`,
285
+ hintLine1,
286
+ hintLine2,
287
+ ];
288
+ }
289
+ }
290
+
291
+ // Session list — mirrors upstream SessionList rendering exactly:
292
+ // search input + blank line + session rows (one line each, right-aligned metadata)
293
+ // Supports tree structure in threaded mode (├─ └─ │ prefixes)
294
+
295
+ class FastResumeSessionList implements Component {
296
+ private theme: Theme;
297
+ allSessions: SessionHeader[] = [];
298
+ filteredNodes: FlatSessionNode[] = [];
299
+ selectedIndex = 0;
300
+ searchInput: Input;
301
+ showCwd = false;
302
+ showPath = false;
303
+ sortMode: SortMode = "threaded";
304
+ nameFilter: NameFilter = "all";
305
+ confirmingDeletePath: string | null = null;
306
+ maxVisible = 10;
307
+ currentSessionCanonicalPath: string | undefined;
308
+
309
+ onSelect?: (sessionPath: string) => void;
310
+ onCancel?: () => void;
311
+ onExit?: () => void;
312
+ onToggleScope?: () => void;
313
+ onToggleSort?: () => void;
314
+ onToggleNameFilter?: () => void;
315
+ onTogglePath?: (showPath: boolean) => void;
316
+ onDeleteConfirmationChange?: (path: string | null) => void;
317
+ onDeleteSession?: (sessionPath: string) => void;
318
+ onRenameSession?: (sessionPath: string) => void;
319
+ onError?: (msg: string) => void;
320
+
321
+ private _focused = false;
322
+ get focused() { return this._focused; }
323
+ set focused(v: boolean) {
324
+ this._focused = v;
325
+ this.searchInput.focused = v;
326
+ }
327
+
328
+ constructor(theme: Theme, currentSessionFilePath: string | undefined) {
329
+ this.theme = theme;
330
+ this.currentSessionCanonicalPath = canonicalizePath(currentSessionFilePath ?? "");
331
+ this.searchInput = new Input();
332
+
333
+ this.searchInput.onSubmit = () => {
334
+ const selected = this.filteredNodes[this.selectedIndex];
335
+ if (selected) {
336
+ this.onSelect?.(selected.session.path);
337
+ }
338
+ };
339
+ }
340
+
341
+ private isCurrentSessionPath(path: string): boolean {
342
+ if (!this.currentSessionCanonicalPath) return false;
343
+ return (canonicalizePath(path) ?? path) === this.currentSessionCanonicalPath;
344
+ }
345
+
346
+ setSortMode(sortMode: SortMode): void {
347
+ this.sortMode = sortMode;
348
+ this.filterSessions(this.searchInput.getValue());
349
+ }
350
+
351
+ setNameFilter(nameFilter: NameFilter): void {
352
+ this.nameFilter = nameFilter;
353
+ this.filterSessions(this.searchInput.getValue());
354
+ }
355
+
356
+ setSessions(sessions: SessionHeader[], showCwd: boolean): void {
357
+ this.allSessions = sessions;
358
+ this.showCwd = showCwd;
359
+ this.filterSessions(this.searchInput.getValue());
360
+ }
361
+
362
+ setConfirmingDeletePath(path: string | null): void {
363
+ this.confirmingDeletePath = path;
364
+ this.onDeleteConfirmationChange?.(path);
365
+ }
366
+
367
+ startDeleteConfirmationForSelectedSession(): void {
368
+ const selected = this.filteredNodes[this.selectedIndex];
369
+ if (!selected) return;
370
+ if (this.isCurrentSessionPath(selected.session.path)) {
371
+ this.onError?.("Cannot delete the currently active session");
372
+ return;
373
+ }
374
+ this.setConfirmingDeletePath(selected.session.path);
375
+ }
376
+
377
+ getSelectedSessionPath(): string | undefined {
378
+ const selected = this.filteredNodes[this.selectedIndex];
379
+ return selected?.session.path;
380
+ }
381
+
382
+ filterSessions(query: string): void {
383
+ const nameFiltered = this.nameFilter === "all"
384
+ ? this.allSessions
385
+ : this.allSessions.filter(hasSessionName);
386
+
387
+ const trimmed = query.trim();
388
+
389
+ if (this.sortMode === "threaded" && !trimmed) {
390
+ // Threaded mode without search: show tree structure
391
+ const roots = buildSessionTree(nameFiltered);
392
+ this.filteredNodes = flattenSessionTree(roots);
393
+ } else {
394
+ // Other modes or with search: flat list via filterAndSortSessions
395
+ const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode);
396
+ this.filteredNodes = filtered.map((session) => ({
397
+ session,
398
+ depth: 0,
399
+ isLast: true,
400
+ ancestorContinues: [],
401
+ }));
402
+ }
403
+
404
+ this.selectedIndex = Math.min(
405
+ this.selectedIndex,
406
+ Math.max(0, this.filteredNodes.length - 1),
407
+ );
408
+ }
409
+
410
+ invalidate(): void {
411
+ this.searchInput.invalidate();
412
+ }
413
+
414
+ render(width: number): string[] {
415
+ const t = this.theme;
416
+ const lines: string[] = [];
417
+
418
+ // Search input
419
+ lines.push(...this.searchInput.render(width));
420
+ lines.push(""); // Blank line after search
421
+
422
+ if (this.filteredNodes.length === 0) {
423
+ let emptyMessage: string;
424
+ if (this.nameFilter === "named") {
425
+ const toggleKey = keyText("app.session.toggleNamedFilter");
426
+ if (this.showCwd) {
427
+ emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`;
428
+ } else {
429
+ emptyMessage = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`;
430
+ }
431
+ } else if (this.showCwd) {
432
+ emptyMessage = " No sessions found";
433
+ } else {
434
+ emptyMessage = " No sessions in current folder. Press Tab to view all.";
435
+ }
436
+ lines.push(t.fg("muted", truncateToWidth(emptyMessage, width, "…")));
437
+ return lines;
438
+ }
439
+
440
+ // Calculate visible range with scrolling
441
+ const startIndex = Math.max(
442
+ 0,
443
+ Math.min(
444
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
445
+ this.filteredNodes.length - this.maxVisible,
446
+ ),
447
+ );
448
+ const endIndex = Math.min(startIndex + this.maxVisible, this.filteredNodes.length);
449
+
450
+ for (let i = startIndex; i < endIndex; i++) {
451
+ const node = this.filteredNodes[i]!;
452
+ const session = node.session;
453
+ const isSelected = i === this.selectedIndex;
454
+ const isConfirmingDelete = session.path === this.confirmingDeletePath;
455
+ const isCurrent = this.isCurrentSessionPath(session.path);
456
+ const hasName = !!session.name;
457
+
458
+ // Build tree prefix
459
+ const prefix = buildTreePrefix(node);
460
+
461
+ // Session display text
462
+ const displayText = (session.name ?? session.firstMessage)
463
+ .replace(/[\x00-\x1f\x7f]/g, " ")
464
+ .trim();
465
+
466
+ // Right side: path (if toggled) + cwd (if all scope) + message count + age
467
+ const age = formatSessionDate(session.modified);
468
+ const msgCount = String(session.messageCount);
469
+ let rightPart = `${msgCount} ${age}`;
470
+ if (this.showCwd && session.cwd) {
471
+ rightPart = `${shortenPath(session.cwd)} ${rightPart}`;
472
+ }
473
+ if (this.showPath) {
474
+ rightPart = `${shortenPath(session.path)} ${rightPart}`;
475
+ }
476
+
477
+ // Cursor
478
+ const cursor = isSelected ? t.fg("accent", "› ") : " ";
479
+
480
+ // Calculate available width for message
481
+ const prefixWidth = visibleWidth(prefix);
482
+ const rightWidth = visibleWidth(rightPart) + 2;
483
+ const availableForMsg = width - 2 - prefixWidth - rightWidth; // -2 for cursor
484
+ const truncatedMsg = truncateToWidth(displayText, Math.max(10, availableForMsg), "…");
485
+
486
+ // Style message — same color logic as built-in
487
+ let messageColor: Parameters<Theme["fg"]>[0] | null = null;
488
+ if (isConfirmingDelete) {
489
+ messageColor = "error";
490
+ } else if (isCurrent) {
491
+ messageColor = "accent";
492
+ } else if (hasName) {
493
+ messageColor = "warning";
494
+ }
495
+ let styledMsg = messageColor ? t.fg(messageColor, truncatedMsg) : truncatedMsg;
496
+ if (isSelected) {
497
+ styledMsg = t.bold(styledMsg);
498
+ }
499
+
500
+ // Build line — same layout as built-in
501
+ const leftPart = cursor + t.fg("dim", prefix) + styledMsg;
502
+ const leftWidth = visibleWidth(leftPart);
503
+ const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart));
504
+ const styledRight = t.fg(isConfirmingDelete ? "error" : "dim", rightPart);
505
+ let line = leftPart + " ".repeat(spacing) + styledRight;
506
+ if (isSelected) {
507
+ line = t.bg("selectedBg", line);
508
+ }
509
+ lines.push(truncateToWidth(line, width));
510
+ }
511
+
512
+ // Scroll indicator
513
+ if (startIndex > 0 || endIndex < this.filteredNodes.length) {
514
+ const scrollText = ` (${this.selectedIndex + 1}/${this.filteredNodes.length})`;
515
+ lines.push(t.fg("muted", truncateToWidth(scrollText, width, "")));
516
+ }
517
+
518
+ return lines;
519
+ }
520
+
521
+ handleInput(data: string): void {
522
+ const kb = getKeybindings();
523
+
524
+ // Handle delete confirmation state first — intercept all keys
525
+ if (this.confirmingDeletePath !== null) {
526
+ if (kb.matches(data, "tui.select.confirm")) {
527
+ const pathToDelete = this.confirmingDeletePath;
528
+ this.setConfirmingDeletePath(null);
529
+ this.onDeleteSession?.(pathToDelete);
530
+ return;
531
+ }
532
+ if (kb.matches(data, "tui.select.cancel")) {
533
+ this.setConfirmingDeletePath(null);
534
+ return;
535
+ }
536
+ // Ignore all other keys while confirming
537
+ return;
538
+ }
539
+
540
+ if (kb.matches(data, "tui.input.tab")) {
541
+ this.onToggleScope?.();
542
+ return;
543
+ }
544
+
545
+ if (kb.matches(data, "app.session.toggleSort")) {
546
+ this.onToggleSort?.();
547
+ return;
548
+ }
549
+
550
+ if (kb.matches(data, "app.session.toggleNamedFilter")) {
551
+ this.onToggleNameFilter?.();
552
+ return;
553
+ }
554
+
555
+ // Ctrl+P: toggle path display
556
+ if (kb.matches(data, "app.session.togglePath")) {
557
+ this.showPath = !this.showPath;
558
+ this.onTogglePath?.(this.showPath);
559
+ return;
560
+ }
561
+
562
+ // Ctrl+D: initiate delete confirmation
563
+ if (kb.matches(data, "app.session.delete")) {
564
+ this.startDeleteConfirmationForSelectedSession();
565
+ return;
566
+ }
567
+
568
+ // Ctrl+R: rename selected session
569
+ if (kb.matches(data, "app.session.rename")) {
570
+ const selected = this.filteredNodes[this.selectedIndex];
571
+ if (selected) {
572
+ this.onRenameSession?.(selected.session.path);
573
+ }
574
+ return;
575
+ }
576
+
577
+ // Ctrl+Backspace: convenience alias for delete when search is empty
578
+ if (kb.matches(data, "app.session.deleteNoninvasive")) {
579
+ if (this.searchInput.getValue().length > 0) {
580
+ this.searchInput.handleInput(data);
581
+ this.filterSessions(this.searchInput.getValue());
582
+ return;
583
+ }
584
+ this.startDeleteConfirmationForSelectedSession();
585
+ return;
586
+ }
587
+
588
+ if (kb.matches(data, "tui.select.up")) {
589
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
590
+ } else if (kb.matches(data, "tui.select.down")) {
591
+ this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + 1);
592
+ } else if (kb.matches(data, "tui.select.pageUp")) {
593
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
594
+ } else if (kb.matches(data, "tui.select.pageDown")) {
595
+ this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisible);
596
+ } else if (kb.matches(data, "tui.select.confirm")) {
597
+ const selected = this.filteredNodes[this.selectedIndex];
598
+ if (selected && this.onSelect) {
599
+ this.onSelect(selected.session.path);
600
+ }
601
+ } else if (kb.matches(data, "tui.select.cancel")) {
602
+ this.onCancel?.();
603
+ } else {
604
+ // Pass everything else to search input
605
+ this.searchInput.handleInput(data);
606
+ this.filterSessions(this.searchInput.getValue());
607
+ }
608
+ }
609
+ }
610
+
611
+ // Top-level component — mirrors SessionSelectorComponent layout exactly:
612
+ // Spacer(1) → DynamicBorder → Spacer(1) → Header → Spacer(1) → SessionList → Spacer(1) → DynamicBorder
613
+ // Or, when in rename mode: same layout wrapping a rename panel
614
+
615
+ class FastResumePicker extends Container {
616
+ private header: FastResumeHeader;
617
+ private sessionList: FastResumeSessionList;
618
+ private renameInput: Input;
619
+ private theme: Theme;
620
+ private tuiRequestRender: () => void;
621
+ private done: (result: FastResumeResult) => void;
622
+
623
+ private scope: PickerScope = "current";
624
+ private sortMode: SortMode = "threaded";
625
+ private nameFilter: NameFilter = "all";
626
+ private currentSessions: SessionHeader[] | null = null;
627
+ private allSessions: SessionHeader[] | null = null;
628
+ private currentLoading = false;
629
+ private allLoading = false;
630
+
631
+ private allMetas: SessionFileMeta[] = [];
632
+ private loadingAbort: AbortController | null = null;
633
+ private allLoadSeq = 0;
634
+
635
+ private mode: "list" | "rename" = "list";
636
+ private renameTargetPath: string | null = null;
637
+
638
+ // Focusable — propagate to sessionList or renameInput
639
+ private _focused = false;
640
+ get focused() { return this._focused; }
641
+ set focused(v: boolean) {
642
+ this._focused = v;
643
+ this.sessionList.focused = v;
644
+ this.renameInput.focused = v;
645
+ if (v && this.mode === "rename") {
646
+ this.renameInput.focused = true;
647
+ }
648
+ }
649
+
650
+ private buildBaseLayout(content: Component, options?: { showHeader?: boolean }): void {
651
+ this.clear();
652
+ this.addChild(new Spacer(1));
653
+ this.addChild(new DynamicBorder((s) => this.theme.fg("accent", s)));
654
+ this.addChild(new Spacer(1));
655
+ if (options?.showHeader ?? true) {
656
+ this.addChild(this.header);
657
+ this.addChild(new Spacer(1));
658
+ }
659
+ this.addChild(content);
660
+ this.addChild(new Spacer(1));
661
+ this.addChild(new DynamicBorder((s) => this.theme.fg("accent", s)));
662
+ }
663
+
664
+ constructor(
665
+ theme: Theme,
666
+ currentCwd: string,
667
+ currentSessionPath: string | undefined,
668
+ initialCurrentSessions: SessionHeader[],
669
+ allMetas: SessionFileMeta[],
670
+ allSessions: SessionHeader[] | null,
671
+ done: (result: FastResumeResult) => void,
672
+ tuiRequestRender: () => void,
673
+ ) {
674
+ super();
675
+ this.theme = theme;
676
+ this.done = done;
677
+ this.tuiRequestRender = tuiRequestRender;
678
+ this.allMetas = allMetas;
679
+
680
+ // Create header
681
+ this.header = new FastResumeHeader(theme, tuiRequestRender);
682
+
683
+ // Create rename input
684
+ this.renameInput = new Input();
685
+ this.renameInput.onSubmit = (value) => {
686
+ void this.confirmRename(value);
687
+ };
688
+
689
+ // Create session list
690
+ this.sessionList = new FastResumeSessionList(theme, currentSessionPath);
691
+ this.currentSessions = initialCurrentSessions;
692
+ this.allSessions = allSessions;
693
+
694
+ // Set initial data into the list
695
+ this.sessionList.setSessions(initialCurrentSessions, false);
696
+
697
+ // Wire session list events
698
+ this.sessionList.onSelect = (sessionPath) => {
699
+ this.header.clearStatusTimeout();
700
+ this.loadingAbort?.abort();
701
+ this.done({ sessionPath, cancelled: false });
702
+ };
703
+ this.sessionList.onCancel = () => {
704
+ this.header.clearStatusTimeout();
705
+ this.loadingAbort?.abort();
706
+ this.done({ cancelled: true });
707
+ };
708
+ this.sessionList.onExit = () => {
709
+ this.header.clearStatusTimeout();
710
+ this.loadingAbort?.abort();
711
+ this.done({ cancelled: true });
712
+ };
713
+ this.sessionList.onToggleScope = () => this.toggleScope();
714
+ this.sessionList.onToggleSort = () => this.toggleSortMode();
715
+ this.sessionList.onToggleNameFilter = () => this.toggleNameFilter();
716
+ this.sessionList.onTogglePath = (showPath) => {
717
+ this.header.showPath = showPath;
718
+ this.tuiRequestRender();
719
+ };
720
+ this.sessionList.onDeleteConfirmationChange = (path) => {
721
+ this.header.confirmingDeletePath = path;
722
+ this.tuiRequestRender();
723
+ };
724
+ this.sessionList.onError = (msg) => {
725
+ this.header.setStatusMessage({ type: "error", message: msg }, 3000);
726
+ this.tuiRequestRender();
727
+ };
728
+ this.sessionList.onDeleteSession = async (sessionPath) => {
729
+ const result = await deleteSessionFile(sessionPath);
730
+ if (result.ok) {
731
+ // Remove from both caches
732
+ if (this.currentSessions) {
733
+ this.currentSessions = this.currentSessions.filter((s) => s.path !== sessionPath);
734
+ }
735
+ if (this.allSessions) {
736
+ this.allSessions = this.allSessions.filter((s) => s.path !== sessionPath);
737
+ }
738
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
739
+ const showCwd = this.scope === "all";
740
+ this.sessionList.setSessions(sessions, showCwd);
741
+ const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
742
+ this.header.setStatusMessage({ type: "info", message: msg }, 2000);
743
+ // Refresh sessions in background since the file is gone
744
+ await this.refreshSessionsAfterMutation();
745
+ } else {
746
+ const errorMessage = result.error ?? "Unknown error";
747
+ this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000);
748
+ }
749
+ this.tuiRequestRender();
750
+ };
751
+ this.sessionList.onRenameSession = (sessionPath) => {
752
+ if (this.scope === "current" && this.currentLoading) return;
753
+ if (this.scope === "all" && this.allLoading) return;
754
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
755
+ const session = sessions.find((s) => s.path === sessionPath);
756
+ this.enterRenameMode(sessionPath, session?.name);
757
+ };
758
+
759
+ // Build layout
760
+ this.buildBaseLayout(this.sessionList);
761
+
762
+ // Start loading current sessions (mark as loaded since we already have them)
763
+ this.currentLoading = false;
764
+ this.header.loading = false;
765
+
766
+ // If we don't have all sessions yet, pre-load them in the background
767
+ if (allSessions === null && allMetas.length > 0) {
768
+ this.startAllLoadBackground();
769
+ }
770
+ }
771
+
772
+ private enterRenameMode(sessionPath: string, currentName?: string): void {
773
+ this.mode = "rename";
774
+ this.renameTargetPath = sessionPath;
775
+ this.renameInput.setValue(currentName ?? "");
776
+ this.renameInput.focused = true;
777
+
778
+ const panel = new Container();
779
+ panel.addChild(new Text(this.theme.bold("Rename Session"), 1, 0));
780
+ panel.addChild(new Spacer(1));
781
+ panel.addChild(this.renameInput);
782
+ panel.addChild(new Spacer(1));
783
+ panel.addChild(new Text(
784
+ this.theme.fg("muted", `${keyText("tui.select.confirm")} to save · ${keyText("tui.select.cancel")} to cancel`),
785
+ 1,
786
+ 0,
787
+ ));
788
+
789
+ this.buildBaseLayout(panel, { showHeader: false });
790
+ this.tuiRequestRender();
791
+ }
792
+
793
+ private exitRenameMode(): void {
794
+ this.mode = "list";
795
+ this.renameTargetPath = null;
796
+ this.buildBaseLayout(this.sessionList);
797
+ this.tuiRequestRender();
798
+ }
799
+
800
+ private async confirmRename(value: string): Promise<void> {
801
+ const next = value.trim();
802
+ if (!next) return;
803
+ const target = this.renameTargetPath;
804
+ if (!target) {
805
+ this.exitRenameMode();
806
+ return;
807
+ }
808
+
809
+ try {
810
+ const mgr = SessionManager.open(target);
811
+ mgr.appendSessionInfo(next);
812
+ await this.refreshSessionsAfterMutation();
813
+ } finally {
814
+ this.exitRenameMode();
815
+ }
816
+ }
817
+
818
+ private async refreshSessionsAfterMutation(): Promise<void> {
819
+ // After delete/rename, the in-memory arrays are already updated (filtered above).
820
+ // Just re-apply them to the session list.
821
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
822
+ const showCwd = this.scope === "all";
823
+ this.sessionList.setSessions(sessions, showCwd);
824
+ }
825
+
826
+ private startAllLoadBackground(): void {
827
+ this.allLoading = true;
828
+ const seq = ++this.allLoadSeq;
829
+ const BATCH_SIZE = 50;
830
+ const sorted = sortByModifiedDesc([...this.allMetas]);
831
+ let offset = 0;
832
+ const allParsed: SessionHeader[] = [];
833
+
834
+ const loadBatch = () => {
835
+ if (seq !== this.allLoadSeq) return; // Stale
836
+ if (this.loadingAbort?.signal.aborted) return;
837
+
838
+ const batch = sorted.slice(offset, offset + BATCH_SIZE);
839
+ if (batch.length === 0) {
840
+ this.allLoading = false;
841
+ this.allSessions = sortByModified(allParsed);
842
+
843
+ // If we're currently showing "all" scope, update the list
844
+ if (this.scope === "all") {
845
+ this.header.loading = false;
846
+ this.sessionList.setSessions(this.allSessions, true);
847
+ this.tuiRequestRender();
848
+
849
+ // Auto-dismiss if no sessions exist anywhere
850
+ if (this.allSessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
851
+ this.done({ cancelled: true });
852
+ }
853
+ }
854
+ return;
855
+ }
856
+
857
+ let headers: SessionHeader[];
858
+ try {
859
+ headers = loadSessionHeaders(batch);
860
+ } catch (err) {
861
+ const message = err instanceof Error ? err.message : String(err);
862
+ this.allLoading = false;
863
+ if (this.scope === "all") {
864
+ this.header.loading = false;
865
+ this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
866
+ this.tuiRequestRender();
867
+ }
868
+ return;
869
+ }
870
+
871
+ allParsed.push(...headers);
872
+
873
+ // If we're currently showing "all" scope, update progress
874
+ if (this.scope === "all") {
875
+ this.header.loadProgress = { loaded: allParsed.length, total: sorted.length };
876
+ this.allSessions = sortByModified([...allParsed]);
877
+ this.sessionList.setSessions(this.allSessions, true);
878
+ this.tuiRequestRender();
879
+ }
880
+
881
+ offset += BATCH_SIZE;
882
+ setImmediate(loadBatch);
883
+ };
884
+
885
+ setImmediate(loadBatch);
886
+ }
887
+
888
+ private toggleScope(): void {
889
+ if (this.scope === "current") {
890
+ this.scope = "all";
891
+ this.header.scope = "all";
892
+
893
+ if (this.allSessions !== null) {
894
+ this.header.loading = false;
895
+ this.sessionList.setSessions(this.allSessions, true);
896
+ } else if (!this.allLoading) {
897
+ // Start loading all sessions
898
+ this.allLoading = true;
899
+ this.header.loading = true;
900
+ this.header.loadProgress = null;
901
+ this.startAllLoadBackground();
902
+ } else {
903
+ this.header.loading = true;
904
+ }
905
+ } else {
906
+ this.scope = "current";
907
+ this.header.scope = "current";
908
+ this.header.loading = false;
909
+ this.sessionList.setSessions(this.currentSessions ?? [], false);
910
+ }
911
+
912
+ this.tuiRequestRender();
913
+ }
914
+
915
+ private toggleSortMode(): void {
916
+ // Cycle: threaded → recent → relevance → threaded
917
+ this.sortMode = this.sortMode === "threaded" ? "recent" : this.sortMode === "recent" ? "relevance" : "threaded";
918
+ this.header.sortMode = this.sortMode;
919
+ this.sessionList.setSortMode(this.sortMode);
920
+ this.tuiRequestRender();
921
+ }
922
+
923
+ private toggleNameFilter(): void {
924
+ this.nameFilter = this.nameFilter === "all" ? "named" : "all";
925
+ this.header.nameFilter = this.nameFilter;
926
+ this.sessionList.setNameFilter(this.nameFilter);
927
+ this.tuiRequestRender();
928
+ }
929
+
930
+ handleInput(data: string): void {
931
+ if (this.mode === "rename") {
932
+ const kb = getKeybindings();
933
+ if (kb.matches(data, "tui.select.cancel")) {
934
+ this.exitRenameMode();
935
+ return;
936
+ }
937
+ this.renameInput.handleInput(data);
938
+ return;
939
+ }
940
+ this.sessionList.handleInput(data);
941
+ }
942
+ }
943
+
944
+ async function showFastResumePicker(
945
+ ctx: ExtensionCommandContext,
946
+ initialQuery?: string,
947
+ ): Promise<void> {
948
+ const cwd = ctx.cwd;
949
+ const sessionDir = ctx.sessionManager.getSessionDir();
950
+
951
+ const t0 = Date.now();
952
+
953
+ // Phase 1: stat all session files
954
+ const currentMetas = sessionDir ? scanSessionDir(sessionDir) : [];
955
+ const allMetas = scanAllSessionDirs();
956
+
957
+ // Phase 2: quickly parse the first 30 for instant display
958
+ const INITIAL_BATCH = 30;
959
+ const sortedCurrent = sortByModifiedDesc(currentMetas);
960
+ const quickMetas = sortedCurrent.slice(0, INITIAL_BATCH);
961
+ const quickHeaders = loadSessionHeaders(quickMetas);
962
+ const currentSessions = sortByModified(quickHeaders);
963
+
964
+ const loadTime = Date.now() - t0;
965
+
966
+ ctx.ui.notify(
967
+ `Fast resume: ${currentSessions.length} current, ${allMetas.length} total in ${loadTime}ms`,
968
+ "info",
969
+ );
970
+
971
+ const result = await ctx.ui.custom<FastResumeResult>(
972
+ (_tui, theme, _kb, done) => {
973
+ const picker = new FastResumePicker(
974
+ theme,
975
+ cwd,
976
+ ctx.sessionManager.getSessionFile(),
977
+ currentSessions,
978
+ allMetas,
979
+ null, // allSessions not yet loaded — will load in background
980
+ (result) => done(result),
981
+ () => _tui.requestRender(),
982
+ );
983
+
984
+ return picker;
985
+ },
986
+ );
987
+
988
+ if (result && result.sessionPath && !result.cancelled) {
989
+ await ctx.switchSession(result.sessionPath);
990
+ }
991
+ }
992
+
993
+ // Reference to the original showSessionSelector, saved before patching
994
+ let origShowSessionSelector: ((this: InteractiveMode) => void) | null = null;
995
+
996
+ function installResumeHijack(): void {
997
+ if (origShowSessionSelector !== null) return; // Already patched
998
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- prototype patching requires any cast for private access
999
+ const proto = InteractiveMode.prototype as any;
1000
+ if (
1001
+ !InteractiveMode ||
1002
+ typeof InteractiveMode !== "function" ||
1003
+ typeof proto.showSessionSelector !== "function"
1004
+ ) {
1005
+ return; // Guard: API changed or not available
1006
+ }
1007
+ origShowSessionSelector = proto.showSessionSelector;
1008
+ proto.showSessionSelector = function (this: InteractiveMode) {
1009
+ // Try to get an ExtensionCommandContext from the running session's extension runner
1010
+ const session = (this as any).session;
1011
+ if (!session?.extensionRunner?.createCommandContext) {
1012
+ // Fallback to original if we can't get a command context
1013
+ origShowSessionSelector!.call(this);
1014
+ return;
1015
+ }
1016
+ const ctx = session.extensionRunner.createCommandContext() as ExtensionCommandContext;
1017
+ // Fire-and-forget — same pattern as the original (synchronous, UI appears immediately)
1018
+ void showFastResumePicker(ctx);
1019
+ };
1020
+ }
1021
+
1022
+ function uninstallResumeHijack(): void {
1023
+ if (origShowSessionSelector === null) return;
1024
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- prototype patching requires any cast for private access
1025
+ const proto = InteractiveMode.prototype as any;
1026
+ if (
1027
+ InteractiveMode &&
1028
+ typeof InteractiveMode === "function" &&
1029
+ typeof proto.showSessionSelector === "function"
1030
+ ) {
1031
+ proto.showSessionSelector = origShowSessionSelector;
1032
+ }
1033
+ origShowSessionSelector = null;
1034
+ }
1035
+
1036
+ export default function (pi: ExtensionAPI) {
1037
+ const config = readConfig();
1038
+ const hijackResume = config.hijackResume !== false;
1039
+
1040
+ if (hijackResume) {
1041
+ // Hijack /resume — replace the built-in session selector with our fast picker
1042
+ installResumeHijack();
1043
+ // Don't register /fast-resume — /resume already opens the fast picker
1044
+ } else {
1045
+ // Normal mode — register /fast-resume as a standalone command
1046
+ pi.registerCommand("fast-resume", {
1047
+ description: "Fast session resume — instant picker with incremental loading",
1048
+ getArgumentCompletions: (prefix: string) => {
1049
+ if (!prefix) return null;
1050
+ return [{ value: prefix, label: `Search: ${prefix}` }];
1051
+ },
1052
+ handler: async (args, ctx) => {
1053
+ const query = args?.trim() || undefined;
1054
+ await showFastResumePicker(ctx, query);
1055
+ },
1056
+ });
1057
+ }
1058
+
1059
+ pi.registerShortcut(Key.ctrlShift("f"), {
1060
+ description: hijackResume ? "Open fast session resume (also /resume)" : "Open fast session resume picker",
1061
+ handler: async (ctx) => {
1062
+ await showFastResumePicker(ctx as unknown as ExtensionCommandContext);
1063
+ },
1064
+ });
1065
+
1066
+ // Clean up the prototype patch on session shutdown (reload, quit, session switch)
1067
+ pi.on("session_shutdown", () => {
1068
+ if (hijackResume) {
1069
+ uninstallResumeHijack();
1070
+ }
1071
+ });
1072
+ }