pi-fast-resume 1.3.0 → 1.4.1

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
@@ -43,7 +43,7 @@
43
43
  * tradeoff is by design — the fast load time depends on partial reads.
44
44
  */
45
45
 
46
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
46
+ import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
47
47
  import type { KeyId } from "@earendil-works/pi-tui";
48
48
  import {
49
49
  DynamicBorder,
@@ -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
@@ -106,7 +114,7 @@ interface FastResumeConfig {
106
114
  shortcut?: string;
107
115
  }
108
116
 
109
- const CONFIG_PATH = join(HOME, ".pi", "agent", "extensions", "pi-fast-resume.json");
117
+ const CONFIG_PATH = join(getAgentDir(), "extensions", "pi-fast-resume.json");
110
118
 
111
119
  function readConfig(): FastResumeConfig {
112
120
  try {
@@ -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.1",
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
@@ -1,7 +1,7 @@
1
1
  import { openSync, readSync, closeSync, readdirSync, statSync, realpathSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { homedir } from "node:os";
4
3
  import { StringDecoder } from "node:string_decoder";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export interface SessionHeader {
7
7
  path: string;
@@ -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;
@@ -315,8 +337,7 @@ function forEachLineForward(
315
337
  return { reachedEof: true, consumedBytes };
316
338
  }
317
339
 
318
- const HOME = homedir();
319
- const DEFAULT_SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
340
+ const DEFAULT_SESSIONS_DIR = join(getAgentDir(), "sessions");
320
341
 
321
342
  /**
322
343
  * Scan ALL session directories under the root (~/.pi/agent/sessions/).
@@ -417,12 +438,20 @@ export function loadSessionHeaderForward(
417
438
  try {
418
439
  fd = openSync(meta.path, "r");
419
440
  const acc = newAccumulator();
420
- const { reachedEof } = forEachLineForward(fd, meta.size, (line) => {
441
+ const { reachedEof, consumedBytes } = forEachLineForward(fd, meta.size, (line) => {
421
442
  processEntry(acc, line);
422
443
  if (acc.header && acc.foundFirstUser) return false;
423
444
  return true;
424
445
  });
425
- return buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
446
+ const header = buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
447
+ if (header) {
448
+ // Carry forward-pass bookkeeping so the deferred rename-name resolver can
449
+ // skip files whose forward pass reached EOF (name already final) and bound
450
+ // its tail read below by the consumed bytes (never re-reads covered bytes).
451
+ header._fwdReachedEof = reachedEof;
452
+ header._fwdConsumedBytes = consumedBytes;
453
+ }
454
+ return header;
426
455
  } catch {
427
456
  return null;
428
457
  } finally {
@@ -439,15 +468,30 @@ export function loadSessionHeaderForward(
439
468
  // This is the deferred half of loadSessionHeader, exposed so callers can show
440
469
  // a row immediately with the forward name and resolve the rename name in the
441
470
  // 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 {
471
+ // forward region for small files. Pass `consumedBytesLowerBound` (the forward
472
+ // pass's consumedBytes) so the tail starts at/after the bytes the forward pass
473
+ // already parsed — avoiding a redundant re-read and re-parse of that range.
474
+ // When the bound equals the file size (the forward pass reached EOF) there are
475
+ // no bytes left to read and this returns found:false; callers that already
476
+ // know the forward pass reached EOF should skip the call entirely (see
477
+ // resolveSessionNamesDeferred).
478
+ export function resolveSessionName(
479
+ meta: SessionFileMeta,
480
+ options?: { consumedBytesLowerBound?: number },
481
+ ): TailSessionInfo {
445
482
  if (meta.size <= 0) return { found: false };
483
+ // Bound the tail below by the forward pass's consumed bytes so it never
484
+ // re-reads bytes the forward pass already covered. Matches the combined
485
+ // loadSessionHeader path's tail math: tailReadSize = min(TAIL, size - bound),
486
+ // tailOffset = size - tailReadSize (>= bound).
487
+ const lowerBound = Math.max(0, Math.min(options?.consumedBytesLowerBound ?? 0, meta.size));
488
+ const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - lowerBound);
489
+ if (tailReadSize <= 0) return { found: false };
446
490
  let fd: number | undefined;
447
491
  try {
448
492
  fd = openSync(meta.path, "r");
449
- const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size);
450
- const tailBuf = Buffer.alloc(tailReadSize);
493
+ // #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
494
+ const tailBuf = Buffer.allocUnsafe(tailReadSize);
451
495
  const tailOffset = meta.size - tailReadSize;
452
496
  const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
453
497
  return scanTailForSessionInfo(tailBuf, tailBytesRead);
@@ -458,6 +502,28 @@ export function resolveSessionName(meta: SessionFileMeta): TailSessionInfo {
458
502
  }
459
503
  }
460
504
 
505
+ // Resolve rename names for a batch of forward-loaded headers, the pure core of
506
+ // the picker's cooperative name-resolution drain. Skips headers whose forward
507
+ // pass reached EOF (their name is already final — no tail read needed) and
508
+ // bounds each remaining tail read below by the forward pass's consumed bytes
509
+ // (never re-reads already-covered bytes). Returns only paths whose tail found
510
+ // a session_info (name may be undefined for an explicit clear), for the caller
511
+ // to apply in-place. Exposed for direct testing and benchmarking.
512
+ export function resolveSessionNamesDeferred(
513
+ headers: SessionHeader[],
514
+ metaByPath: Map<string, SessionFileMeta>,
515
+ ): Map<string, string | undefined> {
516
+ const updates = new Map<string, string | undefined>();
517
+ for (const h of headers) {
518
+ if (h._fwdReachedEof) continue; // forward pass saw every session_info
519
+ const meta = metaByPath.get(h.path);
520
+ if (!meta) continue;
521
+ const tail = resolveSessionName(meta, { consumedBytesLowerBound: h._fwdConsumedBytes });
522
+ if (tail.found) updates.set(h.path, tail.name);
523
+ }
524
+ return updates;
525
+ }
526
+
461
527
  // Load a session header using a streaming forward read plus a bounded tail read
462
528
  // — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
463
529
  // followed by resolveSessionName, but bounds the tail below by the forward stop
@@ -490,7 +556,8 @@ export function loadSessionHeader(
490
556
  if (!forwardReachedEof) {
491
557
  try {
492
558
  const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
493
- const tailBuf = Buffer.alloc(tailReadSize);
559
+ // #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
560
+ const tailBuf = Buffer.allocUnsafe(tailReadSize);
494
561
  const tailOffset = meta.size - tailReadSize;
495
562
  const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
496
563
  tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
@@ -557,16 +624,37 @@ export function filterByCwd(
557
624
  });
558
625
  }
559
626
 
627
+ // Process-lifetime memo of realpath results. canonicalizePath is called
628
+ // 2–3× per session per tree build (buildSessionTree) and once per visible row
629
+ // per render (isCurrentSessionPath), and the tree is rebuilt on every keystroke
630
+ // in threaded mode. realpathSync is a syscall (~µs each); memoizing collapses
631
+ // thousands of syscalls per keystroke to Map lookups. This is pure memoization
632
+ // (no persistent file, no staleness to manage) — realpath of a path is stable
633
+ // for the process lifetime unless a symlink target changes, which doesn't
634
+ // happen to session files under ~/.pi/agent/sessions. Call
635
+ // clearCanonicalPathCache() to reset (used by tests/benches).
636
+ const canonicalPathCache = new Map<string, string>();
637
+
560
638
  /**
561
- * Canonicalize a file path by resolving symlinks.
639
+ * Canonicalize a file path by resolving symlinks, memoized per process.
562
640
  * Matches pi-core's canonicalizePath behavior (realpathSync with fallback).
563
641
  */
564
642
  export function canonicalizePath(path: string): string {
643
+ const cached = canonicalPathCache.get(path);
644
+ if (cached !== undefined) return cached;
645
+ let result: string;
565
646
  try {
566
- return realpathSync(path);
647
+ result = realpathSync(path);
567
648
  } catch {
568
- return path;
649
+ result = path;
569
650
  }
651
+ canonicalPathCache.set(path, result);
652
+ return result;
653
+ }
654
+
655
+ /** Clear the canonicalizePath memo. Intended for tests/benches. */
656
+ export function clearCanonicalPathCache(): void {
657
+ canonicalPathCache.clear();
570
658
  }
571
659
 
572
660
  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
  });