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/README.md +219 -0
- package/fast-resume.ts +1072 -0
- package/package.json +56 -0
- package/src/index.ts +23 -0
- package/src/picker-state.ts +95 -0
- package/src/scanner.ts +291 -0
- package/src/search.ts +310 -0
- package/vitest.config.ts +15 -0
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
|
+
}
|