pi-fast-resume 1.0.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/src/search.ts ADDED
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Session search logic — ported from pi-core's session-selector-search.js.
3
+ *
4
+ * Supports three query modes:
5
+ * - Fuzzy tokens (default): each token fuzzy-matches against session text
6
+ * - Regex: re:<pattern> — RegExp search against session text
7
+ * - Exact phrase: "phrase" — case-insensitive substring match
8
+ *
9
+ * Also provides tree-building utilities for threaded sort mode.
10
+ */
11
+
12
+ import { fuzzyMatch } from "@earendil-works/pi-tui";
13
+ import { canonicalizePath, type SessionHeader } from "./scanner.js";
14
+
15
+ // Search query parsing
16
+
17
+ export type SearchTokenKind = "fuzzy" | "phrase";
18
+
19
+ export interface SearchToken {
20
+ kind: SearchTokenKind;
21
+ value: string;
22
+ }
23
+
24
+ export interface ParsedSearch {
25
+ mode: "tokens" | "regex";
26
+ tokens: SearchToken[];
27
+ regex: RegExp | null;
28
+ error?: string;
29
+ }
30
+
31
+ function normalizeWhitespaceLower(text: string): string {
32
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
33
+ }
34
+
35
+ /**
36
+ * Build the searchable text for a session.
37
+ *
38
+ * IMPORTANT LIMITATION: pi-core's /resume builds `allMessagesText` from the
39
+ * full file, so search matches against every message in the session. We only
40
+ * read the first 16KB, so we search against `firstMessage` instead. This means
41
+ * queries like `fix oauth` won't match a session where that phrase appears in
42
+ * the 5th message but not the 1st. Name, id, and cwd matches are unaffected.
43
+ *
44
+ * See README "Known Limitations" section for the user-facing explanation.
45
+ */
46
+ function getSessionSearchText(session: SessionHeader): string {
47
+ return `${session.id} ${session.name ?? ""} ${session.firstMessage} ${session.cwd}`;
48
+ }
49
+
50
+ export function parseSearchQuery(query: string): ParsedSearch {
51
+ const trimmed = query.trim();
52
+ if (!trimmed) {
53
+ return { mode: "tokens", tokens: [], regex: null };
54
+ }
55
+
56
+ // Regex mode: re:<pattern>
57
+ if (trimmed.startsWith("re:")) {
58
+ const pattern = trimmed.slice(3).trim();
59
+ if (!pattern) {
60
+ return { mode: "regex", tokens: [], regex: null, error: "Empty regex" };
61
+ }
62
+ try {
63
+ return { mode: "regex", tokens: [], regex: new RegExp(pattern, "i") };
64
+ } catch (err) {
65
+ const msg = err instanceof Error ? err.message : String(err);
66
+ return { mode: "regex", tokens: [], regex: null, error: msg };
67
+ }
68
+ }
69
+
70
+ // Token mode with quote support.
71
+ // Example: foo "node cve" bar
72
+ const tokens: SearchToken[] = [];
73
+ let buf = "";
74
+ let inQuote = false;
75
+ let hadUnclosedQuote = false;
76
+
77
+ const flush = (kind: SearchTokenKind) => {
78
+ const v = buf.trim();
79
+ buf = "";
80
+ if (!v) return;
81
+ tokens.push({ kind, value: v });
82
+ };
83
+
84
+ for (let i = 0; i < trimmed.length; i++) {
85
+ const ch = trimmed[i];
86
+ if (ch === '"') {
87
+ if (inQuote) {
88
+ flush("phrase");
89
+ inQuote = false;
90
+ } else {
91
+ flush("fuzzy");
92
+ inQuote = true;
93
+ }
94
+ continue;
95
+ }
96
+ if (!inQuote && /\s/.test(ch)) {
97
+ flush("fuzzy");
98
+ continue;
99
+ }
100
+ buf += ch;
101
+ }
102
+
103
+ if (inQuote) {
104
+ hadUnclosedQuote = true;
105
+ }
106
+
107
+ // If quotes were unbalanced, fall back to plain whitespace tokenization.
108
+ if (hadUnclosedQuote) {
109
+ return {
110
+ mode: "tokens",
111
+ tokens: trimmed
112
+ .split(/\s+/)
113
+ .map((t) => t.trim())
114
+ .filter((t) => t.length > 0)
115
+ .map((t) => ({ kind: "fuzzy" as const, value: t })),
116
+ regex: null,
117
+ };
118
+ }
119
+
120
+ flush(inQuote ? "phrase" : "fuzzy");
121
+ return { mode: "tokens", tokens, regex: null };
122
+ }
123
+
124
+ export interface MatchResult {
125
+ matches: boolean;
126
+ score: number;
127
+ }
128
+
129
+ export function matchSession(session: SessionHeader, parsed: ParsedSearch): MatchResult {
130
+ const text = getSessionSearchText(session);
131
+
132
+ if (parsed.mode === "regex") {
133
+ if (!parsed.regex) {
134
+ return { matches: false, score: 0 };
135
+ }
136
+ const idx = text.search(parsed.regex);
137
+ if (idx < 0) return { matches: false, score: 0 };
138
+ return { matches: true, score: idx * 0.1 };
139
+ }
140
+
141
+ if (parsed.tokens.length === 0) {
142
+ return { matches: true, score: 0 };
143
+ }
144
+
145
+ let totalScore = 0;
146
+ let normalizedText: string | null = null;
147
+
148
+ for (const token of parsed.tokens) {
149
+ if (token.kind === "phrase") {
150
+ if (normalizedText === null) {
151
+ normalizedText = normalizeWhitespaceLower(text);
152
+ }
153
+ const phrase = normalizeWhitespaceLower(token.value);
154
+ if (!phrase) continue;
155
+ const idx = normalizedText.indexOf(phrase);
156
+ if (idx < 0) return { matches: false, score: 0 };
157
+ totalScore += idx * 0.1;
158
+ continue;
159
+ }
160
+
161
+ const m = fuzzyMatch(token.value, text);
162
+ if (!m.matches) return { matches: false, score: 0 };
163
+ totalScore += m.score;
164
+ }
165
+
166
+ return { matches: true, score: totalScore };
167
+ }
168
+
169
+ export function hasSessionName(session: SessionHeader): boolean {
170
+ return Boolean(session.name?.trim());
171
+ }
172
+
173
+ export type SortMode = "threaded" | "recent" | "relevance";
174
+ export type NameFilter = "all" | "named";
175
+
176
+ /**
177
+ * Filter and sort sessions — matches pi-core's filterAndSortSessions behavior:
178
+ * - "recent" mode: filter only, keep incoming order (mtime desc)
179
+ * - "relevance" mode: sort by match score, tie-break by modified desc
180
+ * - "threaded" mode with query: same as relevance
181
+ * - No query: return nameFiltered as-is (tree mode is handled separately)
182
+ */
183
+ export function filterAndSortSessions(
184
+ sessions: SessionHeader[],
185
+ query: string,
186
+ sortMode: SortMode,
187
+ nameFilter: NameFilter = "all",
188
+ ): SessionHeader[] {
189
+ const nameFiltered = nameFilter === "all"
190
+ ? sessions
191
+ : sessions.filter((s) => hasSessionName(s));
192
+
193
+ const trimmed = query.trim();
194
+ if (!trimmed) return nameFiltered;
195
+
196
+ const parsed = parseSearchQuery(query);
197
+ if (parsed.error) return [];
198
+
199
+ // Recent mode: filter only, keep incoming order.
200
+ if (sortMode === "recent") {
201
+ const filtered: SessionHeader[] = [];
202
+ for (const s of nameFiltered) {
203
+ const res = matchSession(s, parsed);
204
+ if (res.matches) filtered.push(s);
205
+ }
206
+ return filtered;
207
+ }
208
+
209
+ // Relevance / threaded-with-query: sort by score, tie-break by modified desc.
210
+ const scored: { session: SessionHeader; score: number }[] = [];
211
+ for (const s of nameFiltered) {
212
+ const res = matchSession(s, parsed);
213
+ if (!res.matches) continue;
214
+ scored.push({ session: s, score: res.score });
215
+ }
216
+ scored.sort((a, b) => {
217
+ if (a.score !== b.score) return a.score - b.score;
218
+ return b.session.modified.getTime() - a.session.modified.getTime();
219
+ });
220
+ return scored.map((r) => r.session);
221
+ }
222
+
223
+ // Tree structure for threaded view
224
+
225
+ export interface SessionTreeNode {
226
+ session: SessionHeader;
227
+ children: SessionTreeNode[];
228
+ }
229
+
230
+ export interface FlatSessionNode {
231
+ session: SessionHeader;
232
+ depth: number;
233
+ isLast: boolean;
234
+ ancestorContinues: boolean[];
235
+ }
236
+
237
+ /**
238
+ * Build a tree structure from sessions based on parentSessionPath.
239
+ * Returns root nodes sorted by modified date (descending).
240
+ */
241
+ export function buildSessionTree(sessions: SessionHeader[]): SessionTreeNode[] {
242
+ const byPath = new Map<string, SessionTreeNode>();
243
+ for (const session of sessions) {
244
+ const sessionPath = canonicalizePath(session.path) ?? session.path;
245
+ byPath.set(sessionPath, { session, children: [] });
246
+ }
247
+
248
+ const roots: SessionTreeNode[] = [];
249
+ for (const session of sessions) {
250
+ const sessionPath = canonicalizePath(session.path) ?? session.path;
251
+ const node = byPath.get(sessionPath);
252
+ if (!node) continue;
253
+
254
+ const parentPath = session.parentSessionPath
255
+ ? canonicalizePath(session.parentSessionPath)
256
+ : undefined;
257
+
258
+ if (parentPath && byPath.has(parentPath)) {
259
+ byPath.get(parentPath)!.children.push(node);
260
+ } else {
261
+ roots.push(node);
262
+ }
263
+ }
264
+
265
+ // Sort children and roots by modified date (descending)
266
+ const sortNodes = (nodes: SessionTreeNode[]) => {
267
+ nodes.sort((a, b) => b.session.modified.getTime() - a.session.modified.getTime());
268
+ for (const node of nodes) {
269
+ sortNodes(node.children);
270
+ }
271
+ };
272
+ sortNodes(roots);
273
+
274
+ return roots;
275
+ }
276
+
277
+ /**
278
+ * Flatten tree into display list with tree structure metadata.
279
+ */
280
+ export function flattenSessionTree(roots: SessionTreeNode[]): FlatSessionNode[] {
281
+ const result: FlatSessionNode[] = [];
282
+
283
+ const walk = (node: SessionTreeNode, depth: number, ancestorContinues: boolean[], isLast: boolean) => {
284
+ result.push({ session: node.session, depth, isLast, ancestorContinues });
285
+ for (let i = 0; i < node.children.length; i++) {
286
+ const childIsLast = i === node.children.length - 1;
287
+ // Only show continuation line for non-root ancestors
288
+ const continues = depth > 0 ? !isLast : false;
289
+ walk(node.children[i]!, depth + 1, [...ancestorContinues, continues], childIsLast);
290
+ }
291
+ };
292
+
293
+ for (let i = 0; i < roots.length; i++) {
294
+ walk(roots[i]!, 0, [], i === roots.length - 1);
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * Build the tree prefix string (├─ └─ │ characters) for a flat node.
302
+ */
303
+ export function buildTreePrefix(node: FlatSessionNode): string {
304
+ if (node.depth === 0) {
305
+ return "";
306
+ }
307
+ const parts = node.ancestorContinues.map((continues) => (continues ? "│ " : " "));
308
+ const branch = node.isLast ? "└─ " : "├─ ";
309
+ return parts.join("") + branch;
310
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["__tests__/**/*.test.ts"],
8
+ exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
9
+ coverage: {
10
+ provider: "v8",
11
+ reporter: ["text", "json", "html"],
12
+ exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
13
+ },
14
+ },
15
+ });