pi-fast-resume 1.2.0 → 1.4.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 +12 -7
- package/fast-resume.ts +372 -52
- package/package.json +2 -1
- package/src/index.ts +20 -2
- package/src/scanner.ts +172 -17
- package/src/search.ts +14 -1
- package/vitest.config.ts +4 -1
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ Tested with **1,771 sessions, 1.46 GB** of JSONL data on disk.
|
|
|
47
47
|
| **pi-fast-resume (partial read)** | **6 ms** | ~580 ms |
|
|
48
48
|
| DuckDB NDJSON full scan | 2,560 ms | 2,560 ms |
|
|
49
49
|
|
|
50
|
+
> The all-dirs `stat()` pass (~100 ms at scale) now runs **after** first paint, so first paint no longer depends on total session count — only on the current project's session dir.
|
|
51
|
+
|
|
50
52
|
<details>
|
|
51
53
|
<summary><strong>Full benchmark table</strong></summary>
|
|
52
54
|
|
|
@@ -165,8 +167,8 @@ Press `Tab` to switch to **all sessions** — shows every session pi knows about
|
|
|
165
167
|
## How it works
|
|
166
168
|
|
|
167
169
|
```
|
|
168
|
-
stat()
|
|
169
|
-
(~
|
|
170
|
+
stat() current dir's .jsonl ──► sort by mtime ──► stream top 30 forward
|
|
171
|
+
(~cheap, one dir) (recent first) (~6ms)
|
|
170
172
|
│
|
|
171
173
|
▼
|
|
172
174
|
┌─────────────────┐
|
|
@@ -174,15 +176,18 @@ stat() all .jsonl files ──────► sort by mtime ──────
|
|
|
174
176
|
│ immediately │
|
|
175
177
|
└────────┬────────┘
|
|
176
178
|
│
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
Background (after first paint):
|
|
180
|
+
1. stream the rest of the current dir in batches of 50 (rows appear as they load)
|
|
181
|
+
2. stat() ALL session dirs (~100ms at scale) — deferred off the critical path
|
|
182
|
+
3. forward-load all-scope headers in batches of 50 (non-blocking via setImmediate)
|
|
183
|
+
4. resolve rename names in the background (skip header-only files; bound each tail read)
|
|
179
184
|
```
|
|
180
185
|
|
|
181
|
-
1. **`stat()`
|
|
186
|
+
1. **`stat()` the current project's session dir** — collect paths and mtimes (cheap, one directory)
|
|
182
187
|
2. **Sort by mtime descending** — most recent sessions first
|
|
183
|
-
3. **Stream
|
|
188
|
+
3. **Stream the top 30 forward** line by line until the first user message (~6 ms for the first screen)
|
|
184
189
|
4. **Show picker** — user can navigate, filter, and select immediately
|
|
185
|
-
5. **Background load** — remaining sessions
|
|
190
|
+
5. **Background load** — after first paint, stream the remaining current-scope sessions in batches of 50, then stat every session dir and forward-load the all-scope headers, non-blocking
|
|
186
191
|
6. **Tab to switch scope** — filter to current project or show everything
|
|
187
192
|
|
|
188
193
|
No indexing. No database. No persistent state. Just reads the files on disk.
|
package/fast-resume.ts
CHANGED
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
scanAllSessionDirs,
|
|
73
73
|
scanSessionDir,
|
|
74
74
|
loadSessionHeaders,
|
|
75
|
+
loadSessionHeadersForward,
|
|
76
|
+
resolveSessionNamesDeferred,
|
|
75
77
|
sortByModified,
|
|
76
78
|
sortByModifiedDesc,
|
|
77
79
|
filterByCwd,
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
import {
|
|
83
85
|
parseSearchQuery,
|
|
84
86
|
matchSession,
|
|
87
|
+
invalidateSessionSearchText,
|
|
85
88
|
hasSessionName,
|
|
86
89
|
filterAndSortSessions,
|
|
87
90
|
buildSessionTree,
|
|
@@ -95,6 +98,13 @@ import {
|
|
|
95
98
|
|
|
96
99
|
const HOME = homedir();
|
|
97
100
|
|
|
101
|
+
// #2 — Top-N immediate current-scope load. Forward-load this many most-recent
|
|
102
|
+
// sessions before first paint; the rest stream in from the background in
|
|
103
|
+
// batches of 50. 30 covers the visible page (maxVisible=10) with a scroll
|
|
104
|
+
// buffer. Projects with fewer sessions load fully in the immediate pass and
|
|
105
|
+
// never start the background current load.
|
|
106
|
+
const IMMEDIATE_CURRENT_COUNT = 30;
|
|
107
|
+
|
|
98
108
|
// Config — read from ~/.pi/agent/extensions/pi-fast-resume.json
|
|
99
109
|
// Example: { "hijackResume": false, "shortcut": "alt+u" }
|
|
100
110
|
// By default hijackResume is true — /resume opens the fast picker
|
|
@@ -123,23 +133,30 @@ export interface FastResumeResult {
|
|
|
123
133
|
|
|
124
134
|
type StatusMessage = { type: "info" | "error"; message: string };
|
|
125
135
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
// #2 — Load the current-scope sessions in two phases: forward-load the top-N
|
|
137
|
+
// most-recent headers immediately (the first paint), and return the remaining
|
|
138
|
+
// metas for the picker's background current-scope load. `allMetas` is every
|
|
139
|
+
// current-scope meta (used to seed the path→meta lookup for rename-name
|
|
140
|
+
// resolution). For custom session dirs, headers are filtered to the current cwd
|
|
141
|
+
// (matching SessionManager.list); the cwd filter is re-applied to the
|
|
142
|
+
// background batches so the streamed rows stay cwd-correct.
|
|
143
|
+
function loadCurrentSessionsTopN(
|
|
130
144
|
cwd: string,
|
|
131
145
|
sessionDir: string | undefined,
|
|
132
146
|
usesDefaultSessionDir: boolean,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
immediateCount: number,
|
|
148
|
+
): { headers: SessionHeader[]; remaining: SessionFileMeta[]; allMetas: SessionFileMeta[] } {
|
|
149
|
+
if (!sessionDir) return { headers: [], remaining: [], allMetas: [] };
|
|
150
|
+
const allMetas = sortByModifiedDesc(scanSessionDir(sessionDir));
|
|
151
|
+
const immediateMetas = allMetas.slice(0, immediateCount);
|
|
152
|
+
const remaining = allMetas.slice(immediateCount);
|
|
153
|
+
let headers = loadSessionHeadersForward(immediateMetas);
|
|
137
154
|
if (!usesDefaultSessionDir) {
|
|
138
155
|
// Custom session dirs may contain sessions from multiple cwds; filter to
|
|
139
156
|
// the current one, matching SessionManager.list behavior.
|
|
140
157
|
headers = filterByCwd(headers, cwd);
|
|
141
158
|
}
|
|
142
|
-
return sortByModified(headers);
|
|
159
|
+
return { headers: sortByModified(headers), remaining, allMetas };
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
function loadAllSessionMetas(
|
|
@@ -347,6 +364,17 @@ class FastResumeSessionList implements Component {
|
|
|
347
364
|
nameFilter: NameFilter = "all";
|
|
348
365
|
confirmingDeletePath: string | null = null;
|
|
349
366
|
maxVisible = 10;
|
|
367
|
+
|
|
368
|
+
// #4 — Cache for the threaded-mode tree (no-query path). The tree's shape and
|
|
369
|
+
// order depend only on parentSessionPath (immutable), modified (stable after
|
|
370
|
+
// load), and the session set — NOT on `name`. So a tree built for a given
|
|
371
|
+
// session-array reference stays valid across in-place name mutations (rename
|
|
372
|
+
// resolution) and across query typing/clearing, as long as the array ref is
|
|
373
|
+
// stable. Keyed on the `nameFilter==="all"` array (=== this.allSessions);
|
|
374
|
+
// the "named" subset depends on names and is never cached. setSessions passes
|
|
375
|
+
// a new ref on load/scope/mutation (cache misses → rebuild) and the same ref
|
|
376
|
+
// on name-resolution batches (cache hits → skip rebuild).
|
|
377
|
+
private _treeCache: { sessionsRef: SessionHeader[]; flat: FlatSessionNode[] } | null = null;
|
|
350
378
|
currentSessionCanonicalPath: string | undefined;
|
|
351
379
|
|
|
352
380
|
onSelect?: (sessionPath: string) => void;
|
|
@@ -402,6 +430,15 @@ class FastResumeSessionList implements Component {
|
|
|
402
430
|
this.filterSessions(this.searchInput.getValue());
|
|
403
431
|
}
|
|
404
432
|
|
|
433
|
+
// #5 — Drop the threaded-tree cache so the next filterSessions rebuilds it.
|
|
434
|
+
// The background loads reuse one growing session array (same ref) and append
|
|
435
|
+
// batches in place; without invalidation the ref-keyed cache would hit and
|
|
436
|
+
// serve a stale tree missing the new rows. Call before setSessions whenever
|
|
437
|
+
// the array's CONTENT changed but its REFERENCE did not.
|
|
438
|
+
invalidateTreeCache(): void {
|
|
439
|
+
this._treeCache = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
405
442
|
setConfirmingDeletePath(path: string | null): void {
|
|
406
443
|
this.confirmingDeletePath = path;
|
|
407
444
|
this.onDeleteConfirmationChange?.(path);
|
|
@@ -430,11 +467,23 @@ class FastResumeSessionList implements Component {
|
|
|
430
467
|
const trimmed = query.trim();
|
|
431
468
|
|
|
432
469
|
if (this.sortMode === "threaded" && !trimmed) {
|
|
433
|
-
// Threaded mode without search: show tree structure
|
|
434
|
-
|
|
435
|
-
|
|
470
|
+
// Threaded mode without search: show tree structure. Cache it when the
|
|
471
|
+
// nameFiltered array is the stable all-sessions ref (nameFilter==="all")
|
|
472
|
+
// — the tree doesn't depend on names, so it survives in-place name
|
|
473
|
+
// mutations and query typing/clearing until the session set changes.
|
|
474
|
+
const canCache = this.nameFilter === "all"; // nameFiltered === this.allSessions
|
|
475
|
+
if (canCache && this._treeCache !== null && this._treeCache.sessionsRef === nameFiltered) {
|
|
476
|
+
this.filteredNodes = this._treeCache.flat;
|
|
477
|
+
} else {
|
|
478
|
+
const roots = buildSessionTree(nameFiltered);
|
|
479
|
+
const flat = flattenSessionTree(roots);
|
|
480
|
+
this.filteredNodes = flat;
|
|
481
|
+
if (canCache) this._treeCache = { sessionsRef: nameFiltered, flat };
|
|
482
|
+
}
|
|
436
483
|
} else {
|
|
437
|
-
// Other modes or with search: flat list via filterAndSortSessions
|
|
484
|
+
// Other modes or with search: flat list via filterAndSortSessions. Leave
|
|
485
|
+
// the tree cache in place — a later "threaded + no query" reuses it as
|
|
486
|
+
// long as the session array ref is stable.
|
|
438
487
|
const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode);
|
|
439
488
|
this.filteredNodes = filtered.map((session) => ({
|
|
440
489
|
session,
|
|
@@ -674,6 +723,21 @@ class FastResumePicker extends Container {
|
|
|
674
723
|
private allMetas: SessionFileMeta[] = [];
|
|
675
724
|
private loadingAbort: AbortController | null = null;
|
|
676
725
|
private allLoadSeq = 0;
|
|
726
|
+
private currentLoadSeq = 0;
|
|
727
|
+
// #2 — Current-scope metas not yet forward-loaded (beyond the top-N immediate
|
|
728
|
+
// pass). The background current load drains these in batches of 50.
|
|
729
|
+
private remainingCurrentMetas: SessionFileMeta[] = [];
|
|
730
|
+
|
|
731
|
+
// Deferred rename-name resolution. The picker displays rows immediately
|
|
732
|
+
// with forward-only headers (fast: ~80ms for 2.5k sessions); the latest rename
|
|
733
|
+
// name (which pi appends at EOF, past the forward stop) is resolved per file
|
|
734
|
+
// in the background and applied in-place, so a row's name pops in without
|
|
735
|
+
// blocking the initial render. See resolveSessionNamesDeferred in scanner.ts.
|
|
736
|
+
private metaByPath = new Map<string, SessionFileMeta>();
|
|
737
|
+
private nameResolveQueue: SessionHeader[] = [];
|
|
738
|
+
private nameResolveScheduled = false;
|
|
739
|
+
private nameResolveSeq = 0;
|
|
740
|
+
private nameResolvedPaths = new Set<string>();
|
|
677
741
|
|
|
678
742
|
private mode: "list" | "rename" = "list";
|
|
679
743
|
private renameTargetPath: string | null = null;
|
|
@@ -715,8 +779,8 @@ class FastResumePicker extends Container {
|
|
|
715
779
|
usesDefaultSessionDir: boolean,
|
|
716
780
|
currentSessionPath: string | undefined,
|
|
717
781
|
initialCurrentSessions: SessionHeader[],
|
|
718
|
-
|
|
719
|
-
|
|
782
|
+
remainingCurrentMetas: SessionFileMeta[],
|
|
783
|
+
currentMetas: SessionFileMeta[],
|
|
720
784
|
done: (result: FastResumeResult) => void,
|
|
721
785
|
tuiRequestRender: () => void,
|
|
722
786
|
initialQuery?: string,
|
|
@@ -725,7 +789,9 @@ class FastResumePicker extends Container {
|
|
|
725
789
|
this.theme = theme;
|
|
726
790
|
this.done = done;
|
|
727
791
|
this.tuiRequestRender = tuiRequestRender;
|
|
728
|
-
|
|
792
|
+
// allMetas is populated in the background (the all-dirs stat is deferred
|
|
793
|
+
// off the first-paint critical path); seeded here with current-scope metas.
|
|
794
|
+
this.allMetas = [];
|
|
729
795
|
this.cwd = currentCwd;
|
|
730
796
|
this.sessionDir = sessionDir;
|
|
731
797
|
this.usesDefaultSessionDir = usesDefaultSessionDir;
|
|
@@ -742,7 +808,7 @@ class FastResumePicker extends Container {
|
|
|
742
808
|
// Create session list
|
|
743
809
|
this.sessionList = new FastResumeSessionList(theme, currentSessionPath);
|
|
744
810
|
this.currentSessions = initialCurrentSessions;
|
|
745
|
-
this.allSessions =
|
|
811
|
+
this.allSessions = null; // loaded in the background by startAllLoadBackground
|
|
746
812
|
|
|
747
813
|
// Set initial data into the list
|
|
748
814
|
this.sessionList.setSessions(initialCurrentSessions, false);
|
|
@@ -757,16 +823,19 @@ class FastResumePicker extends Container {
|
|
|
757
823
|
this.sessionList.onSelect = (sessionPath) => {
|
|
758
824
|
this.header.clearStatusTimeout();
|
|
759
825
|
this.loadingAbort?.abort();
|
|
826
|
+
this.nameResolveSeq++; // cancel any pending name-resolution ticks
|
|
760
827
|
this.done({ sessionPath, cancelled: false });
|
|
761
828
|
};
|
|
762
829
|
this.sessionList.onCancel = () => {
|
|
763
830
|
this.header.clearStatusTimeout();
|
|
764
831
|
this.loadingAbort?.abort();
|
|
832
|
+
this.nameResolveSeq++;
|
|
765
833
|
this.done({ cancelled: true });
|
|
766
834
|
};
|
|
767
835
|
this.sessionList.onExit = () => {
|
|
768
836
|
this.header.clearStatusTimeout();
|
|
769
837
|
this.loadingAbort?.abort();
|
|
838
|
+
this.nameResolveSeq++;
|
|
770
839
|
this.done({ cancelled: true });
|
|
771
840
|
};
|
|
772
841
|
this.sessionList.onToggleScope = () => this.toggleScope();
|
|
@@ -818,14 +887,28 @@ class FastResumePicker extends Container {
|
|
|
818
887
|
// Build layout
|
|
819
888
|
this.buildBaseLayout(this.sessionList);
|
|
820
889
|
|
|
821
|
-
//
|
|
822
|
-
|
|
890
|
+
// Seed the path → meta lookup from the current-scope metas (cheap — one
|
|
891
|
+
// dir). The all-scope metas are merged in by the background all-load. Rows
|
|
892
|
+
// are already visible with the correct firstMessage; rename names populate
|
|
893
|
+
// in-place as their tails resolve.
|
|
894
|
+
for (const m of currentMetas) this.metaByPath.set(m.path, m);
|
|
895
|
+
this.enqueueNameResolution(initialCurrentSessions);
|
|
896
|
+
|
|
897
|
+
// #2 — Current scope shows the top-N headers immediately; the rest stream
|
|
898
|
+
// in from the background (silent: header stays on "◉ Current Folder",
|
|
899
|
+
// rows appear as batches complete). currentLoading tracks this for the
|
|
900
|
+
// rename guard and refresh cancellation; header.loading is NOT flipped so
|
|
901
|
+
// the initial view doesn't flash a loading state over visible rows.
|
|
902
|
+
this.remainingCurrentMetas = remainingCurrentMetas;
|
|
903
|
+
this.currentLoading = remainingCurrentMetas.length > 0;
|
|
904
|
+
if (this.currentLoading) this.startCurrentLoadBackground();
|
|
823
905
|
this.header.loading = false;
|
|
824
906
|
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
907
|
+
// Pre-load the all-scope metas + headers in the background. This stats all
|
|
908
|
+
// session dirs (the ~100ms-at-scale cost that used to block first paint)
|
|
909
|
+
// off the critical path, then forward-loads headers in cooperative batches
|
|
910
|
+
// so switching to "all" scope is instant.
|
|
911
|
+
this.startAllLoadBackground();
|
|
829
912
|
}
|
|
830
913
|
|
|
831
914
|
private enterRenameMode(sessionPath: string, currentName?: string): void {
|
|
@@ -899,9 +982,14 @@ class FastResumePicker extends Container {
|
|
|
899
982
|
private async refreshSessionsAfterMutation(): Promise<void> {
|
|
900
983
|
// Rescan from disk so renames, deletes, and newly created sessions are
|
|
901
984
|
// reflected in the list. This mirrors upstream's loadScope(scope, "refresh").
|
|
902
|
-
// Bump the sequence
|
|
903
|
-
//
|
|
985
|
+
// Bump the sequence numbers first so any in-progress background loads
|
|
986
|
+
// (current top-N remainder or all-scope) stop before they can overwrite
|
|
987
|
+
// the rescanned data. rescanCurrentScope/AllScope do a full load (combined
|
|
988
|
+
// forward+tail) so the refreshed names are accurate.
|
|
904
989
|
this.allLoadSeq++;
|
|
990
|
+
this.currentLoadSeq++;
|
|
991
|
+
this.currentLoading = false;
|
|
992
|
+
this.remainingCurrentMetas = [];
|
|
905
993
|
try {
|
|
906
994
|
if (this.scope === "current") {
|
|
907
995
|
this.currentSessions = this.rescanCurrentScope();
|
|
@@ -924,26 +1012,158 @@ class FastResumePicker extends Container {
|
|
|
924
1012
|
}
|
|
925
1013
|
}
|
|
926
1014
|
|
|
1015
|
+
// #2 — Background current-scope load: forward-load the remaining current
|
|
1016
|
+
// metas (those beyond the top-N immediate pass) in cooperative batches of
|
|
1017
|
+
// 50, merging into currentSessions (re-sorted by modified) and streaming
|
|
1018
|
+
// rows into the visible list when the user is on current scope. Silent — no
|
|
1019
|
+
// header loading indicator (rows are already visible). Idempotent; cancelled
|
|
1020
|
+
// by currentLoadSeq bumps (refresh) or loadingAbort (select/cancel/exit).
|
|
1021
|
+
private startCurrentLoadBackground(): void {
|
|
1022
|
+
if (this.remainingCurrentMetas.length === 0) {
|
|
1023
|
+
this.currentLoading = false;
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.currentLoading = true;
|
|
1027
|
+
const seq = ++this.currentLoadSeq;
|
|
1028
|
+
const sorted = this.remainingCurrentMetas;
|
|
1029
|
+
setImmediate(() => this.runCurrentLoadHeaders(seq, sorted));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// #5 — Background current-scope load. Seeds the accumulator with a snapshot
|
|
1033
|
+
// of the top-N immediate headers (one copy, ~30 elements), appends each
|
|
1034
|
+
// batch's cwd-filtered headers in place (no per-batch array copy or re-sort),
|
|
1035
|
+
// and sorts once at completion. Reuses one growing array ref for display, so
|
|
1036
|
+
// the threaded-tree cache is invalidated before each setSessions to rebuild
|
|
1037
|
+
// with the new rows. Intermediate order is insertion order ≈ mtime desc
|
|
1038
|
+
// (batches arrive pre-sorted by mtime). Fixes the prior per-batch
|
|
1039
|
+
// merge that duplicated earlier batches' headers.
|
|
1040
|
+
private runCurrentLoadHeaders(seq: number, sorted: SessionFileMeta[]): void {
|
|
1041
|
+
const BATCH_SIZE = 50;
|
|
1042
|
+
let offset = 0;
|
|
1043
|
+
// Seed with a copy of the immediate top-N headers so appending batches
|
|
1044
|
+
// can't mutate the list's current array out from under a prior render, and
|
|
1045
|
+
// so the accumulator owns its storage independently.
|
|
1046
|
+
const acc: SessionHeader[] = (this.currentSessions ?? []).slice();
|
|
1047
|
+
|
|
1048
|
+
const show = () => {
|
|
1049
|
+
this.currentSessions = acc;
|
|
1050
|
+
if (this.scope === "current") {
|
|
1051
|
+
this.sessionList.invalidateTreeCache();
|
|
1052
|
+
this.sessionList.setSessions(acc, false);
|
|
1053
|
+
this.tuiRequestRender();
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const loadBatch = () => {
|
|
1058
|
+
if (seq !== this.currentLoadSeq) { this.currentLoading = false; return; } // stale
|
|
1059
|
+
if (this.loadingAbort?.signal.aborted) { this.currentLoading = false; return; }
|
|
1060
|
+
|
|
1061
|
+
const batch = sorted.slice(offset, offset + BATCH_SIZE);
|
|
1062
|
+
if (batch.length === 0) {
|
|
1063
|
+
this.currentLoading = false;
|
|
1064
|
+
sortByModified(acc); // final sort in place (sortByModified returns acc)
|
|
1065
|
+
show();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let headers: SessionHeader[];
|
|
1070
|
+
try {
|
|
1071
|
+
headers = loadSessionHeadersForward(batch);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
this.currentLoading = false;
|
|
1074
|
+
this.handleCurrentLoadError(seq, err);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (!this.usesDefaultSessionDir) headers = filterByCwd(headers, this.cwd);
|
|
1079
|
+
acc.push(...headers);
|
|
1080
|
+
this.enqueueNameResolution(headers);
|
|
1081
|
+
show();
|
|
1082
|
+
|
|
1083
|
+
offset += BATCH_SIZE;
|
|
1084
|
+
setImmediate(loadBatch);
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
setImmediate(loadBatch);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private handleCurrentLoadError(seq: number, err: unknown): void {
|
|
1091
|
+
if (seq !== this.currentLoadSeq) return;
|
|
1092
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1093
|
+
if (this.scope === "current") {
|
|
1094
|
+
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
1095
|
+
this.tuiRequestRender();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Kick off the background all-scope load: first stat all session dirs (the
|
|
1100
|
+
// ~100ms-at-scale cost deferred off the first-paint critical path), then
|
|
1101
|
+
// forward-load headers in cooperative batches. Idempotent — a no-op if a
|
|
1102
|
+
// load is already running or already complete. The constructor calls this
|
|
1103
|
+
// once so switching to "all" scope is instant; toggleScope relies on it.
|
|
927
1104
|
private startAllLoadBackground(): void {
|
|
1105
|
+
if (this.allLoading) return; // already running
|
|
1106
|
+
if (this.allSessions !== null) return; // already complete
|
|
928
1107
|
this.allLoading = true;
|
|
929
1108
|
const seq = ++this.allLoadSeq;
|
|
1109
|
+
setImmediate(() => this.runAllLoadMetas(seq));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Phase 1: stat all session dirs in the background, merge into metaByPath,
|
|
1113
|
+
// then dispatch phase 2 (header batches). Releases allLoading and aborts if
|
|
1114
|
+
// superseded by a newer load (e.g. a refresh).
|
|
1115
|
+
private runAllLoadMetas(seq: number): void {
|
|
1116
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; }
|
|
1117
|
+
if (this.loadingAbort?.signal.aborted) { this.allLoading = false; return; }
|
|
1118
|
+
|
|
1119
|
+
let allMetas: SessionFileMeta[];
|
|
1120
|
+
try {
|
|
1121
|
+
allMetas = loadAllSessionMetas(this.sessionDir, this.usesDefaultSessionDir);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
this.allLoading = false;
|
|
1124
|
+
this.handleAllLoadError(seq, err);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; }
|
|
1128
|
+
this.allMetas = allMetas;
|
|
1129
|
+
for (const m of allMetas) {
|
|
1130
|
+
if (!this.metaByPath.has(m.path)) this.metaByPath.set(m.path, m);
|
|
1131
|
+
}
|
|
1132
|
+
this.runAllLoadHeaders(seq, sortByModifiedDesc(allMetas));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Phase 2: forward-load all-scope headers in cooperative batches of 50,
|
|
1136
|
+
// enqueuing each batch for background rename-name resolution. Updates the
|
|
1137
|
+
// active list + progress only while the user is viewing "all" scope.
|
|
1138
|
+
// #5 — Background all-scope header load. Appends each batch into a single
|
|
1139
|
+
// growing array (no per-batch array copy or re-sort) and sorts once at
|
|
1140
|
+
// completion. Reuses one array ref for display, so the threaded-tree cache
|
|
1141
|
+
// is invalidated before each setSessions to rebuild with the new rows.
|
|
1142
|
+
// Intermediate order is insertion order ≈ mtime desc (batches arrive
|
|
1143
|
+
// pre-sorted by mtime; the threaded tree re-sorts internally regardless).
|
|
1144
|
+
private runAllLoadHeaders(seq: number, sorted: SessionFileMeta[]): void {
|
|
930
1145
|
const BATCH_SIZE = 50;
|
|
931
|
-
const sorted = sortByModifiedDesc([...this.allMetas]);
|
|
932
1146
|
let offset = 0;
|
|
933
1147
|
const allParsed: SessionHeader[] = [];
|
|
934
1148
|
|
|
1149
|
+
if (this.scope === "all") {
|
|
1150
|
+
this.header.loadProgress = { loaded: 0, total: sorted.length };
|
|
1151
|
+
this.tuiRequestRender();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
935
1154
|
const loadBatch = () => {
|
|
936
|
-
if (seq !== this.allLoadSeq) return; //
|
|
937
|
-
if (this.loadingAbort?.signal.aborted) return;
|
|
1155
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; } // stale — release
|
|
1156
|
+
if (this.loadingAbort?.signal.aborted) { this.allLoading = false; return; }
|
|
938
1157
|
|
|
939
1158
|
const batch = sorted.slice(offset, offset + BATCH_SIZE);
|
|
940
1159
|
if (batch.length === 0) {
|
|
941
1160
|
this.allLoading = false;
|
|
942
|
-
this.allSessions = sortByModified(allParsed);
|
|
1161
|
+
this.allSessions = sortByModified(allParsed); // final sort in place
|
|
943
1162
|
|
|
944
1163
|
// If we're currently showing "all" scope, update the list
|
|
945
1164
|
if (this.scope === "all") {
|
|
946
1165
|
this.header.loading = false;
|
|
1166
|
+
this.sessionList.invalidateTreeCache();
|
|
947
1167
|
this.sessionList.setSessions(this.allSessions, true);
|
|
948
1168
|
this.tuiRequestRender();
|
|
949
1169
|
|
|
@@ -957,24 +1177,30 @@ class FastResumePicker extends Container {
|
|
|
957
1177
|
|
|
958
1178
|
let headers: SessionHeader[];
|
|
959
1179
|
try {
|
|
960
|
-
|
|
1180
|
+
// Forward-only: the rename name resolves in the background via
|
|
1181
|
+
// resolveSessionNamesDeferred (enqueued below), so rows appear with the
|
|
1182
|
+
// correct firstMessage immediately and names populate in-place.
|
|
1183
|
+
headers = loadSessionHeadersForward(batch);
|
|
961
1184
|
} catch (err) {
|
|
962
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
963
1185
|
this.allLoading = false;
|
|
964
|
-
|
|
965
|
-
this.header.loading = false;
|
|
966
|
-
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
967
|
-
this.tuiRequestRender();
|
|
968
|
-
}
|
|
1186
|
+
this.handleAllLoadError(seq, err);
|
|
969
1187
|
return;
|
|
970
1188
|
}
|
|
971
1189
|
|
|
972
1190
|
allParsed.push(...headers);
|
|
973
|
-
|
|
974
|
-
//
|
|
1191
|
+
// Enqueue this batch's headers (carrying forward-pass bookkeeping) for
|
|
1192
|
+
// background rename-name resolution. Names populate in-place as they
|
|
1193
|
+
// resolve; if the user is viewing "all" scope, newly-named rows reflect
|
|
1194
|
+
// in the active list.
|
|
1195
|
+
this.enqueueNameResolution(headers);
|
|
1196
|
+
|
|
1197
|
+
// If we're currently showing "all" scope, update progress. Reuse the
|
|
1198
|
+
// growing allParsed ref (insertion order ≈ mtime desc); invalidate the
|
|
1199
|
+
// tree cache so it rebuilds with the appended rows.
|
|
975
1200
|
if (this.scope === "all") {
|
|
976
1201
|
this.header.loadProgress = { loaded: allParsed.length, total: sorted.length };
|
|
977
|
-
this.allSessions =
|
|
1202
|
+
this.allSessions = allParsed;
|
|
1203
|
+
this.sessionList.invalidateTreeCache();
|
|
978
1204
|
this.sessionList.setSessions(this.allSessions, true);
|
|
979
1205
|
this.tuiRequestRender();
|
|
980
1206
|
}
|
|
@@ -986,22 +1212,110 @@ class FastResumePicker extends Container {
|
|
|
986
1212
|
setImmediate(loadBatch);
|
|
987
1213
|
}
|
|
988
1214
|
|
|
1215
|
+
private handleAllLoadError(seq: number, err: unknown): void {
|
|
1216
|
+
if (seq !== this.allLoadSeq) return;
|
|
1217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1218
|
+
if (this.scope === "all") {
|
|
1219
|
+
this.header.loading = false;
|
|
1220
|
+
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
1221
|
+
this.tuiRequestRender();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Enqueue forward-loaded headers for background rename-name resolution.
|
|
1226
|
+
// Each path is resolved at most once (deduped via nameResolvedPaths). A
|
|
1227
|
+
// header whose forward pass reached EOF already has its final name — it's
|
|
1228
|
+
// marked resolved and skipped (no tail read). The rest carry the forward
|
|
1229
|
+
// pass's consumed bytes as a lower bound so the tail read never re-reads
|
|
1230
|
+
// already-covered bytes. Safe to call for the current-scope sessions at
|
|
1231
|
+
// construction and for each batch of the all-scope background load.
|
|
1232
|
+
private enqueueNameResolution(headers: SessionHeader[]): void {
|
|
1233
|
+
for (const h of headers) {
|
|
1234
|
+
if (this.nameResolvedPaths.has(h.path)) continue;
|
|
1235
|
+
this.nameResolvedPaths.add(h.path);
|
|
1236
|
+
if (h._fwdReachedEof) continue; // forward pass saw every session_info
|
|
1237
|
+
this.nameResolveQueue.push(h);
|
|
1238
|
+
}
|
|
1239
|
+
this.scheduleNameResolution();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private scheduleNameResolution(): void {
|
|
1243
|
+
if (this.nameResolveScheduled) return;
|
|
1244
|
+
this.nameResolveScheduled = true;
|
|
1245
|
+
setImmediate(() => this.drainNameResolution());
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Resolve one cooperative batch of rename names (up to 50 per tick), apply
|
|
1249
|
+
// any found names in-place, and re-render once for the whole batch. Yields
|
|
1250
|
+
// between batches so input stays responsive even while thousands of tail
|
|
1251
|
+
// reads resolve. Aborts cleanly on select/cancel/exit via nameResolveSeq.
|
|
1252
|
+
private drainNameResolution(): void {
|
|
1253
|
+
this.nameResolveScheduled = false;
|
|
1254
|
+
if (this.loadingAbort?.signal.aborted) return;
|
|
1255
|
+
const seq = this.nameResolveSeq;
|
|
1256
|
+
const BATCH = 50;
|
|
1257
|
+
const batch = this.nameResolveQueue.splice(0, BATCH);
|
|
1258
|
+
if (batch.length === 0) return;
|
|
1259
|
+
|
|
1260
|
+
// Pure core: skip reached-EOF headers, bound each tail by the forward
|
|
1261
|
+
// pass's consumed bytes. Returns only paths whose tail found a session_info.
|
|
1262
|
+
const updates = resolveSessionNamesDeferred(batch, this.metaByPath);
|
|
1263
|
+
|
|
1264
|
+
let updatedAny = false;
|
|
1265
|
+
for (const [path, name] of updates) {
|
|
1266
|
+
if (seq !== this.nameResolveSeq) return; // stale — picker exited/aborted
|
|
1267
|
+
if (this.applyNameUpdate(path, name)) updatedAny = true;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (updatedAny) {
|
|
1271
|
+
const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
|
|
1272
|
+
const showCwd = this.scope === "all";
|
|
1273
|
+
this.sessionList.setSessions(sessions, showCwd);
|
|
1274
|
+
this.tuiRequestRender();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (this.nameResolveQueue.length > 0) this.scheduleNameResolution();
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Apply a resolved name to the session with the given path in both the
|
|
1281
|
+
// current- and all-scope caches. The same logical session may appear as
|
|
1282
|
+
// distinct objects in the two caches, so both are updated. Returns whether a
|
|
1283
|
+
// session was found and updated (so the caller can batch re-renders).
|
|
1284
|
+
private applyNameUpdate(path: string, name: string | undefined): boolean {
|
|
1285
|
+
let updated = false;
|
|
1286
|
+
const updateArr = (arr: SessionHeader[] | null) => {
|
|
1287
|
+
if (!arr) return;
|
|
1288
|
+
for (const s of arr) {
|
|
1289
|
+
if (s.path === path) {
|
|
1290
|
+
s.name = name;
|
|
1291
|
+
// #4 — name is part of the cached search blob; drop it so the next
|
|
1292
|
+
// matchSession rebuilds with the new name.
|
|
1293
|
+
invalidateSessionSearchText(s);
|
|
1294
|
+
updated = true;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
updateArr(this.currentSessions);
|
|
1299
|
+
updateArr(this.allSessions);
|
|
1300
|
+
return updated;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
989
1303
|
private toggleScope(): void {
|
|
990
1304
|
if (this.scope === "current") {
|
|
991
1305
|
this.scope = "all";
|
|
992
1306
|
this.header.scope = "all";
|
|
993
1307
|
|
|
994
1308
|
if (this.allSessions !== null) {
|
|
1309
|
+
// All-scope headers are already loaded — show them.
|
|
995
1310
|
this.header.loading = false;
|
|
996
1311
|
this.sessionList.setSessions(this.allSessions, true);
|
|
997
|
-
} else if (!this.allLoading) {
|
|
998
|
-
// Start loading all sessions
|
|
999
|
-
this.allLoading = true;
|
|
1000
|
-
this.header.loading = true;
|
|
1001
|
-
this.header.loadProgress = null;
|
|
1002
|
-
this.startAllLoadBackground();
|
|
1003
1312
|
} else {
|
|
1313
|
+
// All-scope load is in progress (started at construction: metas stat
|
|
1314
|
+
// → header batches). Show the loading indicator until it lands; the
|
|
1315
|
+
// background load updates the list as batches complete. startAllLoadBackground
|
|
1316
|
+
// is idempotent, so this also restarts the load if a refresh cancelled it.
|
|
1004
1317
|
this.header.loading = true;
|
|
1318
|
+
this.startAllLoadBackground();
|
|
1005
1319
|
}
|
|
1006
1320
|
} else {
|
|
1007
1321
|
this.scope = "current";
|
|
@@ -1052,15 +1366,21 @@ async function showFastResumePicker(
|
|
|
1052
1366
|
|
|
1053
1367
|
const t0 = Date.now();
|
|
1054
1368
|
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
|
|
1058
|
-
|
|
1369
|
+
// #2 — Forward-load only the top-N most-recent current-scope sessions before
|
|
1370
|
+
// first paint; the rest stream in from the background. The current-scope
|
|
1371
|
+
// stat is cheap (one dir); the all-dirs stat (~100ms at scale) is deferred
|
|
1372
|
+
// off the first-paint critical path — the picker stats all dirs in the
|
|
1373
|
+
// background before it starts the all-scope header load.
|
|
1374
|
+
const {
|
|
1375
|
+
headers: currentSessions,
|
|
1376
|
+
remaining: remainingCurrentMetas,
|
|
1377
|
+
allMetas: currentMetas,
|
|
1378
|
+
} = loadCurrentSessionsTopN(cwd, sessionDir, usesDefaultSessionDir, IMMEDIATE_CURRENT_COUNT);
|
|
1059
1379
|
|
|
1060
1380
|
const loadTime = Date.now() - t0;
|
|
1061
1381
|
|
|
1062
1382
|
ctx.ui.notify(
|
|
1063
|
-
`Fast resume: ${currentSessions.length} current
|
|
1383
|
+
`Fast resume: ${currentSessions.length} current${remainingCurrentMetas.length > 0 ? " (streaming)" : ""}, all-scope in background in ${loadTime}ms`,
|
|
1064
1384
|
"info",
|
|
1065
1385
|
);
|
|
1066
1386
|
|
|
@@ -1073,8 +1393,8 @@ async function showFastResumePicker(
|
|
|
1073
1393
|
usesDefaultSessionDir,
|
|
1074
1394
|
ctx.sessionManager.getSessionFile(),
|
|
1075
1395
|
currentSessions,
|
|
1076
|
-
|
|
1077
|
-
|
|
1396
|
+
remainingCurrentMetas,
|
|
1397
|
+
currentMetas,
|
|
1078
1398
|
(result) => done(result),
|
|
1079
1399
|
() => _tui.requestRender(),
|
|
1080
1400
|
initialQuery,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fast-resume",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Fast session picker for pi — reads headers + first messages from 16KB partial reads instead of full-file parsing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"test": "vitest run",
|
|
51
51
|
"test:watch": "vitest",
|
|
52
52
|
"test:coverage": "vitest run --coverage",
|
|
53
|
+
"bench": "vitest bench",
|
|
53
54
|
"typecheck": "tsc --noEmit",
|
|
54
55
|
"lint:dead": "knip --no-gitignore"
|
|
55
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
parseSessionFromBuffer,
|
|
3
|
+
scanTailForSessionInfo,
|
|
4
|
+
loadSessionHeader,
|
|
5
|
+
loadSessionHeaderForward,
|
|
6
|
+
loadSessionHeaders,
|
|
7
|
+
loadSessionHeadersForward,
|
|
8
|
+
resolveSessionName,
|
|
9
|
+
resolveSessionNamesDeferred,
|
|
10
|
+
scanAllSessionDirs,
|
|
11
|
+
scanSessionDir,
|
|
12
|
+
sortByModified,
|
|
13
|
+
sortByModifiedDesc,
|
|
14
|
+
filterByCwd,
|
|
15
|
+
matchQuery,
|
|
16
|
+
canonicalizePath,
|
|
17
|
+
clearCanonicalPathCache,
|
|
18
|
+
} from "./scanner.js";
|
|
19
|
+
export type { SessionHeader, SessionFileMeta, TailSessionInfo } from "./scanner.js";
|
|
3
20
|
export {
|
|
4
21
|
parseSearchQuery,
|
|
5
22
|
matchSession,
|
|
23
|
+
invalidateSessionSearchText,
|
|
6
24
|
hasSessionName,
|
|
7
25
|
filterAndSortSessions,
|
|
8
26
|
buildSessionTree,
|
package/src/scanner.ts
CHANGED
|
@@ -13,6 +13,17 @@ export interface SessionHeader {
|
|
|
13
13
|
messageCount: number;
|
|
14
14
|
firstMessage: string;
|
|
15
15
|
name?: string;
|
|
16
|
+
// Internal forward-pass bookkeeping for the deferred rename-name resolver.
|
|
17
|
+
// Not part of the public contract; only loadSessionHeaderForward sets these.
|
|
18
|
+
/** @internal forward pass consumed the whole file — forward name is final; the deferred tail resolver skips it. */
|
|
19
|
+
_fwdReachedEof?: boolean;
|
|
20
|
+
/** @internal bytes the forward pass consumed — lower bound for the deferred tail read so it never re-reads covered bytes. */
|
|
21
|
+
_fwdConsumedBytes?: number;
|
|
22
|
+
// Internal lazy cache for the search blob (id + name + firstMessage + cwd).
|
|
23
|
+
// Built on first matchSession call, invalidated when `name` mutates (rename
|
|
24
|
+
// resolution). Keeps per-keystroke search from re-concatenating per session.
|
|
25
|
+
/** @internal */
|
|
26
|
+
_searchText?: string;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export interface SessionFileMeta {
|
|
@@ -240,6 +251,14 @@ export function scanTailForSessionInfo(
|
|
|
240
251
|
for (const line of lines) {
|
|
241
252
|
const trimmed = line.trim();
|
|
242
253
|
if (!trimmed) continue;
|
|
254
|
+
// #7 — Cheap pre-filter: only session_info entries matter here, so skip
|
|
255
|
+
// JSON.parse for the (common) message lines without a full parse. The
|
|
256
|
+
// quoted marker is conservative — a message whose content literally
|
|
257
|
+
// contains "session_info" false-positives into one parse (harmless); a
|
|
258
|
+
// real session_info entry always carries it. Partial lines at the
|
|
259
|
+
// read-start boundary lack the marker and skip cheaply (JSON.parse would
|
|
260
|
+
// have thrown and been caught anyway).
|
|
261
|
+
if (!trimmed.includes('"session_info"')) continue;
|
|
243
262
|
try {
|
|
244
263
|
const entry = JSON.parse(trimmed);
|
|
245
264
|
if (typeof entry === "object" && entry !== null && entry.type === "session_info") {
|
|
@@ -271,7 +290,10 @@ function forEachLineForward(
|
|
|
271
290
|
onLine: (line: string) => boolean | void,
|
|
272
291
|
): { reachedEof: boolean; consumedBytes: number } {
|
|
273
292
|
const decoder = new StringDecoder("utf8");
|
|
274
|
-
|
|
293
|
+
// #8 — allocUnsafe: readSync fully overwrites [0, bytesRead) before the
|
|
294
|
+
// buffer is read (via decoder.write(subarray(0, bytesRead))), so the
|
|
295
|
+
// zero-fill of Buffer.alloc is wasted work. Skips a 16 KB memset per file.
|
|
296
|
+
const chunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
|
|
275
297
|
let lineBuf = "";
|
|
276
298
|
let offset = 0;
|
|
277
299
|
let consumedBytes = 0;
|
|
@@ -397,20 +419,117 @@ export function scanSessionDir(
|
|
|
397
419
|
return results;
|
|
398
420
|
}
|
|
399
421
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
422
|
+
// Forward-only load: reads complete lines from the start and stops at the
|
|
423
|
+
// first user message (which is all the title row needs). This reads exactly as
|
|
424
|
+
// many bytes as the first user message requires — a few KB for a normal
|
|
425
|
+
// session, ~19KB for a <skill> injection, more for a base64 image — and never
|
|
426
|
+
// truncates a line mid-JSON the way a fixed byte window would. So oversized
|
|
427
|
+
// first user messages (the cases that used to show "(no messages)") are parsed
|
|
428
|
+
// correctly.
|
|
402
429
|
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
430
|
+
// No tail read — the returned header's name reflects only session_info entries
|
|
431
|
+
// seen within the forward window. For sessions whose latest rename lives past
|
|
432
|
+
// the forward stop point (the common case for renamed large sessions), pair
|
|
433
|
+
// this with resolveSessionName() run in the background; the name then populates
|
|
434
|
+
// in-place without blocking the picker's initial render.
|
|
435
|
+
export function loadSessionHeaderForward(
|
|
436
|
+
meta: SessionFileMeta,
|
|
437
|
+
): SessionHeader | null {
|
|
438
|
+
let fd: number | undefined;
|
|
439
|
+
try {
|
|
440
|
+
fd = openSync(meta.path, "r");
|
|
441
|
+
const acc = newAccumulator();
|
|
442
|
+
const { reachedEof, consumedBytes } = forEachLineForward(fd, meta.size, (line) => {
|
|
443
|
+
processEntry(acc, line);
|
|
444
|
+
if (acc.header && acc.foundFirstUser) return false;
|
|
445
|
+
return true;
|
|
446
|
+
});
|
|
447
|
+
const header = buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
|
|
448
|
+
if (header) {
|
|
449
|
+
// Carry forward-pass bookkeeping so the deferred rename-name resolver can
|
|
450
|
+
// skip files whose forward pass reached EOF (name already final) and bound
|
|
451
|
+
// its tail read below by the consumed bytes (never re-reads covered bytes).
|
|
452
|
+
header._fwdReachedEof = reachedEof;
|
|
453
|
+
header._fwdConsumedBytes = consumedBytes;
|
|
454
|
+
}
|
|
455
|
+
return header;
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
} finally {
|
|
459
|
+
if (fd !== undefined) closeSync(fd);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Resolve the latest session_info (the rename name) from a bounded tail at EOF,
|
|
464
|
+
// independent of any forward pass. Returns found:false when no session_info
|
|
465
|
+
// lives in the tail region (keep whatever name the forward pass produced);
|
|
466
|
+
// found:true means a session_info was seen — its name (or explicit clear)
|
|
467
|
+
// overrides the forward name (it is later in file order).
|
|
409
468
|
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
469
|
+
// This is the deferred half of loadSessionHeader, exposed so callers can show
|
|
470
|
+
// a row immediately with the forward name and resolve the rename name in the
|
|
471
|
+
// background. Reading up to TAIL_READ_SIZE bytes from EOF may overlap the
|
|
472
|
+
// forward region for small files. Pass `consumedBytesLowerBound` (the forward
|
|
473
|
+
// pass's consumedBytes) so the tail starts at/after the bytes the forward pass
|
|
474
|
+
// already parsed — avoiding a redundant re-read and re-parse of that range.
|
|
475
|
+
// When the bound equals the file size (the forward pass reached EOF) there are
|
|
476
|
+
// no bytes left to read and this returns found:false; callers that already
|
|
477
|
+
// know the forward pass reached EOF should skip the call entirely (see
|
|
478
|
+
// resolveSessionNamesDeferred).
|
|
479
|
+
export function resolveSessionName(
|
|
480
|
+
meta: SessionFileMeta,
|
|
481
|
+
options?: { consumedBytesLowerBound?: number },
|
|
482
|
+
): TailSessionInfo {
|
|
483
|
+
if (meta.size <= 0) return { found: false };
|
|
484
|
+
// Bound the tail below by the forward pass's consumed bytes so it never
|
|
485
|
+
// re-reads bytes the forward pass already covered. Matches the combined
|
|
486
|
+
// loadSessionHeader path's tail math: tailReadSize = min(TAIL, size - bound),
|
|
487
|
+
// tailOffset = size - tailReadSize (>= bound).
|
|
488
|
+
const lowerBound = Math.max(0, Math.min(options?.consumedBytesLowerBound ?? 0, meta.size));
|
|
489
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - lowerBound);
|
|
490
|
+
if (tailReadSize <= 0) return { found: false };
|
|
491
|
+
let fd: number | undefined;
|
|
492
|
+
try {
|
|
493
|
+
fd = openSync(meta.path, "r");
|
|
494
|
+
// #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
|
|
495
|
+
const tailBuf = Buffer.allocUnsafe(tailReadSize);
|
|
496
|
+
const tailOffset = meta.size - tailReadSize;
|
|
497
|
+
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
498
|
+
return scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
499
|
+
} catch {
|
|
500
|
+
return { found: false };
|
|
501
|
+
} finally {
|
|
502
|
+
if (fd !== undefined) closeSync(fd);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Resolve rename names for a batch of forward-loaded headers, the pure core of
|
|
507
|
+
// the picker's cooperative name-resolution drain. Skips headers whose forward
|
|
508
|
+
// pass reached EOF (their name is already final — no tail read needed) and
|
|
509
|
+
// bounds each remaining tail read below by the forward pass's consumed bytes
|
|
510
|
+
// (never re-reads already-covered bytes). Returns only paths whose tail found
|
|
511
|
+
// a session_info (name may be undefined for an explicit clear), for the caller
|
|
512
|
+
// to apply in-place. Exposed for direct testing and benchmarking.
|
|
513
|
+
export function resolveSessionNamesDeferred(
|
|
514
|
+
headers: SessionHeader[],
|
|
515
|
+
metaByPath: Map<string, SessionFileMeta>,
|
|
516
|
+
): Map<string, string | undefined> {
|
|
517
|
+
const updates = new Map<string, string | undefined>();
|
|
518
|
+
for (const h of headers) {
|
|
519
|
+
if (h._fwdReachedEof) continue; // forward pass saw every session_info
|
|
520
|
+
const meta = metaByPath.get(h.path);
|
|
521
|
+
if (!meta) continue;
|
|
522
|
+
const tail = resolveSessionName(meta, { consumedBytesLowerBound: h._fwdConsumedBytes });
|
|
523
|
+
if (tail.found) updates.set(h.path, tail.name);
|
|
524
|
+
}
|
|
525
|
+
return updates;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Load a session header using a streaming forward read plus a bounded tail read
|
|
529
|
+
// — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
|
|
530
|
+
// followed by resolveSessionName, but bounds the tail below by the forward stop
|
|
531
|
+
// offset so it never re-reads already-covered bytes. Use this when the full
|
|
532
|
+
// header (including rename name) is needed synchronously.
|
|
414
533
|
export function loadSessionHeader(
|
|
415
534
|
meta: SessionFileMeta,
|
|
416
535
|
): SessionHeader | null {
|
|
@@ -438,7 +557,8 @@ export function loadSessionHeader(
|
|
|
438
557
|
if (!forwardReachedEof) {
|
|
439
558
|
try {
|
|
440
559
|
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
|
|
441
|
-
|
|
560
|
+
// #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
|
|
561
|
+
const tailBuf = Buffer.allocUnsafe(tailReadSize);
|
|
442
562
|
const tailOffset = meta.size - tailReadSize;
|
|
443
563
|
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
444
564
|
tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
@@ -466,6 +586,20 @@ export function loadSessionHeaders(
|
|
|
466
586
|
return results;
|
|
467
587
|
}
|
|
468
588
|
|
|
589
|
+
// Forward-only batch load — see loadSessionHeaderForward. Use for the picker's
|
|
590
|
+
// immediate display path: rows appear instantly with the correct firstMessage,
|
|
591
|
+
// and rename names resolve in the background via resolveSessionName().
|
|
592
|
+
export function loadSessionHeadersForward(
|
|
593
|
+
metas: SessionFileMeta[],
|
|
594
|
+
): SessionHeader[] {
|
|
595
|
+
const results: SessionHeader[] = [];
|
|
596
|
+
for (const meta of metas) {
|
|
597
|
+
const header = loadSessionHeaderForward(meta);
|
|
598
|
+
if (header) results.push(header);
|
|
599
|
+
}
|
|
600
|
+
return results;
|
|
601
|
+
}
|
|
602
|
+
|
|
469
603
|
export function sortByModified(sessions: SessionHeader[]): SessionHeader[] {
|
|
470
604
|
return sessions.sort(
|
|
471
605
|
(a, b) => b.modified.getTime() - a.modified.getTime(),
|
|
@@ -491,16 +625,37 @@ export function filterByCwd(
|
|
|
491
625
|
});
|
|
492
626
|
}
|
|
493
627
|
|
|
628
|
+
// Process-lifetime memo of realpath results. canonicalizePath is called
|
|
629
|
+
// 2–3× per session per tree build (buildSessionTree) and once per visible row
|
|
630
|
+
// per render (isCurrentSessionPath), and the tree is rebuilt on every keystroke
|
|
631
|
+
// in threaded mode. realpathSync is a syscall (~µs each); memoizing collapses
|
|
632
|
+
// thousands of syscalls per keystroke to Map lookups. This is pure memoization
|
|
633
|
+
// (no persistent file, no staleness to manage) — realpath of a path is stable
|
|
634
|
+
// for the process lifetime unless a symlink target changes, which doesn't
|
|
635
|
+
// happen to session files under ~/.pi/agent/sessions. Call
|
|
636
|
+
// clearCanonicalPathCache() to reset (used by tests/benches).
|
|
637
|
+
const canonicalPathCache = new Map<string, string>();
|
|
638
|
+
|
|
494
639
|
/**
|
|
495
|
-
* Canonicalize a file path by resolving symlinks.
|
|
640
|
+
* Canonicalize a file path by resolving symlinks, memoized per process.
|
|
496
641
|
* Matches pi-core's canonicalizePath behavior (realpathSync with fallback).
|
|
497
642
|
*/
|
|
498
643
|
export function canonicalizePath(path: string): string {
|
|
644
|
+
const cached = canonicalPathCache.get(path);
|
|
645
|
+
if (cached !== undefined) return cached;
|
|
646
|
+
let result: string;
|
|
499
647
|
try {
|
|
500
|
-
|
|
648
|
+
result = realpathSync(path);
|
|
501
649
|
} catch {
|
|
502
|
-
|
|
650
|
+
result = path;
|
|
503
651
|
}
|
|
652
|
+
canonicalPathCache.set(path, result);
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Clear the canonicalizePath memo. Intended for tests/benches. */
|
|
657
|
+
export function clearCanonicalPathCache(): void {
|
|
658
|
+
canonicalPathCache.clear();
|
|
504
659
|
}
|
|
505
660
|
|
|
506
661
|
export function matchQuery(
|
package/src/search.ts
CHANGED
|
@@ -45,9 +45,22 @@ function normalizeWhitespaceLower(text: string): string {
|
|
|
45
45
|
* matches are unaffected.
|
|
46
46
|
*
|
|
47
47
|
* See README "Known Limitations" section for the user-facing explanation.
|
|
48
|
+
*
|
|
49
|
+
* Memoized on the session (`_searchText`): matchSession is called per session
|
|
50
|
+
* per token per keystroke, so the concatenation is built once on first search
|
|
51
|
+
* and reused thereafter. Invalidate via invalidateSessionSearchText when
|
|
52
|
+
* `name` mutates (the picker's rename-name resolution updates names in place).
|
|
48
53
|
*/
|
|
49
54
|
function getSessionSearchText(session: SessionHeader): string {
|
|
50
|
-
|
|
55
|
+
if (session._searchText !== undefined) return session._searchText;
|
|
56
|
+
const text = `${session.id} ${session.name ?? ""} ${session.firstMessage} ${session.cwd}`;
|
|
57
|
+
session._searchText = text;
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop the cached search blob so the next getSessionSearchText rebuilds it. */
|
|
62
|
+
export function invalidateSessionSearchText(session: SessionHeader): void {
|
|
63
|
+
session._searchText = undefined;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export function parseSearchQuery(query: string): ParsedSearch {
|
package/vitest.config.ts
CHANGED
|
@@ -9,7 +9,10 @@ export default defineConfig({
|
|
|
9
9
|
coverage: {
|
|
10
10
|
provider: "v8",
|
|
11
11
|
reporter: ["text", "json", "html"],
|
|
12
|
-
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
|
|
12
|
+
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.bench.ts"],
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
|
+
// `vitest bench` discovers bench files via its default glob
|
|
16
|
+
// (**/*.{bench,benchmark}.*); `vitest run` only uses test.include above, so
|
|
17
|
+
// __tests__/perf.bench.ts is benchmark-only and never runs as a test.
|
|
15
18
|
});
|