pi-fast-resume 1.2.0 → 1.3.0

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