pi-fast-resume 1.1.0 → 1.1.2
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 +6 -0
- package/fast-resume.ts +110 -16
- package/package.json +3 -3
- package/src/index.ts +1 -2
- package/src/scanner.ts +4 -5
- package/src/search.ts +2 -0
- package/src/picker-state.ts +0 -95
package/README.md
CHANGED
|
@@ -68,6 +68,12 @@ Tested with **1,771 sessions, 1.46 GB** of JSONL data on disk.
|
|
|
68
68
|
|
|
69
69
|
**With `pi install`** (recommended):
|
|
70
70
|
|
|
71
|
+
```bash
|
|
72
|
+
pi install npm:pi-fast-resume
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or install from GitHub:
|
|
76
|
+
|
|
71
77
|
```bash
|
|
72
78
|
pi install https://github.com/monotykamary/pi-fast-resume
|
|
73
79
|
```
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fast-resume",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"lint:dead": "knip --no-gitignore"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
41
|
-
"@earendil-works/pi-tui": "0.
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.79.4",
|
|
41
|
+
"@earendil-works/pi-tui": "0.79.4",
|
|
42
42
|
"@types/node": "25.9.1",
|
|
43
43
|
"@vitest/coverage-v8": "4.1.7",
|
|
44
44
|
"knip": "6.14.1",
|
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
|
@@ -46,7 +46,7 @@ export function parseSessionFromBuffer(
|
|
|
46
46
|
|
|
47
47
|
let header: { id: string; timestamp: string; cwd?: string; parentSession?: string } | null = null;
|
|
48
48
|
let firstUserMsg = "";
|
|
49
|
-
let name
|
|
49
|
+
let name: string | undefined;
|
|
50
50
|
let msgCount = 0;
|
|
51
51
|
let lastActivityTime: number | undefined;
|
|
52
52
|
|
|
@@ -60,8 +60,7 @@ export function parseSessionFromBuffer(
|
|
|
60
60
|
continue;
|
|
61
61
|
}
|
|
62
62
|
if (entry.type === "session_info") {
|
|
63
|
-
|
|
64
|
-
if (n) name = n;
|
|
63
|
+
name = entry.name?.trim() || undefined;
|
|
65
64
|
}
|
|
66
65
|
if (entry.type === "message") {
|
|
67
66
|
msgCount++;
|
|
@@ -126,8 +125,8 @@ export function parseSessionFromBuffer(
|
|
|
126
125
|
created: new Date(header.timestamp),
|
|
127
126
|
modified,
|
|
128
127
|
messageCount: msgCount,
|
|
129
|
-
firstMessage: firstUserMsg || "",
|
|
130
|
-
name
|
|
128
|
+
firstMessage: firstUserMsg || "(no messages)",
|
|
129
|
+
name,
|
|
131
130
|
};
|
|
132
131
|
}
|
|
133
132
|
|
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
|
-
}
|