pi-fast-resume 1.1.1 → 1.1.3

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Instant session picker for [pi](https://github.com/earendil-works/pi-coding-agent)**
6
6
 
7
- _Reads 16KB per file instead of the full JSONL — first results in **6ms**._
7
+ _Reads the head + tail of each file instead of the full JSONL — first results in **6ms**._
8
8
 
9
9
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
10
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
@@ -16,7 +16,7 @@ _Reads 16KB per file instead of the full JSONL — first results in **6ms**._
16
16
  > **`/resume` takes 5.6 seconds** when you have 1,700+ sessions.
17
17
  > pi-fast-resume's `/fast-resume` takes **6 milliseconds**.
18
18
 
19
- Same picker UI and keybindings as `/resume`. The difference is pi-fast-resume never reads beyond the first 16KB of any session file. Headers, names, first messages — they all live in the first few lines. Everything after that is full message history the picker never shows. Search matches against the first message only (see [Known Limitations](#known-limitations)).
19
+ Same picker UI and keybindings as `/resume`. The difference is pi-fast-resume reads at most ~24KB of each file (a 16KB head + up to 8KB tail) instead of the full JSONL. The head carries the header and first user message; the tail recovers the latest session name, which pi appends at EOF on `/rename`. Everything between is full message history the picker never shows. Search matches against the first message only (see [Known Limitations](#known-limitations)).
20
20
 
21
21
  ```
22
22
  ──────────────────────────────────────────────────────────
@@ -180,7 +180,7 @@ stat() all .jsonl files ──────► sort by mtime ──────
180
180
 
181
181
  1. **`stat()` all session files** — collect paths and mtimes (~100 ms for 1,700 files)
182
182
  2. **Sort by mtime descending** — most recent sessions first
183
- 3. **Read first 16KB** of the top 30 files — extract header, name, first user message (~6 ms)
183
+ 3. **Read head (16KB) + tail (≤8KB)** of the top 30 files — extract header, first user message, and latest session name (~6 ms)
184
184
  4. **Show picker** — user can navigate, filter, and select immediately
185
185
  5. **Background load** — remaining sessions stream in batches of 50, non-blocking
186
186
  6. **Tab to switch scope** — filter to current project or show everything
package/fast-resume.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  loadSessionHeaders,
74
74
  sortByModified,
75
75
  sortByModifiedDesc,
76
+ filterByCwd,
76
77
  canonicalizePath,
77
78
  type SessionHeader,
78
79
  type SessionFileMeta,
@@ -88,8 +89,8 @@ import {
88
89
  type FlatSessionNode,
89
90
  type SortMode,
90
91
  type NameFilter,
92
+ type PickerScope,
91
93
  } from "./src/search.js";
92
- import type { PickerScope } from "./src/picker-state.js";
93
94
 
94
95
  const HOME = homedir();
95
96
 
@@ -121,6 +122,43 @@ export interface FastResumeResult {
121
122
 
122
123
  type StatusMessage = { type: "info" | "error"; message: string };
123
124
 
125
+ // Session loading helpers — mirror SessionManager.list / listAll behavior
126
+ // while keeping partial reads.
127
+
128
+ function loadCurrentSessionsImmediate(
129
+ cwd: string,
130
+ sessionDir: string | undefined,
131
+ usesDefaultSessionDir: boolean,
132
+ ): SessionHeader[] {
133
+ if (!sessionDir) return [];
134
+ const metas = sortByModifiedDesc(scanSessionDir(sessionDir));
135
+ let headers = loadSessionHeaders(metas);
136
+ if (!usesDefaultSessionDir) {
137
+ // Custom session dirs may contain sessions from multiple cwds; filter to
138
+ // the current one, matching SessionManager.list behavior.
139
+ headers = filterByCwd(headers, cwd);
140
+ }
141
+ return sortByModified(headers);
142
+ }
143
+
144
+ function loadAllSessionMetas(
145
+ sessionDir: string | undefined,
146
+ usesDefaultSessionDir: boolean,
147
+ ): SessionFileMeta[] {
148
+ if (usesDefaultSessionDir) {
149
+ return scanAllSessionDirs();
150
+ }
151
+ if (!sessionDir) return [];
152
+ return sortByModifiedDesc(scanSessionDir(sessionDir));
153
+ }
154
+
155
+ // ReadonlySessionManager doesn't declare usesDefaultSessionDir, but the
156
+ // runtime SessionManager has it. Default to true so old pi versions behave
157
+ // like the original default-dir-only fast-resume.
158
+ function getUsesDefaultSessionDir(sessionManager: ExtensionCommandContext["sessionManager"]): boolean {
159
+ return (sessionManager as any).usesDefaultSessionDir?.() ?? true;
160
+ }
161
+
124
162
  // Helpers
125
163
 
126
164
  function shortenPath(path: string): string {
@@ -639,6 +677,10 @@ class FastResumePicker extends Container {
639
677
  private mode: "list" | "rename" = "list";
640
678
  private renameTargetPath: string | null = null;
641
679
 
680
+ private cwd: string;
681
+ private sessionDir: string | undefined;
682
+ private usesDefaultSessionDir: boolean;
683
+
642
684
  // Focusable — propagate to sessionList or renameInput
643
685
  private _focused = false;
644
686
  get focused() { return this._focused; }
@@ -668,18 +710,24 @@ class FastResumePicker extends Container {
668
710
  constructor(
669
711
  theme: Theme,
670
712
  currentCwd: string,
713
+ sessionDir: string | undefined,
714
+ usesDefaultSessionDir: boolean,
671
715
  currentSessionPath: string | undefined,
672
716
  initialCurrentSessions: SessionHeader[],
673
717
  allMetas: SessionFileMeta[],
674
718
  allSessions: SessionHeader[] | null,
675
719
  done: (result: FastResumeResult) => void,
676
720
  tuiRequestRender: () => void,
721
+ initialQuery?: string,
677
722
  ) {
678
723
  super();
679
724
  this.theme = theme;
680
725
  this.done = done;
681
726
  this.tuiRequestRender = tuiRequestRender;
682
727
  this.allMetas = allMetas;
728
+ this.cwd = currentCwd;
729
+ this.sessionDir = sessionDir;
730
+ this.usesDefaultSessionDir = usesDefaultSessionDir;
683
731
 
684
732
  // Create header
685
733
  this.header = new FastResumeHeader(theme, tuiRequestRender);
@@ -698,6 +746,12 @@ class FastResumePicker extends Container {
698
746
  // Set initial data into the list
699
747
  this.sessionList.setSessions(initialCurrentSessions, false);
700
748
 
749
+ // Seed an optional initial query from /fast-resume <query>
750
+ if (initialQuery !== undefined && initialQuery !== "") {
751
+ this.sessionList.searchInput.setValue(initialQuery);
752
+ this.sessionList.filterSessions(initialQuery);
753
+ }
754
+
701
755
  // Wire session list events
702
756
  this.sessionList.onSelect = (sessionPath) => {
703
757
  this.header.clearStatusTimeout();
@@ -819,12 +873,54 @@ class FastResumePicker extends Container {
819
873
  }
820
874
  }
821
875
 
876
+ private rescanCurrentScope(): SessionHeader[] {
877
+ if (!this.sessionDir) return [];
878
+ const metas = sortByModifiedDesc(scanSessionDir(this.sessionDir));
879
+ let headers = loadSessionHeaders(metas);
880
+ if (!this.usesDefaultSessionDir) {
881
+ headers = filterByCwd(headers, this.cwd);
882
+ }
883
+ return sortByModified(headers);
884
+ }
885
+
886
+ private rescanAllScope(): SessionHeader[] {
887
+ if (this.usesDefaultSessionDir) {
888
+ const metas = scanAllSessionDirs();
889
+ const headers = loadSessionHeaders(metas);
890
+ return sortByModified(headers);
891
+ }
892
+ if (!this.sessionDir) return [];
893
+ const metas = sortByModifiedDesc(scanSessionDir(this.sessionDir));
894
+ const headers = loadSessionHeaders(metas);
895
+ return sortByModified(headers);
896
+ }
897
+
822
898
  private async refreshSessionsAfterMutation(): Promise<void> {
823
- // After delete/rename, the in-memory arrays are already updated (filtered above).
824
- // Just re-apply them to the session list.
825
- const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
826
- const showCwd = this.scope === "all";
827
- this.sessionList.setSessions(sessions, showCwd);
899
+ // Rescan from disk so renames, deletes, and newly created sessions are
900
+ // reflected in the list. This mirrors upstream's loadScope(scope, "refresh").
901
+ // Bump the sequence number first so any in-progress background all-load
902
+ // stops before it can overwrite the rescanned data.
903
+ this.allLoadSeq++;
904
+ try {
905
+ if (this.scope === "current") {
906
+ this.currentSessions = this.rescanCurrentScope();
907
+ this.sessionList.setSessions(this.currentSessions, false);
908
+ } else {
909
+ this.allLoading = true;
910
+ this.allSessions = this.rescanAllScope();
911
+ this.allLoading = false;
912
+ this.sessionList.setSessions(this.allSessions, true);
913
+ }
914
+ this.header.loading = false;
915
+ this.header.loadProgress = null;
916
+ } catch (err) {
917
+ this.currentLoading = false;
918
+ this.allLoading = false;
919
+ this.header.loading = false;
920
+ this.header.loadProgress = null;
921
+ const message = err instanceof Error ? err.message : String(err);
922
+ this.header.setStatusMessage({ type: "error", message: `Failed to refresh: ${message}` }, 4000);
923
+ }
828
924
  }
829
925
 
830
926
  private startAllLoadBackground(): void {
@@ -951,19 +1047,14 @@ async function showFastResumePicker(
951
1047
  ): Promise<void> {
952
1048
  const cwd = ctx.cwd;
953
1049
  const sessionDir = ctx.sessionManager.getSessionDir();
1050
+ const usesDefaultSessionDir = getUsesDefaultSessionDir(ctx.sessionManager);
954
1051
 
955
1052
  const t0 = Date.now();
956
1053
 
957
- // Phase 1: stat all session files
958
- const currentMetas = sessionDir ? scanSessionDir(sessionDir) : [];
959
- const allMetas = scanAllSessionDirs();
960
-
961
- // Phase 2: quickly parse the first 30 for instant display
962
- const INITIAL_BATCH = 30;
963
- const sortedCurrent = sortByModifiedDesc(currentMetas);
964
- const quickMetas = sortedCurrent.slice(0, INITIAL_BATCH);
965
- const quickHeaders = loadSessionHeaders(quickMetas);
966
- const currentSessions = sortByModified(quickHeaders);
1054
+ // Load the current-scope sessions immediately, and collect metadata for the
1055
+ // incremental "all" scope load that happens in the background.
1056
+ const currentSessions = loadCurrentSessionsImmediate(cwd, sessionDir, usesDefaultSessionDir);
1057
+ const allMetas = loadAllSessionMetas(sessionDir, usesDefaultSessionDir);
967
1058
 
968
1059
  const loadTime = Date.now() - t0;
969
1060
 
@@ -977,12 +1068,15 @@ async function showFastResumePicker(
977
1068
  const picker = new FastResumePicker(
978
1069
  theme,
979
1070
  cwd,
1071
+ sessionDir,
1072
+ usesDefaultSessionDir,
980
1073
  ctx.sessionManager.getSessionFile(),
981
1074
  currentSessions,
982
1075
  allMetas,
983
1076
  null, // allSessions not yet loaded — will load in background
984
1077
  (result) => done(result),
985
1078
  () => _tui.requestRender(),
1079
+ initialQuery,
986
1080
  );
987
1081
 
988
1082
  return picker;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-fast-resume",
3
- "version": "1.1.1",
4
- "description": "Fast session picker for pi \u2014 reads headers + first messages from 16KB partial reads instead of full-file parsing",
3
+ "version": "1.1.3",
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",
7
7
  "license": "MIT",
@@ -29,13 +29,6 @@
29
29
  "src/",
30
30
  "README.md"
31
31
  ],
32
- "scripts": {
33
- "test": "vitest run",
34
- "test:watch": "vitest",
35
- "test:coverage": "vitest run --coverage",
36
- "typecheck": "tsc --noEmit",
37
- "lint:dead": "knip --no-gitignore"
38
- },
39
32
  "devDependencies": {
40
33
  "@earendil-works/pi-coding-agent": "0.79.4",
41
34
  "@earendil-works/pi-tui": "0.79.4",
@@ -52,5 +45,12 @@
52
45
  },
53
46
  "overrides": {
54
47
  "brace-expansion": "5.0.6"
48
+ },
49
+ "scripts": {
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:coverage": "vitest run --coverage",
53
+ "typecheck": "tsc --noEmit",
54
+ "lint:dead": "knip --no-gitignore"
55
55
  }
56
- }
56
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  export { parseSessionFromBuffer, loadSessionHeader, loadSessionHeaders, scanAllSessionDirs, scanSessionDir, sortByModified, sortByModifiedDesc, filterByCwd, matchQuery, canonicalizePath } from "./scanner.js";
2
2
  export type { SessionHeader, SessionFileMeta } from "./scanner.js";
3
- export { createInitialState, setScope, setSessions, setFilter, moveSelection } from "./picker-state.js";
4
- export type { PickerScope, PickerState } from "./picker-state.js";
5
3
  export {
6
4
  parseSearchQuery,
7
5
  matchSession,
@@ -18,6 +16,7 @@ export type {
18
16
  MatchResult,
19
17
  SortMode,
20
18
  NameFilter,
19
+ PickerScope,
21
20
  SessionTreeNode,
22
21
  FlatSessionNode,
23
22
  } from "./search.js";
package/src/scanner.ts CHANGED
@@ -23,6 +23,25 @@ export interface SessionFileMeta {
23
23
 
24
24
  const PARTIAL_READ_SIZE = 16_384;
25
25
 
26
+ // Size of the trailing read used to recover session_info entries (session
27
+ // names set via /rename or programmatically). These are appended at EOF by
28
+ // SessionManager.appendSessionInfo, so for any session larger than the head
29
+ // window the latest name lands past the partial read and would be invisible —
30
+ // showing "(no messages)" for renamed large sessions. The tail read recovers
31
+ // it, matching pi-core's getSessionName() semantics (latest session_info wins,
32
+ // including explicit name clears).
33
+ const TAIL_READ_SIZE = 8_192;
34
+
35
+ // Result of scanning a file tail for session_info entries.
36
+ // found: false → no session_info seen in the tail; fall back to the head's name.
37
+ // found: true → a session_info was seen (later in file order than anything
38
+ // in the head, since the tail starts past the head window);
39
+ // name is undefined if that entry explicitly cleared the name.
40
+ export interface TailSessionInfo {
41
+ found: boolean;
42
+ name?: string;
43
+ }
44
+
26
45
  function extractTextFromContent(
27
46
  content: string | Array<{ type: string; text?: string }>,
28
47
  ): string {
@@ -39,6 +58,7 @@ export function parseSessionFromBuffer(
39
58
  filePath: string,
40
59
  mtimeMs: number,
41
60
  partial = false,
61
+ tailInfo?: TailSessionInfo,
42
62
  ): SessionHeader | null {
43
63
  const decoder = new StringDecoder("utf8");
44
64
  const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
@@ -46,7 +66,7 @@ export function parseSessionFromBuffer(
46
66
 
47
67
  let header: { id: string; timestamp: string; cwd?: string; parentSession?: string } | null = null;
48
68
  let firstUserMsg = "";
49
- let name = "";
69
+ let name: string | undefined;
50
70
  let msgCount = 0;
51
71
  let lastActivityTime: number | undefined;
52
72
 
@@ -60,8 +80,7 @@ export function parseSessionFromBuffer(
60
80
  continue;
61
81
  }
62
82
  if (entry.type === "session_info") {
63
- const n = entry.name?.trim();
64
- if (n) name = n;
83
+ name = entry.name?.trim() || undefined;
65
84
  }
66
85
  if (entry.type === "message") {
67
86
  msgCount++;
@@ -118,6 +137,11 @@ export function parseSessionFromBuffer(
118
137
  : new Date(mtimeMs);
119
138
  }
120
139
 
140
+ // The tail scan (if any) sees session_info entries at EOF, which are later
141
+ // in file order than anything in the head window — so it wins over the
142
+ // head-derived name, including explicit clears (empty name → undefined).
143
+ const finalName = tailInfo?.found ? tailInfo.name : name;
144
+
121
145
  return {
122
146
  path: filePath,
123
147
  id: header.id,
@@ -126,11 +150,41 @@ export function parseSessionFromBuffer(
126
150
  created: new Date(header.timestamp),
127
151
  modified,
128
152
  messageCount: msgCount,
129
- firstMessage: firstUserMsg || "",
130
- name: name || undefined,
153
+ firstMessage: firstUserMsg || "(no messages)",
154
+ name: finalName,
131
155
  };
132
156
  }
133
157
 
158
+ // Scan a tail chunk (read from near EOF) for session_info entries and return
159
+ // the latest name, matching pi-core's getSessionName() semantics: the latest
160
+ // session_info in file order wins, including explicit clears (empty name).
161
+ // The tail's first line may be a partial line cut off at the read-start
162
+ // boundary — it is skipped naturally when JSON.parse fails.
163
+ export function scanTailForSessionInfo(
164
+ buf: Buffer,
165
+ bytesRead: number,
166
+ ): TailSessionInfo {
167
+ const decoder = new StringDecoder("utf8");
168
+ const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
169
+ const lines = text.split("\n");
170
+ let found = false;
171
+ let name: string | undefined;
172
+ for (const line of lines) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed) continue;
175
+ try {
176
+ const entry = JSON.parse(trimmed);
177
+ if (entry.type === "session_info") {
178
+ found = true;
179
+ name = entry.name?.trim() || undefined;
180
+ }
181
+ } catch {
182
+ // Partial line at tail boundary — skip
183
+ }
184
+ }
185
+ return { found, name };
186
+ }
187
+
134
188
  const HOME = homedir();
135
189
  const DEFAULT_SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
136
190
 
@@ -222,7 +276,35 @@ export function loadSessionHeader(
222
276
  const readSize = Math.min(PARTIAL_READ_SIZE, meta.size);
223
277
  const buf = Buffer.alloc(readSize);
224
278
  const bytesRead = readSync(fd, buf, 0, readSize, 0);
225
- return parseSessionFromBuffer(buf, bytesRead, meta.path, meta.mtimeMs, meta.size > PARTIAL_READ_SIZE);
279
+
280
+ // Recover the latest session name from EOF. session_info entries are
281
+ // appended at EOF by SessionManager.appendSessionInfo, so for any session
282
+ // larger than the head window they live past the 16KB read and would be
283
+ // invisible. The tail read covers `meta.size - PARTIAL_READ_SIZE` bytes
284
+ // (capped at TAIL_READ_SIZE), starting past the head window so it never
285
+ // overlaps. A failure here must not lose the whole header — fall back to
286
+ // head-only parsing by leaving tailInfo undefined.
287
+ let tailInfo: TailSessionInfo | undefined;
288
+ if (meta.size > PARTIAL_READ_SIZE) {
289
+ try {
290
+ const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - PARTIAL_READ_SIZE);
291
+ const tailBuf = Buffer.alloc(tailReadSize);
292
+ const tailOffset = meta.size - tailReadSize;
293
+ const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
294
+ tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
295
+ } catch {
296
+ // Tail read failed — fall back to head-only parse
297
+ }
298
+ }
299
+
300
+ return parseSessionFromBuffer(
301
+ buf,
302
+ bytesRead,
303
+ meta.path,
304
+ meta.mtimeMs,
305
+ meta.size > PARTIAL_READ_SIZE,
306
+ tailInfo,
307
+ );
226
308
  } catch {
227
309
  return null;
228
310
  } finally {
package/src/search.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  import { fuzzyMatch } from "@earendil-works/pi-tui";
13
13
  import { canonicalizePath, type SessionHeader } from "./scanner.js";
14
14
 
15
+ export type PickerScope = "current" | "all";
16
+
15
17
  // Search query parsing
16
18
 
17
19
  export type SearchTokenKind = "fuzzy" | "phrase";
@@ -1,95 +0,0 @@
1
- import type { SessionHeader } from "./scanner.js";
2
-
3
- export type PickerScope = "current" | "all";
4
-
5
- export interface PickerState {
6
- scope: PickerScope;
7
- sessions: SessionHeader[];
8
- filteredSessions: SessionHeader[];
9
- selectedIndex: number;
10
- filterQuery: string;
11
- loadingDone: boolean;
12
- loadedCount: number;
13
- totalCount: number;
14
- }
15
-
16
- export function createInitialState(
17
- initialSessions: SessionHeader[],
18
- totalCount: number,
19
- loadingDone: boolean,
20
- ): PickerState {
21
- return {
22
- scope: "current",
23
- sessions: initialSessions,
24
- filteredSessions: initialSessions,
25
- selectedIndex: 0,
26
- filterQuery: "",
27
- loadingDone,
28
- loadedCount: initialSessions.length,
29
- totalCount,
30
- };
31
- }
32
-
33
- export function setScope(state: PickerState, scope: PickerScope): PickerState {
34
- return {
35
- ...state,
36
- scope,
37
- filterQuery: "",
38
- selectedIndex: 0,
39
- };
40
- }
41
-
42
- export function setSessions(
43
- state: PickerState,
44
- sessions: SessionHeader[],
45
- loadingDone: boolean,
46
- ): PickerState {
47
- const filtered = applyFilter(sessions, state.filterQuery);
48
- return {
49
- ...state,
50
- sessions,
51
- filteredSessions: filtered,
52
- loadingDone,
53
- loadedCount: sessions.length,
54
- selectedIndex: Math.min(state.selectedIndex, Math.max(0, filtered.length - 1)),
55
- };
56
- }
57
-
58
- export function setFilter(
59
- state: PickerState,
60
- query: string,
61
- ): PickerState {
62
- const filtered = applyFilter(state.sessions, query);
63
- return {
64
- ...state,
65
- filterQuery: query,
66
- filteredSessions: filtered,
67
- selectedIndex: Math.min(state.selectedIndex, Math.max(0, filtered.length - 1)),
68
- };
69
- }
70
-
71
- export function moveSelection(
72
- state: PickerState,
73
- delta: number,
74
- ): PickerState {
75
- const max = Math.max(0, state.filteredSessions.length - 1);
76
- return {
77
- ...state,
78
- selectedIndex: Math.max(0, Math.min(max, state.selectedIndex + delta)),
79
- };
80
- }
81
-
82
- function applyFilter(
83
- sessions: SessionHeader[],
84
- query: string,
85
- ): SessionHeader[] {
86
- if (!query) return sessions;
87
- const q = query.toLowerCase();
88
- return sessions.filter(
89
- (s) =>
90
- s.firstMessage.toLowerCase().includes(q) ||
91
- (s.name?.toLowerCase().includes(q) ?? false) ||
92
- s.cwd.toLowerCase().includes(q) ||
93
- s.id.toLowerCase().includes(q),
94
- );
95
- }