pi-fast-resume 1.2.0 → 1.3.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 +106 -2
- package/package.json +1 -1
- package/src/index.ts +17 -2
- package/src/scanner.ts +78 -12
package/fast-resume.ts
CHANGED
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
scanAllSessionDirs,
|
|
73
73
|
scanSessionDir,
|
|
74
74
|
loadSessionHeaders,
|
|
75
|
+
loadSessionHeadersForward,
|
|
76
|
+
resolveSessionName,
|
|
75
77
|
sortByModified,
|
|
76
78
|
sortByModifiedDesc,
|
|
77
79
|
filterByCwd,
|
|
@@ -133,7 +135,7 @@ function loadCurrentSessionsImmediate(
|
|
|
133
135
|
): SessionHeader[] {
|
|
134
136
|
if (!sessionDir) return [];
|
|
135
137
|
const metas = sortByModifiedDesc(scanSessionDir(sessionDir));
|
|
136
|
-
let headers =
|
|
138
|
+
let headers = loadSessionHeadersForward(metas);
|
|
137
139
|
if (!usesDefaultSessionDir) {
|
|
138
140
|
// Custom session dirs may contain sessions from multiple cwds; filter to
|
|
139
141
|
// the current one, matching SessionManager.list behavior.
|
|
@@ -675,6 +677,17 @@ class FastResumePicker extends Container {
|
|
|
675
677
|
private loadingAbort: AbortController | null = null;
|
|
676
678
|
private allLoadSeq = 0;
|
|
677
679
|
|
|
680
|
+
// Deferred rename-name resolution. The picker displays rows immediately
|
|
681
|
+
// with forward-only headers (fast: ~80ms for 2.5k sessions); the latest rename
|
|
682
|
+
// name (which pi appends at EOF, past the forward stop) is resolved per file
|
|
683
|
+
// in the background and applied in-place, so a row's name pops in without
|
|
684
|
+
// blocking the initial render. See resolveSessionName in scanner.ts.
|
|
685
|
+
private metaByPath = new Map<string, SessionFileMeta>();
|
|
686
|
+
private nameResolveQueue: SessionFileMeta[] = [];
|
|
687
|
+
private nameResolveScheduled = false;
|
|
688
|
+
private nameResolveSeq = 0;
|
|
689
|
+
private nameResolvedPaths = new Set<string>();
|
|
690
|
+
|
|
678
691
|
private mode: "list" | "rename" = "list";
|
|
679
692
|
private renameTargetPath: string | null = null;
|
|
680
693
|
|
|
@@ -757,16 +770,19 @@ class FastResumePicker extends Container {
|
|
|
757
770
|
this.sessionList.onSelect = (sessionPath) => {
|
|
758
771
|
this.header.clearStatusTimeout();
|
|
759
772
|
this.loadingAbort?.abort();
|
|
773
|
+
this.nameResolveSeq++; // cancel any pending name-resolution ticks
|
|
760
774
|
this.done({ sessionPath, cancelled: false });
|
|
761
775
|
};
|
|
762
776
|
this.sessionList.onCancel = () => {
|
|
763
777
|
this.header.clearStatusTimeout();
|
|
764
778
|
this.loadingAbort?.abort();
|
|
779
|
+
this.nameResolveSeq++;
|
|
765
780
|
this.done({ cancelled: true });
|
|
766
781
|
};
|
|
767
782
|
this.sessionList.onExit = () => {
|
|
768
783
|
this.header.clearStatusTimeout();
|
|
769
784
|
this.loadingAbort?.abort();
|
|
785
|
+
this.nameResolveSeq++;
|
|
770
786
|
this.done({ cancelled: true });
|
|
771
787
|
};
|
|
772
788
|
this.sessionList.onToggleScope = () => this.toggleScope();
|
|
@@ -818,6 +834,17 @@ class FastResumePicker extends Container {
|
|
|
818
834
|
// Build layout
|
|
819
835
|
this.buildBaseLayout(this.sessionList);
|
|
820
836
|
|
|
837
|
+
// Build the path → meta lookup from allMetas (which is a superset of the
|
|
838
|
+
// current-scope metas both for the default and custom-dir cases), then
|
|
839
|
+
// enqueue the current-scope sessions for background rename-name resolution.
|
|
840
|
+
// Rows are already visible with the correct firstMessage; names populate
|
|
841
|
+
// in-place as their tails resolve.
|
|
842
|
+
for (const m of allMetas) this.metaByPath.set(m.path, m);
|
|
843
|
+
const currentMetas = initialCurrentSessions
|
|
844
|
+
.map((s) => this.metaByPath.get(s.path))
|
|
845
|
+
.filter((m): m is SessionFileMeta => !!m);
|
|
846
|
+
this.enqueueNameResolution(currentMetas);
|
|
847
|
+
|
|
821
848
|
// Start loading current sessions (mark as loaded since we already have them)
|
|
822
849
|
this.currentLoading = false;
|
|
823
850
|
this.header.loading = false;
|
|
@@ -957,7 +984,10 @@ class FastResumePicker extends Container {
|
|
|
957
984
|
|
|
958
985
|
let headers: SessionHeader[];
|
|
959
986
|
try {
|
|
960
|
-
|
|
987
|
+
// Forward-only: the rename name will resolve in the background via
|
|
988
|
+
// resolveSessionName (enqueued below), so rows appear with the correct
|
|
989
|
+
// firstMessage immediately and names populate in-place.
|
|
990
|
+
headers = loadSessionHeadersForward(batch);
|
|
961
991
|
} catch (err) {
|
|
962
992
|
const message = err instanceof Error ? err.message : String(err);
|
|
963
993
|
this.allLoading = false;
|
|
@@ -970,6 +1000,10 @@ class FastResumePicker extends Container {
|
|
|
970
1000
|
}
|
|
971
1001
|
|
|
972
1002
|
allParsed.push(...headers);
|
|
1003
|
+
// Enqueue this batch's metas for background rename-name resolution.
|
|
1004
|
+
// Names will be applied in-place as they resolve; if the user is viewing
|
|
1005
|
+
// "all" scope, newly-named rows also reflect in the active list.
|
|
1006
|
+
this.enqueueNameResolution(batch);
|
|
973
1007
|
|
|
974
1008
|
// If we're currently showing "all" scope, update progress
|
|
975
1009
|
if (this.scope === "all") {
|
|
@@ -986,6 +1020,76 @@ class FastResumePicker extends Container {
|
|
|
986
1020
|
setImmediate(loadBatch);
|
|
987
1021
|
}
|
|
988
1022
|
|
|
1023
|
+
// Enqueue session file metas for background rename-name resolution. Each
|
|
1024
|
+
// path is resolved at most once (deduped via nameResolvedPaths); repeated
|
|
1025
|
+
// enqueues for the same path are no-ops. Safe to call for the current-scope
|
|
1026
|
+
// sessions at construction and for each batch of the all-scope background load.
|
|
1027
|
+
private enqueueNameResolution(metas: SessionFileMeta[]): void {
|
|
1028
|
+
for (const m of metas) {
|
|
1029
|
+
if (this.nameResolvedPaths.has(m.path)) continue;
|
|
1030
|
+
this.nameResolvedPaths.add(m.path);
|
|
1031
|
+
this.nameResolveQueue.push(m);
|
|
1032
|
+
}
|
|
1033
|
+
this.scheduleNameResolution();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private scheduleNameResolution(): void {
|
|
1037
|
+
if (this.nameResolveScheduled) return;
|
|
1038
|
+
this.nameResolveScheduled = true;
|
|
1039
|
+
setImmediate(() => this.drainNameResolution());
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Resolve one cooperative batch of rename names (up to 50 per tick), apply
|
|
1043
|
+
// any found names in-place, and re-render once for the whole batch. Yields
|
|
1044
|
+
// between batches so input stays responsive even while thousands of tail
|
|
1045
|
+
// reads resolve. Aborts cleanly on select/cancel/exit via nameResolveSeq.
|
|
1046
|
+
private drainNameResolution(): void {
|
|
1047
|
+
this.nameResolveScheduled = false;
|
|
1048
|
+
if (this.loadingAbort?.signal.aborted) return;
|
|
1049
|
+
const seq = this.nameResolveSeq;
|
|
1050
|
+
const BATCH = 50;
|
|
1051
|
+
const batch = this.nameResolveQueue.splice(0, BATCH);
|
|
1052
|
+
if (batch.length === 0) return;
|
|
1053
|
+
|
|
1054
|
+
let updatedAny = false;
|
|
1055
|
+
for (const meta of batch) {
|
|
1056
|
+
if (seq !== this.nameResolveSeq) return; // stale — picker exited/aborted
|
|
1057
|
+
const result = resolveSessionName(meta);
|
|
1058
|
+
if (result.found && this.applyNameUpdate(meta.path, result.name)) {
|
|
1059
|
+
updatedAny = true;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (updatedAny) {
|
|
1064
|
+
const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
|
|
1065
|
+
const showCwd = this.scope === "all";
|
|
1066
|
+
this.sessionList.setSessions(sessions, showCwd);
|
|
1067
|
+
this.tuiRequestRender();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (this.nameResolveQueue.length > 0) this.scheduleNameResolution();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Apply a resolved name to the session with the given path in both the
|
|
1074
|
+
// current- and all-scope caches. The same logical session may appear as
|
|
1075
|
+
// distinct objects in the two caches, so both are updated. Returns whether a
|
|
1076
|
+
// session was found and updated (so the caller can batch re-renders).
|
|
1077
|
+
private applyNameUpdate(path: string, name: string | undefined): boolean {
|
|
1078
|
+
let updated = false;
|
|
1079
|
+
const updateArr = (arr: SessionHeader[] | null) => {
|
|
1080
|
+
if (!arr) return;
|
|
1081
|
+
for (const s of arr) {
|
|
1082
|
+
if (s.path === path) {
|
|
1083
|
+
s.name = name;
|
|
1084
|
+
updated = true;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
updateArr(this.currentSessions);
|
|
1089
|
+
updateArr(this.allSessions);
|
|
1090
|
+
return updated;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
989
1093
|
private toggleScope(): void {
|
|
990
1094
|
if (this.scope === "current") {
|
|
991
1095
|
this.scope = "all";
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
parseSessionFromBuffer,
|
|
3
|
+
scanTailForSessionInfo,
|
|
4
|
+
loadSessionHeader,
|
|
5
|
+
loadSessionHeaderForward,
|
|
6
|
+
loadSessionHeaders,
|
|
7
|
+
loadSessionHeadersForward,
|
|
8
|
+
resolveSessionName,
|
|
9
|
+
scanAllSessionDirs,
|
|
10
|
+
scanSessionDir,
|
|
11
|
+
sortByModified,
|
|
12
|
+
sortByModifiedDesc,
|
|
13
|
+
filterByCwd,
|
|
14
|
+
matchQuery,
|
|
15
|
+
canonicalizePath,
|
|
16
|
+
} from "./scanner.js";
|
|
17
|
+
export type { SessionHeader, SessionFileMeta, TailSessionInfo } from "./scanner.js";
|
|
3
18
|
export {
|
|
4
19
|
parseSearchQuery,
|
|
5
20
|
matchSession,
|
package/src/scanner.ts
CHANGED
|
@@ -397,20 +397,72 @@ export function scanSessionDir(
|
|
|
397
397
|
return results;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
400
|
+
// Forward-only load: reads complete lines from the start and stops at the
|
|
401
|
+
// first user message (which is all the title row needs). This reads exactly as
|
|
402
|
+
// many bytes as the first user message requires — a few KB for a normal
|
|
403
|
+
// session, ~19KB for a <skill> injection, more for a base64 image — and never
|
|
404
|
+
// truncates a line mid-JSON the way a fixed byte window would. So oversized
|
|
405
|
+
// first user messages (the cases that used to show "(no messages)") are parsed
|
|
406
|
+
// correctly.
|
|
402
407
|
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
408
|
+
// No tail read — the returned header's name reflects only session_info entries
|
|
409
|
+
// seen within the forward window. For sessions whose latest rename lives past
|
|
410
|
+
// the forward stop point (the common case for renamed large sessions), pair
|
|
411
|
+
// this with resolveSessionName() run in the background; the name then populates
|
|
412
|
+
// in-place without blocking the picker's initial render.
|
|
413
|
+
export function loadSessionHeaderForward(
|
|
414
|
+
meta: SessionFileMeta,
|
|
415
|
+
): SessionHeader | null {
|
|
416
|
+
let fd: number | undefined;
|
|
417
|
+
try {
|
|
418
|
+
fd = openSync(meta.path, "r");
|
|
419
|
+
const acc = newAccumulator();
|
|
420
|
+
const { reachedEof } = forEachLineForward(fd, meta.size, (line) => {
|
|
421
|
+
processEntry(acc, line);
|
|
422
|
+
if (acc.header && acc.foundFirstUser) return false;
|
|
423
|
+
return true;
|
|
424
|
+
});
|
|
425
|
+
return buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
} finally {
|
|
429
|
+
if (fd !== undefined) closeSync(fd);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Resolve the latest session_info (the rename name) from a bounded tail at EOF,
|
|
434
|
+
// independent of any forward pass. Returns found:false when no session_info
|
|
435
|
+
// lives in the tail region (keep whatever name the forward pass produced);
|
|
436
|
+
// found:true means a session_info was seen — its name (or explicit clear)
|
|
437
|
+
// overrides the forward name (it is later in file order).
|
|
409
438
|
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
439
|
+
// This is the deferred half of loadSessionHeader, exposed so callers can show
|
|
440
|
+
// a row immediately with the forward name and resolve the rename name in the
|
|
441
|
+
// background. Reading up to TAIL_READ_SIZE bytes from EOF may overlap the
|
|
442
|
+
// forward region for small files; that is a redundant re-read of a small range
|
|
443
|
+
// (no correctness impact — the latest session_info wins either way).
|
|
444
|
+
export function resolveSessionName(meta: SessionFileMeta): TailSessionInfo {
|
|
445
|
+
if (meta.size <= 0) return { found: false };
|
|
446
|
+
let fd: number | undefined;
|
|
447
|
+
try {
|
|
448
|
+
fd = openSync(meta.path, "r");
|
|
449
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size);
|
|
450
|
+
const tailBuf = Buffer.alloc(tailReadSize);
|
|
451
|
+
const tailOffset = meta.size - tailReadSize;
|
|
452
|
+
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
453
|
+
return scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
454
|
+
} catch {
|
|
455
|
+
return { found: false };
|
|
456
|
+
} finally {
|
|
457
|
+
if (fd !== undefined) closeSync(fd);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Load a session header using a streaming forward read plus a bounded tail read
|
|
462
|
+
// — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
|
|
463
|
+
// followed by resolveSessionName, but bounds the tail below by the forward stop
|
|
464
|
+
// offset so it never re-reads already-covered bytes. Use this when the full
|
|
465
|
+
// header (including rename name) is needed synchronously.
|
|
414
466
|
export function loadSessionHeader(
|
|
415
467
|
meta: SessionFileMeta,
|
|
416
468
|
): SessionHeader | null {
|
|
@@ -466,6 +518,20 @@ export function loadSessionHeaders(
|
|
|
466
518
|
return results;
|
|
467
519
|
}
|
|
468
520
|
|
|
521
|
+
// Forward-only batch load — see loadSessionHeaderForward. Use for the picker's
|
|
522
|
+
// immediate display path: rows appear instantly with the correct firstMessage,
|
|
523
|
+
// and rename names resolve in the background via resolveSessionName().
|
|
524
|
+
export function loadSessionHeadersForward(
|
|
525
|
+
metas: SessionFileMeta[],
|
|
526
|
+
): SessionHeader[] {
|
|
527
|
+
const results: SessionHeader[] = [];
|
|
528
|
+
for (const meta of metas) {
|
|
529
|
+
const header = loadSessionHeaderForward(meta);
|
|
530
|
+
if (header) results.push(header);
|
|
531
|
+
}
|
|
532
|
+
return results;
|
|
533
|
+
}
|
|
534
|
+
|
|
469
535
|
export function sortByModified(sessions: SessionHeader[]): SessionHeader[] {
|
|
470
536
|
return sessions.sort(
|
|
471
537
|
(a, b) => b.modified.getTime() - a.modified.getTime(),
|