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 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
@@ -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
- // Session loading helpers mirror SessionManager.list / listAll behavior
127
- // while keeping partial reads.
128
-
129
- 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(
130
144
  cwd: string,
131
145
  sessionDir: string | undefined,
132
146
  usesDefaultSessionDir: boolean,
133
- ): SessionHeader[] {
134
- if (!sessionDir) return [];
135
- const metas = sortByModifiedDesc(scanSessionDir(sessionDir));
136
- let headers = loadSessionHeaders(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);
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
- const roots = buildSessionTree(nameFiltered);
435
- 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
+ }
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
- allMetas: SessionFileMeta[],
719
- allSessions: SessionHeader[] | null,
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
- 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 = [];
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 = 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
- // Start loading current sessions (mark as loaded since we already have them)
822
- this.currentLoading = false;
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
- // If we don't have all sessions yet, pre-load them in the background
826
- if (allSessions === null && allMetas.length > 0) {
827
- this.startAllLoadBackground();
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 number first so any in-progress background all-load
903
- // 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.
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; // Stale
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
- headers = loadSessionHeaders(batch);
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
- if (this.scope === "all") {
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
- // 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.
975
1200
  if (this.scope === "all") {
976
1201
  this.header.loadProgress = { loaded: allParsed.length, total: sorted.length };
977
- this.allSessions = sortByModified([...allParsed]);
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
- // Load the current-scope sessions immediately, and collect metadata for the
1056
- // incremental "all" scope load that happens in the background.
1057
- const currentSessions = loadCurrentSessionsImmediate(cwd, sessionDir, usesDefaultSessionDir);
1058
- 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);
1059
1379
 
1060
1380
  const loadTime = Date.now() - t0;
1061
1381
 
1062
1382
  ctx.ui.notify(
1063
- `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`,
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
- allMetas,
1077
- null, // allSessions not yet loaded — will load in background
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.2.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
@@ -1,8 +1,26 @@
1
- export { parseSessionFromBuffer, loadSessionHeader, loadSessionHeaders, scanAllSessionDirs, scanSessionDir, sortByModified, sortByModifiedDesc, filterByCwd, matchQuery, canonicalizePath } from "./scanner.js";
2
- export type { SessionHeader, SessionFileMeta } from "./scanner.js";
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
- 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;
@@ -397,20 +419,117 @@ export function scanSessionDir(
397
419
  return results;
398
420
  }
399
421
 
400
- // Load a session header from disk using a streaming forward read plus a bounded
401
- // tail read no fixed head window.
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
- // Forward pass: reads complete lines from the start and stops at the first user
404
- // message (which is all the title row needs). This reads exactly as many bytes
405
- // as the first user message requires a few KB for a normal session, ~19KB
406
- // for a <skill> injection, more for a base64 image and never truncates a line
407
- // mid-JSON the way a fixed byte window would. So oversized first user messages
408
- // (the cases that used to show "(no messages)") are now parsed correctly.
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
- // Tail pass (only when the forward pass stopped before EOF): reads up to
411
- // TAIL_READ_SIZE bytes from EOF and recovers the latest session_info (the
412
- // rename name), bounded below by the forward stop offset so it never re-reads
413
- // covered bytes. See TAIL_READ_SIZE for the documented tradeoff.
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
- const tailBuf = Buffer.alloc(tailReadSize);
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
- return realpathSync(path);
648
+ result = realpathSync(path);
501
649
  } catch {
502
- return path;
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
- 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
  });