@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.
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
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, settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
13
- import { forEachSessionJsonlLine } from "./jsonl.js";
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
- export const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
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
- export function nextSessionSearchPendingActionId(): string {
31
- return `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
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 settingsPath of piSettingsPaths(cwd)) {
86
- if (!existsSync(settingsPath)) continue;
87
- try {
88
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
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
- function canonicalPathForSessionSearch(path: string | undefined): string | undefined {
100
- if (!path) return undefined;
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 realpathSync.native(path);
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
- export function sameSessionSearchProject(sessionCwd: string | undefined, cwd: string): boolean {
109
- return canonicalPathForSessionSearch(sessionCwd) === canonicalPathForSessionSearch(cwd);
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
- async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
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
- return infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined);
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
- qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, (loaded, total) => {
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
- qolSessionUserMessagesCache.clear();
230
+ if (ttlMs > 0) {
231
+ qolSessionSearchReleaseTimer = setTimeout(releaseQolSessionSearchCache, ttlMs);
232
+ qolSessionSearchReleaseTimer.unref?.();
233
+ }
137
234
  return sessions;
138
235
  }).finally(() => {
139
- qolSessionSearchLoading = undefined;
140
- if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
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
- if (!line.trim()) return;
172
- let entry: any;
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
- private readonly sessions: QolSessionSearchSession[];
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: QolSessionSearchSession[],
136
+ sessions: QolSessionSearchLoad,
129
137
  cwd: string,
130
138
  initialQuery = "",
131
139
  currentModel: QolModelInfo | undefined = undefined,
132
140
  ) {
133
- this.done = 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: searchQolSessionHits(scopedSessions, query, cwd),
167
+ results: [],
146
168
  selected: 0,
147
169
  scope,
148
- total: scopedSessions.length,
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 (scope === "all") return this.sessions;
217
- return this.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
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 = searchQolSessionHits(scopedSessions, state.query, this.cwd);
290
+ state.results = [];
291
+ this.searchStatus = { status: "searching" };
224
292
  state.total = scopedSessions.length;
225
293
  if (resetSelection) state.selected = 0;
226
- this.clampSelection();
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.updateResults(true);
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 (state.results.length === 0) {
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
- qolSessionSearchPendingActions,
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
- qolSessionSearchPendingActions,
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 = nextSessionSearchPendingActionId();
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(`${selectedMessage ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
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
- const sessions = await refreshQolSessionSearchCache(ctx);
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(done, tui, theme, sessions, ctx.cwd, initialQuery, currentModel), {
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);