@vanillagreen/pi-qol 2.2.1 → 2.3.4
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 +13 -0
- package/extensions/qol/compaction-handoff.ts +6 -19
- package/extensions/qol/compaction.ts +1 -1
- package/extensions/qol/constants.ts +12 -1
- package/extensions/qol/glyphs.ts +1 -1
- package/extensions/qol/images.ts +41 -6
- package/extensions/qol/notifications.ts +14 -1
- package/extensions/qol/package-config.ts +445 -0
- package/extensions/qol/session-search/cache.ts +175 -46
- package/extensions/qol/session-search/component.ts +104 -14
- package/extensions/qol/session-search/index.ts +28 -18
- package/extensions/qol/session-search/jsonl.ts +33 -1
- package/extensions/qol/session-search/regex-worker.mjs +12 -0
- package/extensions/qol/session-search/regex.ts +49 -0
- package/extensions/qol/session-search/search.ts +60 -166
- package/extensions/qol/session-search/types.ts +10 -0
- package/extensions/qol/settings.ts +5 -100
- package/extensions/qol.ts +50 -12
- package/package.json +4 -3
- package/scripts/lane-retention.ts +187 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -8,9 +8,13 @@ import {
|
|
|
8
8
|
DEFAULT_SESSION_SEARCH_SHORTCUT,
|
|
9
9
|
SESSION_SEARCH_PENDING_SYMBOL,
|
|
10
10
|
SESSION_SEARCH_STATUS_KEY,
|
|
11
|
+
SESSION_SEARCH_TEXT_MAX_CHARS,
|
|
12
|
+
SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION,
|
|
13
|
+
SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS,
|
|
11
14
|
} from "../constants.js";
|
|
12
|
-
import { expandHome, piSettingsPaths,
|
|
13
|
-
import {
|
|
15
|
+
import { expandHome, piSettingsPaths, readSettingsFiles } from "../package-config.js";
|
|
16
|
+
import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
|
|
17
|
+
import { forEachSessionJsonlLine, forEachSessionJsonlLineAsync } from "./jsonl.js";
|
|
14
18
|
import type {
|
|
15
19
|
QolSessionPaletteAction,
|
|
16
20
|
QolSessionSearchPendingMessage,
|
|
@@ -23,12 +27,34 @@ import type {
|
|
|
23
27
|
let qolSessionSearchCache: QolSessionSearchSession[] = [];
|
|
24
28
|
let qolSessionSearchLoadedAt = 0;
|
|
25
29
|
let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
|
|
30
|
+
let qolSessionSearchLoadController: AbortController | undefined;
|
|
31
|
+
let qolSessionSearchReleaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
/** Parsed user prompts per session path, oldest insertion first; at most
|
|
33
|
+
* SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS entries. */
|
|
26
34
|
const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
|
|
27
|
-
|
|
35
|
+
/** The resume or fork action the editor's `/search:resume-pending <id>` line
|
|
36
|
+
* names. Queuing another replaces it, since the editor holds one line. */
|
|
37
|
+
let qolSessionSearchPendingAction: { id: string; action: QolSessionPaletteAction } | undefined;
|
|
28
38
|
let qolSessionSearchPendingActionCounter = 0;
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
/** Hold `action` as the one pending action and return the id that claims it. */
|
|
41
|
+
export function queueSessionSearchPendingAction(action: QolSessionPaletteAction): string {
|
|
42
|
+
const id = `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
|
|
43
|
+
qolSessionSearchPendingAction = { id, action };
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Remove and return the pending action when `id` names it. */
|
|
48
|
+
export function takeSessionSearchPendingAction(id: string): QolSessionPaletteAction | undefined {
|
|
49
|
+
if (qolSessionSearchPendingAction?.id !== id) return undefined;
|
|
50
|
+
const { action } = qolSessionSearchPendingAction;
|
|
51
|
+
qolSessionSearchPendingAction = undefined;
|
|
52
|
+
return action;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop the pending action. Runs on session shutdown. */
|
|
56
|
+
export function clearSessionSearchPendingAction(): void {
|
|
57
|
+
qolSessionSearchPendingAction = undefined;
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
export function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
|
|
@@ -82,62 +108,136 @@ function configuredSessionDir(cwd: string): string | undefined {
|
|
|
82
108
|
const envDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
|
|
83
109
|
if (envDir) return resolveSettingsRelativePath(envDir, join(resolve(cwd), ".pi", "settings.json"));
|
|
84
110
|
let configured: string | undefined;
|
|
85
|
-
for (const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (typeof parsed?.sessionDir === "string" && parsed.sessionDir.trim()) {
|
|
90
|
-
configured = resolveSettingsRelativePath(parsed.sessionDir, settingsPath);
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore malformed optional settings.
|
|
94
|
-
}
|
|
111
|
+
for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
|
|
112
|
+
if (file.kind !== "parsed") continue;
|
|
113
|
+
const sessionDir = file.settings.sessionDir;
|
|
114
|
+
if (typeof sessionDir === "string" && sessionDir.trim()) configured = resolveSettingsRelativePath(sessionDir, file.path);
|
|
95
115
|
}
|
|
96
116
|
return configured;
|
|
97
117
|
}
|
|
98
118
|
|
|
99
|
-
|
|
100
|
-
|
|
119
|
+
/** Resolve a project once at index entry. A deleted directory keeps its
|
|
120
|
+
* absolute spelling; other filesystem failures remain visible. */
|
|
121
|
+
export async function canonicalPathForSessionSearch(path: string): Promise<string> {
|
|
101
122
|
try {
|
|
102
|
-
return
|
|
103
|
-
} catch {
|
|
104
|
-
return resolve(path);
|
|
123
|
+
return await realpath(path);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return resolve(path);
|
|
126
|
+
throw error;
|
|
105
127
|
}
|
|
106
128
|
}
|
|
107
129
|
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
/** Prepare prompts and canonical projects asynchronously. Both the shared
|
|
131
|
+
* cache and an overlay supplied with sessions use this index entry point. */
|
|
132
|
+
export async function prepareQolSessionSearchSessions(sessions: QolSessionSearchSession[], signal: AbortSignal): Promise<QolSessionSearchSession[]> {
|
|
133
|
+
const paths = new Map<string, string>();
|
|
134
|
+
let budget = SESSION_SEARCH_TEXT_MAX_CHARS;
|
|
135
|
+
const prepared: QolSessionSearchSession[] = [];
|
|
136
|
+
for (const session of [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime())) {
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
let canonicalCwd = session.canonicalCwd ?? paths.get(session.cwd);
|
|
139
|
+
if (canonicalCwd === undefined) {
|
|
140
|
+
canonicalCwd = session.cwd ? await canonicalPathForSessionSearch(session.cwd) : "";
|
|
141
|
+
paths.set(session.cwd, canonicalCwd);
|
|
142
|
+
}
|
|
143
|
+
const messages: QolSessionUserMessage[] = [];
|
|
144
|
+
let remaining = Math.min(SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION, budget);
|
|
145
|
+
const keep = (message: QolSessionUserMessage) => {
|
|
146
|
+
if (remaining <= 0) return;
|
|
147
|
+
const text = message.text.slice(0, remaining);
|
|
148
|
+
remaining -= text.length;
|
|
149
|
+
budget -= text.length;
|
|
150
|
+
messages.push({ ...message, text });
|
|
151
|
+
};
|
|
152
|
+
if (session.userMessages !== undefined) session.userMessages.forEach(keep);
|
|
153
|
+
else if (remaining > 0) {
|
|
154
|
+
try {
|
|
155
|
+
let index = 0;
|
|
156
|
+
await forEachSessionJsonlLineAsync(session.path, (line) => {
|
|
157
|
+
const message = userMessageFromLine(line, index + 1);
|
|
158
|
+
if (message) { index++; keep(message); }
|
|
159
|
+
}, signal);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
// Pi can list a session which is removed before its prompt read.
|
|
163
|
+
if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
prepared.push({ ...session, canonicalCwd, userMessages: messages });
|
|
167
|
+
}
|
|
168
|
+
signal.throwIfAborted();
|
|
169
|
+
return prepared;
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
|
|
113
173
|
return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
|
|
114
174
|
}
|
|
115
175
|
|
|
116
|
-
|
|
176
|
+
/** Cap the message text the index keeps: each session's text at the per-session
|
|
177
|
+
* limit, and the sum at the index limit, filled newest session first. */
|
|
178
|
+
export function capSessionSearchText(sessions: QolSessionSearchSession[]): QolSessionSearchSession[] {
|
|
179
|
+
let budget = SESSION_SEARCH_TEXT_MAX_CHARS;
|
|
180
|
+
const newestFirst = [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
181
|
+
for (const session of newestFirst) {
|
|
182
|
+
const kept = session.allMessagesText.slice(0, Math.min(SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION, budget));
|
|
183
|
+
budget -= kept.length;
|
|
184
|
+
session.allMessagesText = kept;
|
|
185
|
+
}
|
|
186
|
+
return sessions;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function loadQolSessionSearchSessions(ctx: ExtensionContext, signal: AbortSignal, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
|
|
117
190
|
const customSessionDir = configuredSessionDir(ctx.cwd);
|
|
118
191
|
const infos = customSessionDir
|
|
119
|
-
? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
|
|
120
|
-
: await SessionManager.listAll(onProgress);
|
|
121
|
-
|
|
192
|
+
? await SessionManager.list(ctx.cwd, customSessionDir, onProgress, signal)
|
|
193
|
+
: await SessionManager.listAll(onProgress, signal);
|
|
194
|
+
signal.throwIfAborted();
|
|
195
|
+
return prepareQolSessionSearchSessions(capSessionSearchText(infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined)), signal);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drop the loaded index and the parsed prompts. Runs when the index outlives
|
|
199
|
+
* its TTL and on session shutdown; the next search loads the index again. */
|
|
200
|
+
export function releaseQolSessionSearchCache(): void {
|
|
201
|
+
qolSessionSearchLoadController?.abort();
|
|
202
|
+
qolSessionSearchLoadController = undefined;
|
|
203
|
+
qolSessionSearchLoading = undefined;
|
|
204
|
+
if (qolSessionSearchReleaseTimer) clearTimeout(qolSessionSearchReleaseTimer);
|
|
205
|
+
qolSessionSearchReleaseTimer = undefined;
|
|
206
|
+
qolSessionSearchCache = [];
|
|
207
|
+
qolSessionSearchLoadedAt = 0;
|
|
208
|
+
qolSessionUserMessagesCache.clear();
|
|
122
209
|
}
|
|
123
210
|
|
|
124
211
|
export async function refreshQolSessionSearchCache(ctx: ExtensionContext, options?: { force?: boolean; quiet?: boolean }): Promise<QolSessionSearchSession[]> {
|
|
125
212
|
const ttlMs = Math.max(0, settingNumber("sessionSearch.cacheTtlSeconds", DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS, ctx.cwd) * 1000);
|
|
213
|
+
// A TTL of 0 keeps the index until session shutdown.
|
|
126
214
|
const fresh = qolSessionSearchCache.length > 0 && (ttlMs === 0 || Date.now() - qolSessionSearchLoadedAt < ttlMs);
|
|
127
215
|
if (!options?.force && fresh) return qolSessionSearchCache;
|
|
128
216
|
if (qolSessionSearchLoading) return qolSessionSearchLoading;
|
|
129
217
|
|
|
130
218
|
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, "Loading sessions...");
|
|
131
|
-
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
qolSessionSearchLoadController = controller;
|
|
221
|
+
qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, controller.signal, (loaded, total) => {
|
|
222
|
+
if (controller.signal.aborted) return;
|
|
132
223
|
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
|
|
133
224
|
}).then((sessions) => {
|
|
225
|
+
controller.signal.throwIfAborted();
|
|
226
|
+
qolSessionSearchLoadController = undefined;
|
|
227
|
+
releaseQolSessionSearchCache();
|
|
134
228
|
qolSessionSearchCache = sessions;
|
|
135
229
|
qolSessionSearchLoadedAt = Date.now();
|
|
136
|
-
|
|
230
|
+
if (ttlMs > 0) {
|
|
231
|
+
qolSessionSearchReleaseTimer = setTimeout(releaseQolSessionSearchCache, ttlMs);
|
|
232
|
+
qolSessionSearchReleaseTimer.unref?.();
|
|
233
|
+
}
|
|
137
234
|
return sessions;
|
|
138
235
|
}).finally(() => {
|
|
139
|
-
|
|
140
|
-
|
|
236
|
+
if (qolSessionSearchLoadController === controller || !controller.signal.aborted) {
|
|
237
|
+
qolSessionSearchLoadController = undefined;
|
|
238
|
+
qolSessionSearchLoading = undefined;
|
|
239
|
+
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
240
|
+
}
|
|
141
241
|
});
|
|
142
242
|
return qolSessionSearchLoading;
|
|
143
243
|
}
|
|
@@ -162,40 +262,69 @@ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
|
|
|
162
262
|
return undefined;
|
|
163
263
|
}
|
|
164
264
|
|
|
265
|
+
function userMessageFromLine(line: string, index: number, fullText = false): QolSessionUserMessage | undefined {
|
|
266
|
+
let entry: { type?: string; id?: unknown; parentId?: unknown; timestamp?: unknown; message?: { role?: string; content?: unknown; timestamp?: unknown } };
|
|
267
|
+
try { entry = JSON.parse(line); } catch { return undefined; }
|
|
268
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
269
|
+
if (message?.role !== "user") return undefined;
|
|
270
|
+
const content = messageContentText(message.content);
|
|
271
|
+
const text = fullText ? content : oneLine(content);
|
|
272
|
+
if (!text) return undefined;
|
|
273
|
+
return {
|
|
274
|
+
entryId: typeof entry.id === "string" ? entry.id : undefined,
|
|
275
|
+
index,
|
|
276
|
+
parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
|
|
277
|
+
text,
|
|
278
|
+
timestamp: sessionMessageTimestamp(entry, message),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
165
282
|
export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
|
|
166
283
|
const cached = qolSessionUserMessagesCache.get(sessionPath);
|
|
167
284
|
if (cached) return cached;
|
|
168
285
|
const messages: QolSessionUserMessage[] = [];
|
|
169
286
|
try {
|
|
170
287
|
forEachSessionJsonlLine(sessionPath, (line) => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
try { entry = JSON.parse(line); } catch { return; }
|
|
174
|
-
const message = entry?.type === "message" ? entry.message : undefined;
|
|
175
|
-
if (!message || message.role !== "user") return;
|
|
176
|
-
const text = oneLine(messageContentText(message.content));
|
|
177
|
-
if (!text) return;
|
|
178
|
-
messages.push({
|
|
179
|
-
entryId: typeof entry.id === "string" ? entry.id : undefined,
|
|
180
|
-
index: messages.length + 1,
|
|
181
|
-
parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
|
|
182
|
-
text,
|
|
183
|
-
timestamp: sessionMessageTimestamp(entry, message),
|
|
184
|
-
});
|
|
288
|
+
const message = userMessageFromLine(line, messages.length + 1);
|
|
289
|
+
if (message) messages.push(message);
|
|
185
290
|
});
|
|
186
291
|
} catch {
|
|
187
292
|
// Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
|
|
188
293
|
}
|
|
189
294
|
qolSessionUserMessagesCache.set(sessionPath, messages);
|
|
295
|
+
for (const oldest of qolSessionUserMessagesCache.keys()) {
|
|
296
|
+
if (qolSessionUserMessagesCache.size <= SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS) break;
|
|
297
|
+
qolSessionUserMessagesCache.delete(oldest);
|
|
298
|
+
}
|
|
190
299
|
return messages;
|
|
191
300
|
}
|
|
192
301
|
|
|
193
302
|
export function userMessagesForResult(result: QolSessionSearchResult): QolSessionUserMessage[] {
|
|
194
|
-
const messages = sessionUserMessages(result.path);
|
|
303
|
+
const messages = result.userMessages ?? sessionUserMessages(result.path);
|
|
195
304
|
if (messages.length > 0) return messages;
|
|
196
305
|
return [{ index: 1, text: oneLine(result.firstMessage || "No user messages") }];
|
|
197
306
|
}
|
|
198
307
|
|
|
308
|
+
/** Load the original Copy/Fork payload without retaining it in the index.
|
|
309
|
+
* SessionManager.open reads synchronously, so use Pi's documented JSONL
|
|
310
|
+
* format to stream to the selected entry instead. */
|
|
311
|
+
export async function sessionUserMessageForAction(result: QolSessionSearchResult, selected?: QolSessionUserMessage, signal = new AbortController().signal): Promise<QolSessionUserMessage> {
|
|
312
|
+
let found: QolSessionUserMessage | undefined;
|
|
313
|
+
let index = 0;
|
|
314
|
+
await forEachSessionJsonlLineAsync(result.path, (line) => {
|
|
315
|
+
const message = userMessageFromLine(line, index + 1, true);
|
|
316
|
+
if (!message) return;
|
|
317
|
+
index++;
|
|
318
|
+
if (selected?.entryId ? message.entryId === selected.entryId : message.index === (selected?.index ?? 1)) {
|
|
319
|
+
found = message;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}, signal, { maxLineChars: Infinity });
|
|
323
|
+
signal.throwIfAborted();
|
|
324
|
+
if (!found) throw new Error(`Session prompt not found: ${selected?.entryId ?? selected?.index ?? 1}`);
|
|
325
|
+
return found;
|
|
326
|
+
}
|
|
327
|
+
|
|
199
328
|
export function sessionUserPromptCount(session: QolSessionSearchSession): number {
|
|
200
329
|
const count = sessionUserMessages(session.path).length;
|
|
201
330
|
if (count > 0) return count;
|
|
@@ -5,17 +5,18 @@ import { SESSION_SEARCH_OVERLAY_HEIGHT_RATIO } from "../constants.js";
|
|
|
5
5
|
import { settingNumber } from "../settings.js";
|
|
6
6
|
import {
|
|
7
7
|
defaultSessionSearchScope,
|
|
8
|
+
canonicalPathForSessionSearch,
|
|
9
|
+
prepareQolSessionSearchSessions,
|
|
8
10
|
modelLabel,
|
|
9
11
|
sameModel,
|
|
10
|
-
sameSessionSearchProject,
|
|
11
12
|
sessionModelInfo,
|
|
12
13
|
sessionResumeTitle,
|
|
13
14
|
shortPathForUi,
|
|
14
15
|
userMessagesForResult,
|
|
16
|
+
sessionUserMessageForAction,
|
|
15
17
|
type QolModelInfo,
|
|
16
18
|
} from "./cache.js";
|
|
17
19
|
import {
|
|
18
|
-
buildPromptSnippet,
|
|
19
20
|
formatSessionSearchDate,
|
|
20
21
|
parseSessionSearchQuery,
|
|
21
22
|
promptRecencyTime,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
QolSessionMessagesState,
|
|
30
31
|
QolSessionPaletteAction,
|
|
31
32
|
QolSessionSearchHit,
|
|
33
|
+
QolSessionSearchLoad,
|
|
32
34
|
QolSessionSearchResult,
|
|
33
35
|
QolSessionSearchScope,
|
|
34
36
|
QolSessionSearchSession,
|
|
@@ -108,7 +110,8 @@ export class QolSessionSearchComponent {
|
|
|
108
110
|
private readonly done: (action: QolSessionPaletteAction) => void;
|
|
109
111
|
private readonly tui: { requestRender(): void; terminal?: { rows?: number } };
|
|
110
112
|
private readonly theme: Theme;
|
|
111
|
-
|
|
113
|
+
/** The index, while it loads, once loaded, or the load's failure. */
|
|
114
|
+
private sessions: QolSessionSearchLoad;
|
|
112
115
|
private readonly cwd: string;
|
|
113
116
|
private readonly currentModel: QolModelInfo | undefined;
|
|
114
117
|
private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" | "confirmModel" = "search";
|
|
@@ -119,18 +122,38 @@ export class QolSessionSearchComponent {
|
|
|
119
122
|
private contextConfirmState: QolSessionContextConfirmState | undefined;
|
|
120
123
|
private forkConfirmState: QolSessionForkConfirmState | undefined;
|
|
121
124
|
private confirmModelState: QolSessionConfirmModelState | undefined;
|
|
125
|
+
private readonly indexController = new AbortController();
|
|
126
|
+
private queryController: AbortController | undefined;
|
|
127
|
+
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
+
private currentSessions: QolSessionSearchSession[] = [];
|
|
129
|
+
private searchStatus: { status: "searching" | "action" } | { status: "ready" } | { status: "failed"; error: string } = { status: "ready" };
|
|
122
130
|
|
|
123
131
|
|
|
124
132
|
constructor(
|
|
125
133
|
done: (action: QolSessionPaletteAction) => void,
|
|
126
134
|
tui: { requestRender(): void; terminal?: { rows?: number } },
|
|
127
135
|
theme: Theme,
|
|
128
|
-
sessions:
|
|
136
|
+
sessions: QolSessionSearchLoad,
|
|
129
137
|
cwd: string,
|
|
130
138
|
initialQuery = "",
|
|
131
139
|
currentModel: QolModelInfo | undefined = undefined,
|
|
132
140
|
) {
|
|
133
|
-
this.done =
|
|
141
|
+
this.done = async (action) => {
|
|
142
|
+
try {
|
|
143
|
+
if (action.type === "copy" || action.type === "fork") {
|
|
144
|
+
this.screen = "search";
|
|
145
|
+
this.searchStatus = { status: "action" };
|
|
146
|
+
this.tui.requestRender();
|
|
147
|
+
action = { ...action, message: await sessionUserMessageForAction(action.result!, action.message, this.indexController.signal) };
|
|
148
|
+
}
|
|
149
|
+
if (this.indexController.signal.aborted) return;
|
|
150
|
+
this.dispose(); done(action);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (this.indexController.signal.aborted) return;
|
|
153
|
+
this.searchStatus = { status: "failed", error: `${action.type === "copy" ? "Copy" : "Fork"} failed: ${String(error)}` };
|
|
154
|
+
this.tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
134
157
|
this.tui = tui;
|
|
135
158
|
this.theme = theme;
|
|
136
159
|
this.sessions = sessions;
|
|
@@ -138,19 +161,55 @@ export class QolSessionSearchComponent {
|
|
|
138
161
|
this.currentModel = currentModel;
|
|
139
162
|
const query = initialQuery.trim();
|
|
140
163
|
const scope = defaultSessionSearchScope(cwd);
|
|
141
|
-
const scopedSessions = this.sessionsForScope(scope);
|
|
142
164
|
this.searchState = {
|
|
143
165
|
cursor: query.length,
|
|
144
166
|
query,
|
|
145
|
-
results:
|
|
167
|
+
results: [],
|
|
146
168
|
selected: 0,
|
|
147
169
|
scope,
|
|
148
|
-
total:
|
|
170
|
+
total: 0,
|
|
149
171
|
};
|
|
172
|
+
if (sessions.status === "ready") this.setSessions(sessions);
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
invalidate(): void {}
|
|
153
176
|
|
|
177
|
+
/** Pi disposes a completed custom overlay. Late loads cannot redraw it. */
|
|
178
|
+
dispose(): void {
|
|
179
|
+
this.indexController.abort();
|
|
180
|
+
this.queryController?.abort();
|
|
181
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
182
|
+
this.debounceTimer = undefined;
|
|
183
|
+
this.currentSessions = [];
|
|
184
|
+
this.sessions = { status: "loading" };
|
|
185
|
+
this.searchState.results = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Show the index once its load settles; the overlay opens before that. */
|
|
189
|
+
setSessions(sessions: QolSessionSearchLoad): void {
|
|
190
|
+
const signal = this.indexController.signal;
|
|
191
|
+
if (signal.aborted) return;
|
|
192
|
+
if (sessions.status !== "ready") {
|
|
193
|
+
this.sessions = sessions;
|
|
194
|
+
this.tui.requestRender();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.sessions = { status: "loading" };
|
|
198
|
+
void Promise.all([
|
|
199
|
+
prepareQolSessionSearchSessions(sessions.sessions, signal),
|
|
200
|
+
canonicalPathForSessionSearch(this.cwd),
|
|
201
|
+
]).then(([prepared, cwd]) => {
|
|
202
|
+
if (signal.aborted) return;
|
|
203
|
+
this.sessions = { status: "ready", sessions: prepared };
|
|
204
|
+
this.currentSessions = prepared.filter((session) => session.canonicalCwd === cwd);
|
|
205
|
+
this.updateResults(false);
|
|
206
|
+
}, (error: unknown) => {
|
|
207
|
+
if (signal.aborted) return;
|
|
208
|
+
this.sessions = { status: "failed", error: String(error) };
|
|
209
|
+
this.tui.requestRender();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
154
213
|
private maxOverlayRows(): number {
|
|
155
214
|
const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
|
|
156
215
|
const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
|
|
@@ -196,6 +255,7 @@ export class QolSessionSearchComponent {
|
|
|
196
255
|
}
|
|
197
256
|
|
|
198
257
|
handleInput(data: string): void {
|
|
258
|
+
if (this.searchStatus.status === "action") return matchesKey(data, "escape") ? this.done({ type: "cancel" }) : undefined;
|
|
199
259
|
if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
|
|
200
260
|
else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
|
|
201
261
|
else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
|
|
@@ -213,17 +273,35 @@ export class QolSessionSearchComponent {
|
|
|
213
273
|
}
|
|
214
274
|
|
|
215
275
|
private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
|
|
216
|
-
if (
|
|
217
|
-
|
|
276
|
+
if (this.sessions.status !== "ready") return [];
|
|
277
|
+
if (scope === "all") return this.sessions.sessions;
|
|
278
|
+
return this.currentSessions;
|
|
218
279
|
}
|
|
219
280
|
|
|
220
281
|
private updateResults(resetSelection = true): void {
|
|
282
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
283
|
+
this.debounceTimer = undefined;
|
|
284
|
+
this.queryController?.abort();
|
|
285
|
+
if (this.indexController.signal.aborted || this.sessions.status !== "ready") return;
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
this.queryController = controller;
|
|
221
288
|
const state = this.searchState;
|
|
222
289
|
const scopedSessions = this.sessionsForScope(state.scope);
|
|
223
|
-
state.results =
|
|
290
|
+
state.results = [];
|
|
291
|
+
this.searchStatus = { status: "searching" };
|
|
224
292
|
state.total = scopedSessions.length;
|
|
225
293
|
if (resetSelection) state.selected = 0;
|
|
226
|
-
this.
|
|
294
|
+
void searchQolSessionHits(scopedSessions, state.query, this.cwd, controller.signal).then((results) => {
|
|
295
|
+
if (controller.signal.aborted || this.indexController.signal.aborted) return;
|
|
296
|
+
state.results = results;
|
|
297
|
+
this.searchStatus = { status: "ready" };
|
|
298
|
+
this.clampSelection();
|
|
299
|
+
this.tui.requestRender();
|
|
300
|
+
}, (error: unknown) => {
|
|
301
|
+
if (controller.signal.aborted || this.indexController.signal.aborted) return;
|
|
302
|
+
this.searchStatus = { status: "failed", error: String(error) };
|
|
303
|
+
this.tui.requestRender();
|
|
304
|
+
});
|
|
227
305
|
}
|
|
228
306
|
|
|
229
307
|
private toggleScope(): void {
|
|
@@ -234,7 +312,11 @@ export class QolSessionSearchComponent {
|
|
|
234
312
|
private updateQuery(query: string, cursor: number): void {
|
|
235
313
|
this.searchState.query = query;
|
|
236
314
|
this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
|
|
237
|
-
this.
|
|
315
|
+
this.queryController?.abort();
|
|
316
|
+
this.searchState.results = [];
|
|
317
|
+
this.searchStatus = { status: "searching" };
|
|
318
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
319
|
+
this.debounceTimer = setTimeout(() => this.updateResults(true), 20);
|
|
238
320
|
}
|
|
239
321
|
|
|
240
322
|
private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
|
|
@@ -614,7 +696,15 @@ export class QolSessionSearchComponent {
|
|
|
614
696
|
if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
|
|
615
697
|
lines.push(divider());
|
|
616
698
|
|
|
617
|
-
if (
|
|
699
|
+
if (this.sessions.status === "loading") {
|
|
700
|
+
lines.push(row(muted("Loading sessions…")));
|
|
701
|
+
} else if (this.sessions.status === "failed") {
|
|
702
|
+
lines.push(row(muted(`Could not load sessions: ${this.sessions.error.split("\n")[0]}`)));
|
|
703
|
+
} else if (this.searchStatus.status === "failed") {
|
|
704
|
+
lines.push(row(muted(`Search failed: ${this.searchStatus.error.split("\n")[0]}`)));
|
|
705
|
+
} else if (this.searchStatus.status === "searching" || this.searchStatus.status === "action") {
|
|
706
|
+
lines.push(row(muted(this.searchStatus.status === "action" ? "Loading prompt… Esc cancel" : "Searching…")));
|
|
707
|
+
} else if (state.results.length === 0) {
|
|
618
708
|
lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
|
|
619
709
|
} else {
|
|
620
710
|
lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
|
|
@@ -5,9 +5,8 @@ import { settingNumber } from "../settings.js";
|
|
|
5
5
|
import { stringifyError } from "../util.js";
|
|
6
6
|
import {
|
|
7
7
|
getPendingSessionSearchMessage,
|
|
8
|
-
nextSessionSearchPendingActionId,
|
|
9
8
|
pinSessionModel,
|
|
10
|
-
|
|
9
|
+
queueSessionSearchPendingAction,
|
|
11
10
|
refreshQolSessionSearchCache,
|
|
12
11
|
sessionDisplayName,
|
|
13
12
|
setPendingSessionSearchMessage,
|
|
@@ -17,12 +16,14 @@ import {
|
|
|
17
16
|
buildSessionSearchContextMessageWithLoader,
|
|
18
17
|
injectSessionSearchContext,
|
|
19
18
|
} from "./context.js";
|
|
20
|
-
import type { QolSessionPaletteAction, QolSessionSearchResult } from "./types.js";
|
|
19
|
+
import type { QolSessionPaletteAction, QolSessionSearchLoad, QolSessionSearchResult } from "./types.js";
|
|
21
20
|
|
|
22
21
|
export {
|
|
23
|
-
|
|
22
|
+
clearSessionSearchPendingAction,
|
|
24
23
|
refreshQolSessionSearchCache,
|
|
24
|
+
releaseQolSessionSearchCache,
|
|
25
25
|
sessionSearchShortcut,
|
|
26
|
+
takeSessionSearchPendingAction,
|
|
26
27
|
} from "./cache.js";
|
|
27
28
|
export { renderSessionSearchContextMessage } from "./context.js";
|
|
28
29
|
|
|
@@ -31,8 +32,7 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
|
|
34
|
-
const id =
|
|
35
|
-
qolSessionSearchPendingActions.set(id, action);
|
|
35
|
+
const id = queueSessionSearchPendingAction(action);
|
|
36
36
|
ctx.ui.setEditorText(`/search:resume-pending ${id}`);
|
|
37
37
|
ctx.ui.notify(`${sessionDisplayName(action.result!)} — press enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
|
|
38
38
|
}
|
|
@@ -42,12 +42,12 @@ export async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: Extens
|
|
|
42
42
|
const commandCtx = asCommandContext(ctx);
|
|
43
43
|
if (typeof commandCtx.switchSession !== "function") return false;
|
|
44
44
|
const targetTitle = sessionDisplayName(action.result);
|
|
45
|
-
const selectedMessage = action.type === "fork" ? action.message : undefined;
|
|
46
45
|
const currentModel = action.keepCurrentModel ? ctx.model : undefined;
|
|
47
46
|
const currentThinking = action.keepCurrentModel && typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined;
|
|
48
|
-
if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
|
|
49
47
|
let replacementStarted = false;
|
|
50
48
|
try {
|
|
49
|
+
const selectedMessage = action.type === "fork" ? action.message : undefined;
|
|
50
|
+
if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
|
|
51
51
|
const result = await commandCtx.switchSession(action.result.path, {
|
|
52
52
|
withSession: async (replacementCtx: any) => {
|
|
53
53
|
replacementStarted = true;
|
|
@@ -68,7 +68,7 @@ export async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: Extens
|
|
|
68
68
|
});
|
|
69
69
|
if (result.cancelled) ctx.ui.notify(selectedMessage ? "Fork cancelled" : "Resume cancelled", "info");
|
|
70
70
|
} catch (error) {
|
|
71
|
-
if (!replacementStarted) ctx.ui.notify(`${
|
|
71
|
+
if (!replacementStarted) ctx.ui.notify(`${action.type === "fork" ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
|
|
72
72
|
}
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
@@ -121,13 +121,28 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
121
121
|
ctx.ui.notify("Session search requires interactive UI", "warning");
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// The overlay opens at once and fills in when the index is loaded: a
|
|
125
|
+
// released index takes a full read of every session file to load again.
|
|
126
|
+
const loading: Promise<QolSessionSearchLoad> = refreshQolSessionSearchCache(ctx).then(
|
|
127
|
+
(sessions) => ({ status: "ready", sessions }),
|
|
128
|
+
(error) => ({ status: "failed", error: stringifyError(error) }),
|
|
129
|
+
);
|
|
125
130
|
const releaseModalLock = acquirekendexModalLock();
|
|
126
131
|
let action: QolSessionPaletteAction | undefined;
|
|
132
|
+
let component: QolSessionSearchComponent | undefined;
|
|
127
133
|
try {
|
|
128
134
|
const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
129
|
-
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) =>
|
|
130
|
-
new QolSessionSearchComponent(
|
|
135
|
+
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) => {
|
|
136
|
+
component = new QolSessionSearchComponent((selected) => {
|
|
137
|
+
if (selected.type === "copy") {
|
|
138
|
+
ctx.ui.setEditorText(selected.message!.text);
|
|
139
|
+
ctx.ui.notify("Copied selected prompt into the editor", "info");
|
|
140
|
+
}
|
|
141
|
+
done(selected);
|
|
142
|
+
}, tui, theme, { status: "loading" }, ctx.cwd, initialQuery, currentModel);
|
|
143
|
+
void loading.then((load) => component?.setSessions(load));
|
|
144
|
+
return component;
|
|
145
|
+
}, {
|
|
131
146
|
overlay: true,
|
|
132
147
|
overlayOptions: {
|
|
133
148
|
anchor: "center",
|
|
@@ -136,6 +151,7 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
136
151
|
},
|
|
137
152
|
});
|
|
138
153
|
} finally {
|
|
154
|
+
component?.dispose();
|
|
139
155
|
releaseModalLock();
|
|
140
156
|
}
|
|
141
157
|
if (!action || action.type === "cancel" || !action.result) return;
|
|
@@ -143,12 +159,6 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
143
159
|
if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) queueSessionSearchCommandAction(ctx, action);
|
|
144
160
|
return;
|
|
145
161
|
}
|
|
146
|
-
if (action.type === "copy") {
|
|
147
|
-
const text = action.message?.text || action.result.firstMessage;
|
|
148
|
-
ctx.ui.setEditorText(text);
|
|
149
|
-
ctx.ui.notify("Copied selected prompt into the editor", "info");
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
162
|
if (action.type === "summarize") {
|
|
153
163
|
try {
|
|
154
164
|
await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
|