pi-fast-resume 1.3.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 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() all .jsonl files ──────► sort by mtime ──────► stream top 30 forward
169
- (~100ms) (recent first) (~6ms)
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
- Background: load rest in batches of 50
178
- (non-blocking via setImmediate)
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()` all session files** — collect paths and mtimes (~100 ms for 1,700 files)
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 each file forward** line by line until the first user message, then read a bounded tail at EOF for the latest rename name (~6 ms)
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 stream in batches of 50, non-blocking
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
@@ -73,7 +73,7 @@ import {
73
73
  scanSessionDir,
74
74
  loadSessionHeaders,
75
75
  loadSessionHeadersForward,
76
- resolveSessionName,
76
+ resolveSessionNamesDeferred,
77
77
  sortByModified,
78
78
  sortByModifiedDesc,
79
79
  filterByCwd,
@@ -84,6 +84,7 @@ import {
84
84
  import {
85
85
  parseSearchQuery,
86
86
  matchSession,
87
+ invalidateSessionSearchText,
87
88
  hasSessionName,
88
89
  filterAndSortSessions,
89
90
  buildSessionTree,
@@ -97,6 +98,13 @@ import {
97
98
 
98
99
  const HOME = homedir();
99
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
+
100
108
  // Config — read from ~/.pi/agent/extensions/pi-fast-resume.json
101
109
  // Example: { "hijackResume": false, "shortcut": "alt+u" }
102
110
  // By default hijackResume is true — /resume opens the fast picker
@@ -125,23 +133,30 @@ export interface FastResumeResult {
125
133
 
126
134
  type StatusMessage = { type: "info" | "error"; message: string };
127
135
 
128
- // Session loading helpers mirror SessionManager.list / listAll behavior
129
- // while keeping partial reads.
130
-
131
- function loadCurrentSessionsImmediate(
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(
132
144
  cwd: string,
133
145
  sessionDir: string | undefined,
134
146
  usesDefaultSessionDir: boolean,
135
- ): SessionHeader[] {
136
- if (!sessionDir) return [];
137
- const metas = sortByModifiedDesc(scanSessionDir(sessionDir));
138
- let headers = loadSessionHeadersForward(metas);
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);
139
154
  if (!usesDefaultSessionDir) {
140
155
  // Custom session dirs may contain sessions from multiple cwds; filter to
141
156
  // the current one, matching SessionManager.list behavior.
142
157
  headers = filterByCwd(headers, cwd);
143
158
  }
144
- return sortByModified(headers);
159
+ return { headers: sortByModified(headers), remaining, allMetas };
145
160
  }
146
161
 
147
162
  function loadAllSessionMetas(
@@ -349,6 +364,17 @@ class FastResumeSessionList implements Component {
349
364
  nameFilter: NameFilter = "all";
350
365
  confirmingDeletePath: string | null = null;
351
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;
352
378
  currentSessionCanonicalPath: string | undefined;
353
379
 
354
380
  onSelect?: (sessionPath: string) => void;
@@ -404,6 +430,15 @@ class FastResumeSessionList implements Component {
404
430
  this.filterSessions(this.searchInput.getValue());
405
431
  }
406
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
+
407
442
  setConfirmingDeletePath(path: string | null): void {
408
443
  this.confirmingDeletePath = path;
409
444
  this.onDeleteConfirmationChange?.(path);
@@ -432,11 +467,23 @@ class FastResumeSessionList implements Component {
432
467
  const trimmed = query.trim();
433
468
 
434
469
  if (this.sortMode === "threaded" && !trimmed) {
435
- // Threaded mode without search: show tree structure
436
- const roots = buildSessionTree(nameFiltered);
437
- this.filteredNodes = flattenSessionTree(roots);
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
+ }
438
483
  } else {
439
- // 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.
440
487
  const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode);
441
488
  this.filteredNodes = filtered.map((session) => ({
442
489
  session,
@@ -676,14 +723,18 @@ class FastResumePicker extends Container {
676
723
  private allMetas: SessionFileMeta[] = [];
677
724
  private loadingAbort: AbortController | null = null;
678
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[] = [];
679
730
 
680
731
  // Deferred rename-name resolution. The picker displays rows immediately
681
732
  // with forward-only headers (fast: ~80ms for 2.5k sessions); the latest rename
682
733
  // name (which pi appends at EOF, past the forward stop) is resolved per file
683
734
  // 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.
735
+ // blocking the initial render. See resolveSessionNamesDeferred in scanner.ts.
685
736
  private metaByPath = new Map<string, SessionFileMeta>();
686
- private nameResolveQueue: SessionFileMeta[] = [];
737
+ private nameResolveQueue: SessionHeader[] = [];
687
738
  private nameResolveScheduled = false;
688
739
  private nameResolveSeq = 0;
689
740
  private nameResolvedPaths = new Set<string>();
@@ -728,8 +779,8 @@ class FastResumePicker extends Container {
728
779
  usesDefaultSessionDir: boolean,
729
780
  currentSessionPath: string | undefined,
730
781
  initialCurrentSessions: SessionHeader[],
731
- allMetas: SessionFileMeta[],
732
- allSessions: SessionHeader[] | null,
782
+ remainingCurrentMetas: SessionFileMeta[],
783
+ currentMetas: SessionFileMeta[],
733
784
  done: (result: FastResumeResult) => void,
734
785
  tuiRequestRender: () => void,
735
786
  initialQuery?: string,
@@ -738,7 +789,9 @@ class FastResumePicker extends Container {
738
789
  this.theme = theme;
739
790
  this.done = done;
740
791
  this.tuiRequestRender = tuiRequestRender;
741
- this.allMetas = allMetas;
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 = [];
742
795
  this.cwd = currentCwd;
743
796
  this.sessionDir = sessionDir;
744
797
  this.usesDefaultSessionDir = usesDefaultSessionDir;
@@ -755,7 +808,7 @@ class FastResumePicker extends Container {
755
808
  // Create session list
756
809
  this.sessionList = new FastResumeSessionList(theme, currentSessionPath);
757
810
  this.currentSessions = initialCurrentSessions;
758
- this.allSessions = allSessions;
811
+ this.allSessions = null; // loaded in the background by startAllLoadBackground
759
812
 
760
813
  // Set initial data into the list
761
814
  this.sessionList.setSessions(initialCurrentSessions, false);
@@ -834,25 +887,28 @@ class FastResumePicker extends Container {
834
887
  // Build layout
835
888
  this.buildBaseLayout(this.sessionList);
836
889
 
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
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
841
893
  // 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
-
848
- // Start loading current sessions (mark as loaded since we already have them)
849
- this.currentLoading = false;
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();
850
905
  this.header.loading = false;
851
906
 
852
- // If we don't have all sessions yet, pre-load them in the background
853
- if (allSessions === null && allMetas.length > 0) {
854
- this.startAllLoadBackground();
855
- }
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();
856
912
  }
857
913
 
858
914
  private enterRenameMode(sessionPath: string, currentName?: string): void {
@@ -926,9 +982,14 @@ class FastResumePicker extends Container {
926
982
  private async refreshSessionsAfterMutation(): Promise<void> {
927
983
  // Rescan from disk so renames, deletes, and newly created sessions are
928
984
  // reflected in the list. This mirrors upstream's loadScope(scope, "refresh").
929
- // Bump the sequence number first so any in-progress background all-load
930
- // stops before it can overwrite the rescanned data.
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.
931
989
  this.allLoadSeq++;
990
+ this.currentLoadSeq++;
991
+ this.currentLoading = false;
992
+ this.remainingCurrentMetas = [];
932
993
  try {
933
994
  if (this.scope === "current") {
934
995
  this.currentSessions = this.rescanCurrentScope();
@@ -951,26 +1012,158 @@ class FastResumePicker extends Container {
951
1012
  }
952
1013
  }
953
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.
954
1104
  private startAllLoadBackground(): void {
1105
+ if (this.allLoading) return; // already running
1106
+ if (this.allSessions !== null) return; // already complete
955
1107
  this.allLoading = true;
956
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 {
957
1145
  const BATCH_SIZE = 50;
958
- const sorted = sortByModifiedDesc([...this.allMetas]);
959
1146
  let offset = 0;
960
1147
  const allParsed: SessionHeader[] = [];
961
1148
 
1149
+ if (this.scope === "all") {
1150
+ this.header.loadProgress = { loaded: 0, total: sorted.length };
1151
+ this.tuiRequestRender();
1152
+ }
1153
+
962
1154
  const loadBatch = () => {
963
- if (seq !== this.allLoadSeq) return; // Stale
964
- 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; }
965
1157
 
966
1158
  const batch = sorted.slice(offset, offset + BATCH_SIZE);
967
1159
  if (batch.length === 0) {
968
1160
  this.allLoading = false;
969
- this.allSessions = sortByModified(allParsed);
1161
+ this.allSessions = sortByModified(allParsed); // final sort in place
970
1162
 
971
1163
  // If we're currently showing "all" scope, update the list
972
1164
  if (this.scope === "all") {
973
1165
  this.header.loading = false;
1166
+ this.sessionList.invalidateTreeCache();
974
1167
  this.sessionList.setSessions(this.allSessions, true);
975
1168
  this.tuiRequestRender();
976
1169
 
@@ -984,31 +1177,30 @@ class FastResumePicker extends Container {
984
1177
 
985
1178
  let headers: SessionHeader[];
986
1179
  try {
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.
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.
990
1183
  headers = loadSessionHeadersForward(batch);
991
1184
  } catch (err) {
992
- const message = err instanceof Error ? err.message : String(err);
993
1185
  this.allLoading = false;
994
- if (this.scope === "all") {
995
- this.header.loading = false;
996
- this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
997
- this.tuiRequestRender();
998
- }
1186
+ this.handleAllLoadError(seq, err);
999
1187
  return;
1000
1188
  }
1001
1189
 
1002
1190
  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);
1007
-
1008
- // If we're currently showing "all" scope, update progress
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.
1009
1200
  if (this.scope === "all") {
1010
1201
  this.header.loadProgress = { loaded: allParsed.length, total: sorted.length };
1011
- this.allSessions = sortByModified([...allParsed]);
1202
+ this.allSessions = allParsed;
1203
+ this.sessionList.invalidateTreeCache();
1012
1204
  this.sessionList.setSessions(this.allSessions, true);
1013
1205
  this.tuiRequestRender();
1014
1206
  }
@@ -1020,15 +1212,29 @@ class FastResumePicker extends Container {
1020
1212
  setImmediate(loadBatch);
1021
1213
  }
1022
1214
 
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);
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);
1032
1238
  }
1033
1239
  this.scheduleNameResolution();
1034
1240
  }
@@ -1051,13 +1257,14 @@ class FastResumePicker extends Container {
1051
1257
  const batch = this.nameResolveQueue.splice(0, BATCH);
1052
1258
  if (batch.length === 0) return;
1053
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
+
1054
1264
  let updatedAny = false;
1055
- for (const meta of batch) {
1265
+ for (const [path, name] of updates) {
1056
1266
  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
- }
1267
+ if (this.applyNameUpdate(path, name)) updatedAny = true;
1061
1268
  }
1062
1269
 
1063
1270
  if (updatedAny) {
@@ -1081,6 +1288,9 @@ class FastResumePicker extends Container {
1081
1288
  for (const s of arr) {
1082
1289
  if (s.path === path) {
1083
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);
1084
1294
  updated = true;
1085
1295
  }
1086
1296
  }
@@ -1096,16 +1306,16 @@ class FastResumePicker extends Container {
1096
1306
  this.header.scope = "all";
1097
1307
 
1098
1308
  if (this.allSessions !== null) {
1309
+ // All-scope headers are already loaded — show them.
1099
1310
  this.header.loading = false;
1100
1311
  this.sessionList.setSessions(this.allSessions, true);
1101
- } else if (!this.allLoading) {
1102
- // Start loading all sessions
1103
- this.allLoading = true;
1104
- this.header.loading = true;
1105
- this.header.loadProgress = null;
1106
- this.startAllLoadBackground();
1107
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.
1108
1317
  this.header.loading = true;
1318
+ this.startAllLoadBackground();
1109
1319
  }
1110
1320
  } else {
1111
1321
  this.scope = "current";
@@ -1156,15 +1366,21 @@ async function showFastResumePicker(
1156
1366
 
1157
1367
  const t0 = Date.now();
1158
1368
 
1159
- // Load the current-scope sessions immediately, and collect metadata for the
1160
- // incremental "all" scope load that happens in the background.
1161
- const currentSessions = loadCurrentSessionsImmediate(cwd, sessionDir, usesDefaultSessionDir);
1162
- const allMetas = loadAllSessionMetas(sessionDir, usesDefaultSessionDir);
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);
1163
1379
 
1164
1380
  const loadTime = Date.now() - t0;
1165
1381
 
1166
1382
  ctx.ui.notify(
1167
- `Fast resume: ${currentSessions.length} current, ${allMetas.length} total in ${loadTime}ms`,
1383
+ `Fast resume: ${currentSessions.length} current${remainingCurrentMetas.length > 0 ? " (streaming)" : ""}, all-scope in background in ${loadTime}ms`,
1168
1384
  "info",
1169
1385
  );
1170
1386
 
@@ -1177,8 +1393,8 @@ async function showFastResumePicker(
1177
1393
  usesDefaultSessionDir,
1178
1394
  ctx.sessionManager.getSessionFile(),
1179
1395
  currentSessions,
1180
- allMetas,
1181
- null, // allSessions not yet loaded — will load in background
1396
+ remainingCurrentMetas,
1397
+ currentMetas,
1182
1398
  (result) => done(result),
1183
1399
  () => _tui.requestRender(),
1184
1400
  initialQuery,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-resume",
3
- "version": "1.3.0",
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
@@ -6,6 +6,7 @@ export {
6
6
  loadSessionHeaders,
7
7
  loadSessionHeadersForward,
8
8
  resolveSessionName,
9
+ resolveSessionNamesDeferred,
9
10
  scanAllSessionDirs,
10
11
  scanSessionDir,
11
12
  sortByModified,
@@ -13,11 +14,13 @@ export {
13
14
  filterByCwd,
14
15
  matchQuery,
15
16
  canonicalizePath,
17
+ clearCanonicalPathCache,
16
18
  } from "./scanner.js";
17
19
  export type { SessionHeader, SessionFileMeta, TailSessionInfo } from "./scanner.js";
18
20
  export {
19
21
  parseSearchQuery,
20
22
  matchSession,
23
+ invalidateSessionSearchText,
21
24
  hasSessionName,
22
25
  filterAndSortSessions,
23
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
- const chunk = Buffer.alloc(READ_CHUNK_SIZE);
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;
@@ -417,12 +439,20 @@ export function loadSessionHeaderForward(
417
439
  try {
418
440
  fd = openSync(meta.path, "r");
419
441
  const acc = newAccumulator();
420
- const { reachedEof } = forEachLineForward(fd, meta.size, (line) => {
442
+ const { reachedEof, consumedBytes } = forEachLineForward(fd, meta.size, (line) => {
421
443
  processEntry(acc, line);
422
444
  if (acc.header && acc.foundFirstUser) return false;
423
445
  return true;
424
446
  });
425
- return buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
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;
426
456
  } catch {
427
457
  return null;
428
458
  } finally {
@@ -439,15 +469,30 @@ export function loadSessionHeaderForward(
439
469
  // This is the deferred half of loadSessionHeader, exposed so callers can show
440
470
  // a row immediately with the forward name and resolve the rename name in the
441
471
  // 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 {
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 {
445
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 };
446
491
  let fd: number | undefined;
447
492
  try {
448
493
  fd = openSync(meta.path, "r");
449
- const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size);
450
- const tailBuf = Buffer.alloc(tailReadSize);
494
+ // #8 allocUnsafe: readSync overwrites [0, bytesRead) before use.
495
+ const tailBuf = Buffer.allocUnsafe(tailReadSize);
451
496
  const tailOffset = meta.size - tailReadSize;
452
497
  const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
453
498
  return scanTailForSessionInfo(tailBuf, tailBytesRead);
@@ -458,6 +503,28 @@ export function resolveSessionName(meta: SessionFileMeta): TailSessionInfo {
458
503
  }
459
504
  }
460
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
+
461
528
  // Load a session header using a streaming forward read plus a bounded tail read
462
529
  // — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
463
530
  // followed by resolveSessionName, but bounds the tail below by the forward stop
@@ -490,7 +557,8 @@ export function loadSessionHeader(
490
557
  if (!forwardReachedEof) {
491
558
  try {
492
559
  const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
493
- const tailBuf = Buffer.alloc(tailReadSize);
560
+ // #8 allocUnsafe: readSync overwrites [0, bytesRead) before use.
561
+ const tailBuf = Buffer.allocUnsafe(tailReadSize);
494
562
  const tailOffset = meta.size - tailReadSize;
495
563
  const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
496
564
  tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
@@ -557,16 +625,37 @@ export function filterByCwd(
557
625
  });
558
626
  }
559
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
+
560
639
  /**
561
- * Canonicalize a file path by resolving symlinks.
640
+ * Canonicalize a file path by resolving symlinks, memoized per process.
562
641
  * Matches pi-core's canonicalizePath behavior (realpathSync with fallback).
563
642
  */
564
643
  export function canonicalizePath(path: string): string {
644
+ const cached = canonicalPathCache.get(path);
645
+ if (cached !== undefined) return cached;
646
+ let result: string;
565
647
  try {
566
- return realpathSync(path);
648
+ result = realpathSync(path);
567
649
  } catch {
568
- return path;
650
+ result = path;
569
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();
570
659
  }
571
660
 
572
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
- return `${session.id} ${session.name ?? ""} ${session.firstMessage} ${session.cwd}`;
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
  });