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 +3 -3
- package/fast-resume.ts +110 -16
- package/package.json +10 -10
- package/src/index.ts +1 -2
- package/src/scanner.ts +88 -6
- package/src/search.ts +2 -0
- package/src/picker-state.ts +0 -95
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
|
|
7
|
+
_Reads the head + tail of each file instead of the full JSONL — first results in **6ms**._
|
|
8
8
|
|
|
9
9
|
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
10
|
[](./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
|
|
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
|
|
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
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
this.
|
|
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
|
-
//
|
|
958
|
-
|
|
959
|
-
const
|
|
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.
|
|
4
|
-
"description": "Fast session picker for pi
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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";
|
package/src/picker-state.ts
DELETED
|
@@ -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
|
-
}
|