@vanillagreen/pi-qol 2.3.2 → 2.4.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/README.md CHANGED
@@ -33,8 +33,10 @@ The extension reads your enabled features when Pi starts. It adds their editor c
33
33
 
34
34
  - A session without a UI loads no session-search index. With a UI, the index is loaded shortly after start and kept for `sessionSearch.cacheTtlSeconds` (default 300 seconds; 0 keeps it until the session ends). A search after the index was released opens at once and fills in when the index has loaded again.
35
35
  - The index keeps at most 32,768 characters of message text per session and 8,388,608 characters in total, newest session first. A session past the total is searched by name, path and first prompt only.
36
+ - Prepared user prompts have the same per-session and total character limits, measured in decoded UTF-16 code units. Indexing skips JSONL records above 2,097,152 decoded UTF-16 code units, not bytes. Copy and Fork read the complete selected prompt separately. Search waits 20 ms after the last edit and cancels an older query. Regex matching runs in a disposable worker with a 25 ms deadline and a separate 1 second startup deadline. A deadline error appears in the search popup.
37
+ - Submitted image paths and existing clipboard images share a 20 MiB raw-byte limit. The extension checks all file sizes before reading any image. A refusal blocks submission and reports an error. It restores the rejected prompt only if the editor is empty; a newer draft stays unchanged. Print and JSON callers receive the diagnostic on stderr.
36
38
  - Where Pi cannot switch sessions directly, a chosen resume or fork waits as one `/search:resume-pending` command in the editor. Only the latest one is kept, and it is dropped when the session ends.
37
- - Parsed prompts are kept for at most 64 sessions, and finished thinking times for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
39
+ - The separate on-demand parsed-prompt cache keeps at most 64 sessions. The prepared search index uses the character limits above, not this session count. Finished thinking times are kept for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
38
40
 
39
41
  ## Saved files
40
42
 
@@ -49,14 +51,14 @@ Open `/extensions:settings`; settings appear under the **QOL** tab. Project sett
49
51
  - `enabled`: package toggle for everything below.
50
52
  - Statusline and editor: `statusline.enabled`, `statusline.showProvider`, `statusline.showAccount`, `replaceFooter`, `compactPrompt`, `showSessionNameTitle`, `showSessionNameWindow`, `inputBottomPaddingLines`, `gitRefreshTimeoutMs`, `showDirtyMarker`, `newlineOnShiftEnter`, `newlineFallbackKey`, `pendingQueue.asciiGreen`, `showImageChips`, `showAttachmentCountInStatus`.
51
53
  - Commands: `enableSessionNameCommand`, `enableHandoffCommand`, `enableScheduleCommand`, `enableContextCommand`, `handoffReviewPrompt`.
52
- - Session naming: `sessionAutoRename.*` (model, fallback model, deterministic fallback, prefix, prompt, limits, notify, debug).
54
+ - Session naming: `sessionAutoRename.*` (model, fallback model, deterministic fallback, prefix, prompt, limits, notify, debug). `sessionAutoRename.maxTokens` also counts the naming model's reasoning tokens: with `gpt-5.4-mini` naming 40 issue-sized first messages, 22 got a title at 96 and 29 at 128, the default. `sessionAutoRename.maxNameChars` stays 80 because no title in those runs was longer than 36 characters, so 70 and 80 give the same names.
53
55
  - Session search: `sessionSearch.*` (shortcut, result and row limits, snippets, overlay width, cache TTL, summary model and limits).
54
56
  - Rate limits: `rateLimitAutoResume.*`.
55
57
  - Permission gate: `permissionGate.enabled`, `permissionGate.commands` (comma-separated literal fragments or `/regex/flags`), `permissionGate.previewLines`, `permissionGate.previewChars`.
56
58
  - Notifications: `notification.*` (triggers, channels, tmux options, protocol, cooldown, title and body).
57
- - Compaction and budget guard: `compaction.*` (custom summaries, model, profile, remote endpoint, branch summaries, idle trigger, budget guard thresholds, chunk input cap, handoff artifact, transcript-risk budget).
59
+ - Compaction and budget guard: `compaction.*` (custom summaries, model, profile, remote endpoint, branch summaries, idle trigger, budget guard thresholds, chunk input cap, handoff artifact, transcript-risk budget). `compaction.maxTokens` stays 8192, the cap on the budget-guard, custom and branch summaries. The fleet value considered was 10192, but no comparison was possible: the fleet's settings, Pi's `compaction.enabled` false with `compaction.customEnabled` and `compaction.branchSummaryEnabled` off, run none of these summaries.
58
60
  - Pi's own `compaction.enabled` (the top-level `compaction` object in the same settings files, not a QOL key) turns off every automatic compaction: when it is `false`, neither the budget guard nor the idle trigger starts one, and `/qol` reports both as disabled by that key. A manual `/compact` still runs. While it is `true` or absent, both keep the defaults and thresholds above: the budget guard on at `compaction.budgetPercent` 85, the idle trigger off until `compaction.idleEnabled` is set.
59
- - Thinking: `thinkingLabel.text`, `thinkingTimer.enabled`, `workingIndicator.mode` (`static` if the animated indicator flashes).
61
+ - Thinking: `thinkingLabel.text`, `thinkingTimer.enabled`, `workingIndicator.mode`. `workingIndicator.mode` defaults to `static`, a dot with no animation timer; `animated` restores Pi's spinner. In a scripted 100-second streaming session on Pi 1.0.1, `animated` drew 2050 and 2080 frames with 4.9 s of CPU, and `static` 867 and 873 frames with 3.2 to 3.4 s. `thinkingTimer.enabled` stays on because turning it off in the same session changed neither the frames (2056 and 2066) nor the CPU (4.8 to 4.9 s).
60
62
 
61
63
  **Show model provider** displays a readable provider name before the model, such as `Copilot / GPT 6 Astra`. It is on by default; changes apply on the next render without reloading. Disabling the QOL statusline leaves Pi's standalone working indicator in place.
62
64
 
@@ -92,7 +92,7 @@ export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
92
92
  export const DEFAULT_AUTO_RENAME_FALLBACK_MODEL = "current";
93
93
  export const DEFAULT_AUTO_RENAME_INPUT_CHARS = 2000;
94
94
  export const DEFAULT_AUTO_RENAME_NAME_CHARS = 80;
95
- export const DEFAULT_AUTO_RENAME_MAX_TOKENS = 96;
95
+ export const DEFAULT_AUTO_RENAME_MAX_TOKENS = 128;
96
96
  export const DEFAULT_AUTO_RENAME_TIMEOUT_MS = 12_000;
97
97
  export const DEFAULT_INPUT_BOTTOM_PADDING_LINES = 0;
98
98
 
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
+ import { open } from "node:fs/promises";
2
3
  import { basename, resolve } from "node:path";
3
4
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
5
  import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
@@ -42,11 +43,44 @@ function mimeTypeForPath(path: string): string {
42
43
  return "image/png";
43
44
  }
44
45
 
45
- export function imageContentForPath(path: string): { type: "image"; data: string; mimeType: string } | undefined {
46
+ /** Submitted image paths and clipboard images share this raw-byte budget. */
47
+ export const SUBMITTED_IMAGES_MAX_BYTES = 20 * 1024 * 1024;
48
+
49
+ /** Stat every open file before reading any of them. Read only the observed
50
+ * bytes and one growth probe, so a growing file cannot bypass the budget. */
51
+ export async function imageContentsForPaths(paths: string[], existing: Array<{ data: string }> = []): Promise<Array<{ type: "image"; data: string; mimeType: string }>> {
52
+ const files: Array<{ path: string; handle: Awaited<ReturnType<typeof open>>; size: number }> = [];
53
+ let total = existing.reduce((sum, image) => sum + Buffer.byteLength(image.data, "base64"), 0);
54
+ const checkSize = () => {
55
+ if (total > SUBMITTED_IMAGES_MAX_BYTES) throw new Error(`IMAGE_SIZE_LIMIT: ${total} bytes exceeds ${SUBMITTED_IMAGES_MAX_BYTES}`);
56
+ };
46
57
  try {
47
- return { data: readFileSync(path).toString("base64"), mimeType: mimeTypeForPath(path), type: "image" };
48
- } catch {
49
- return undefined;
58
+ checkSize();
59
+ for (const path of paths) {
60
+ const handle = await open(path, "r");
61
+ const file = { path, handle, size: 0 };
62
+ files.push(file);
63
+ const stat = await handle.stat();
64
+ if (!stat.isFile()) throw new Error(`IMAGE_NOT_FILE: ${path}`);
65
+ file.size = stat.size;
66
+ total += stat.size;
67
+ checkSize();
68
+ }
69
+ const buffers: Buffer[] = [];
70
+ for (const file of files) {
71
+ const buffer = Buffer.alloc(file.size + 1);
72
+ let offset = 0;
73
+ while (offset < buffer.length) {
74
+ const { bytesRead } = await file.handle.read(buffer, offset, buffer.length - offset, offset);
75
+ if (bytesRead === 0) break;
76
+ offset += bytesRead;
77
+ }
78
+ if (offset > file.size) throw new Error(`IMAGE_SIZE_CHANGED: ${file.path}`);
79
+ buffers.push(buffer.subarray(0, offset));
80
+ }
81
+ return files.map((file, index) => ({ type: "image", data: buffers[index]!.toString("base64"), mimeType: mimeTypeForPath(file.path) }));
82
+ } finally {
83
+ await Promise.all(files.map((file) => file.handle.close()));
50
84
  }
51
85
  }
52
86
 
@@ -1,4 +1,4 @@
1
- import { 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";
@@ -14,7 +14,7 @@ import {
14
14
  } from "../constants.js";
15
15
  import { expandHome, piSettingsPaths, readSettingsFiles } from "../package-config.js";
16
16
  import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
17
- import { forEachSessionJsonlLine } from "./jsonl.js";
17
+ import { forEachSessionJsonlLine, forEachSessionJsonlLineAsync } from "./jsonl.js";
18
18
  import type {
19
19
  QolSessionPaletteAction,
20
20
  QolSessionSearchPendingMessage,
@@ -27,6 +27,7 @@ import type {
27
27
  let qolSessionSearchCache: QolSessionSearchSession[] = [];
28
28
  let qolSessionSearchLoadedAt = 0;
29
29
  let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
30
+ let qolSessionSearchLoadController: AbortController | undefined;
30
31
  let qolSessionSearchReleaseTimer: ReturnType<typeof setTimeout> | undefined;
31
32
  /** Parsed user prompts per session path, oldest insertion first; at most
32
33
  * SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS entries. */
@@ -115,17 +116,57 @@ function configuredSessionDir(cwd: string): string | undefined {
115
116
  return configured;
116
117
  }
117
118
 
118
- function canonicalPathForSessionSearch(path: string | undefined): string | undefined {
119
- 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> {
120
122
  try {
121
- return realpathSync.native(path);
122
- } catch {
123
- 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;
124
127
  }
125
128
  }
126
129
 
127
- export function sameSessionSearchProject(sessionCwd: string | undefined, cwd: string): boolean {
128
- 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;
129
170
  }
130
171
 
131
172
  export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
@@ -145,17 +186,21 @@ export function capSessionSearchText(sessions: QolSessionSearchSession[]): QolSe
145
186
  return sessions;
146
187
  }
147
188
 
148
- async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
189
+ async function loadQolSessionSearchSessions(ctx: ExtensionContext, signal: AbortSignal, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
149
190
  const customSessionDir = configuredSessionDir(ctx.cwd);
150
191
  const infos = customSessionDir
151
- ? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
152
- : await SessionManager.listAll(onProgress);
153
- return capSessionSearchText(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);
154
196
  }
155
197
 
156
198
  /** Drop the loaded index and the parsed prompts. Runs when the index outlives
157
199
  * its TTL and on session shutdown; the next search loads the index again. */
158
200
  export function releaseQolSessionSearchCache(): void {
201
+ qolSessionSearchLoadController?.abort();
202
+ qolSessionSearchLoadController = undefined;
203
+ qolSessionSearchLoading = undefined;
159
204
  if (qolSessionSearchReleaseTimer) clearTimeout(qolSessionSearchReleaseTimer);
160
205
  qolSessionSearchReleaseTimer = undefined;
161
206
  qolSessionSearchCache = [];
@@ -171,9 +216,14 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
171
216
  if (qolSessionSearchLoading) return qolSessionSearchLoading;
172
217
 
173
218
  if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, "Loading sessions...");
174
- 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;
175
223
  if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
176
224
  }).then((sessions) => {
225
+ controller.signal.throwIfAborted();
226
+ qolSessionSearchLoadController = undefined;
177
227
  releaseQolSessionSearchCache();
178
228
  qolSessionSearchCache = sessions;
179
229
  qolSessionSearchLoadedAt = Date.now();
@@ -183,8 +233,11 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
183
233
  }
184
234
  return sessions;
185
235
  }).finally(() => {
186
- qolSessionSearchLoading = undefined;
187
- 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
+ }
188
241
  });
189
242
  return qolSessionSearchLoading;
190
243
  }
@@ -209,26 +262,31 @@ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
209
262
  return undefined;
210
263
  }
211
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
+
212
282
  export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
213
283
  const cached = qolSessionUserMessagesCache.get(sessionPath);
214
284
  if (cached) return cached;
215
285
  const messages: QolSessionUserMessage[] = [];
216
286
  try {
217
287
  forEachSessionJsonlLine(sessionPath, (line) => {
218
- if (!line.trim()) return;
219
- let entry: any;
220
- try { entry = JSON.parse(line); } catch { return; }
221
- const message = entry?.type === "message" ? entry.message : undefined;
222
- if (!message || message.role !== "user") return;
223
- const text = oneLine(messageContentText(message.content));
224
- if (!text) return;
225
- messages.push({
226
- entryId: typeof entry.id === "string" ? entry.id : undefined,
227
- index: messages.length + 1,
228
- parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
229
- text,
230
- timestamp: sessionMessageTimestamp(entry, message),
231
- });
288
+ const message = userMessageFromLine(line, messages.length + 1);
289
+ if (message) messages.push(message);
232
290
  });
233
291
  } catch {
234
292
  // Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
@@ -242,11 +300,31 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
242
300
  }
243
301
 
244
302
  export function userMessagesForResult(result: QolSessionSearchResult): QolSessionUserMessage[] {
245
- const messages = sessionUserMessages(result.path);
303
+ const messages = result.userMessages ?? sessionUserMessages(result.path);
246
304
  if (messages.length > 0) return messages;
247
305
  return [{ index: 1, text: oneLine(result.firstMessage || "No user messages") }];
248
306
  }
249
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
+
250
328
  export function sessionUserPromptCount(session: QolSessionSearchSession): number {
251
329
  const count = sessionUserMessages(session.path).length;
252
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,
@@ -121,6 +122,11 @@ export class QolSessionSearchComponent {
121
122
  private contextConfirmState: QolSessionContextConfirmState | undefined;
122
123
  private forkConfirmState: QolSessionForkConfirmState | undefined;
123
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" };
124
130
 
125
131
 
126
132
  constructor(
@@ -132,7 +138,22 @@ export class QolSessionSearchComponent {
132
138
  initialQuery = "",
133
139
  currentModel: QolModelInfo | undefined = undefined,
134
140
  ) {
135
- 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
+ };
136
157
  this.tui = tui;
137
158
  this.theme = theme;
138
159
  this.sessions = sessions;
@@ -140,24 +161,53 @@ export class QolSessionSearchComponent {
140
161
  this.currentModel = currentModel;
141
162
  const query = initialQuery.trim();
142
163
  const scope = defaultSessionSearchScope(cwd);
143
- const scopedSessions = this.sessionsForScope(scope);
144
164
  this.searchState = {
145
165
  cursor: query.length,
146
166
  query,
147
- results: searchQolSessionHits(scopedSessions, query, cwd),
167
+ results: [],
148
168
  selected: 0,
149
169
  scope,
150
- total: scopedSessions.length,
170
+ total: 0,
151
171
  };
172
+ if (sessions.status === "ready") this.setSessions(sessions);
152
173
  }
153
174
 
154
175
  invalidate(): void {}
155
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
+
156
188
  /** Show the index once its load settles; the overlay opens before that. */
157
189
  setSessions(sessions: QolSessionSearchLoad): void {
158
- this.sessions = sessions;
159
- this.updateResults(false);
160
- this.tui.requestRender();
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
+ });
161
211
  }
162
212
 
163
213
  private maxOverlayRows(): number {
@@ -205,6 +255,7 @@ export class QolSessionSearchComponent {
205
255
  }
206
256
 
207
257
  handleInput(data: string): void {
258
+ if (this.searchStatus.status === "action") return matchesKey(data, "escape") ? this.done({ type: "cancel" }) : undefined;
208
259
  if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
209
260
  else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
210
261
  else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
@@ -224,16 +275,33 @@ export class QolSessionSearchComponent {
224
275
  private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
225
276
  if (this.sessions.status !== "ready") return [];
226
277
  if (scope === "all") return this.sessions.sessions;
227
- return this.sessions.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
278
+ return this.currentSessions;
228
279
  }
229
280
 
230
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;
231
288
  const state = this.searchState;
232
289
  const scopedSessions = this.sessionsForScope(state.scope);
233
- state.results = searchQolSessionHits(scopedSessions, state.query, this.cwd);
290
+ state.results = [];
291
+ this.searchStatus = { status: "searching" };
234
292
  state.total = scopedSessions.length;
235
293
  if (resetSelection) state.selected = 0;
236
- 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
+ });
237
305
  }
238
306
 
239
307
  private toggleScope(): void {
@@ -244,7 +312,11 @@ export class QolSessionSearchComponent {
244
312
  private updateQuery(query: string, cursor: number): void {
245
313
  this.searchState.query = query;
246
314
  this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
247
- 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);
248
320
  }
249
321
 
250
322
  private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
@@ -628,6 +700,10 @@ export class QolSessionSearchComponent {
628
700
  lines.push(row(muted("Loading sessions…")));
629
701
  } else if (this.sessions.status === "failed") {
630
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…")));
631
707
  } else if (state.results.length === 0) {
632
708
  lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
633
709
  } else {
@@ -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
  }
@@ -129,11 +129,18 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
129
129
  );
130
130
  const releaseModalLock = acquirekendexModalLock();
131
131
  let action: QolSessionPaletteAction | undefined;
132
+ let component: QolSessionSearchComponent | undefined;
132
133
  try {
133
134
  const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
134
135
  action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) => {
135
- const component = new QolSessionSearchComponent(done, tui, theme, { status: "loading" }, ctx.cwd, initialQuery, currentModel);
136
- void loading.then((load) => component.setSessions(load));
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));
137
144
  return component;
138
145
  }, {
139
146
  overlay: true,
@@ -144,6 +151,7 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
144
151
  },
145
152
  });
146
153
  } finally {
154
+ component?.dispose();
147
155
  releaseModalLock();
148
156
  }
149
157
  if (!action || action.type === "cancel" || !action.result) return;
@@ -151,12 +159,6 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
151
159
  if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) queueSessionSearchCommandAction(ctx, action);
152
160
  return;
153
161
  }
154
- if (action.type === "copy") {
155
- const text = action.message?.text || action.result.firstMessage;
156
- ctx.ui.setEditorText(text);
157
- ctx.ui.notify("Copied selected prompt into the editor", "info");
158
- return;
159
- }
160
162
  if (action.type === "summarize") {
161
163
  try {
162
164
  await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
@@ -1,6 +1,38 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, createReadStream, openSync, readSync } from "node:fs";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
 
4
+ /** Stream records off the input path. Index reads skip oversized records;
5
+ * an action can read a complete prompt and stop at its selected entry. */
6
+ export async function forEachSessionJsonlLineAsync(sessionPath: string, onLine: (line: string) => unknown, signal: AbortSignal, options?: { maxLineChars: number }): Promise<void> {
7
+ const stream = createReadStream(sessionPath, { signal, highWaterMark: 64 * 1024 });
8
+ const decoder = new StringDecoder("utf8");
9
+ const maxLineChars = options?.maxLineChars ?? 2 * 1024 * 1024;
10
+ let pending = "";
11
+ let skipping = false;
12
+ try {
13
+ for await (const chunk of stream) {
14
+ signal.throwIfAborted();
15
+ const text = decoder.write(chunk);
16
+ let start = 0;
17
+ for (;;) {
18
+ const end = text.indexOf("\n", start);
19
+ const part = text.slice(start, end < 0 ? undefined : end);
20
+ if (!skipping && pending.length + part.length <= maxLineChars) pending += part;
21
+ else { pending = ""; skipping = true; }
22
+ if (end < 0) break;
23
+ if (!skipping && onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending) === false) return;
24
+ pending = "";
25
+ skipping = false;
26
+ start = end + 1;
27
+ }
28
+ }
29
+ pending += decoder.end();
30
+ if (!skipping && pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
31
+ } finally {
32
+ stream.destroy();
33
+ }
34
+ }
35
+
4
36
  export function forEachSessionJsonlLine(sessionPath: string, onLine: (line: string) => void, chunkSize = 64 * 1024): void {
5
37
  const fd = openSync(sessionPath, "r");
6
38
  const buffer = Buffer.allocUnsafe(Math.max(1, Math.floor(chunkSize)));
@@ -0,0 +1,12 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ // The parent owns the deadline and terminates this worker even when exec
4
+ // never returns. Neither prompt matching nor snippet matching runs in Pi.
5
+ parentPort.on("message", ({ source, texts }) => {
6
+ const regex = new RegExp(source, "i");
7
+ parentPort.postMessage({ status: "matched", matches: texts.map((text) => {
8
+ const match = regex.exec(text);
9
+ return match ? { index: match.index, length: match[0].length } : null;
10
+ }) });
11
+ });
12
+ parentPort.postMessage({ status: "ready" });
@@ -0,0 +1,49 @@
1
+ import { Worker } from "node:worker_threads";
2
+
3
+ export interface SessionRegexMatch { index: number; length: number }
4
+ type WorkerReply = { status: "ready" } | { status: "matched"; matches: Array<SessionRegexMatch | null> };
5
+
6
+ /** Run one query's matching in a disposable worker. User-entered regex can
7
+ * backtrack forever, so the owner terminates it rather than timing exec. */
8
+ export function sessionRegexMatches(source: string, texts: string[], signal: AbortSignal): Promise<Array<SessionRegexMatch | null>> {
9
+ signal.throwIfAborted();
10
+ return new Promise((resolve, reject) => {
11
+ const worker = new Worker(new URL("./regex-worker.mjs", import.meta.url), { execArgv: [], env: {} });
12
+ let settled = false;
13
+ // Startup is separate from the 25 ms execution budget.
14
+ let timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: worker startup")), 1000);
15
+ const finish = (error?: Error, matches?: Array<SessionRegexMatch | null>) => {
16
+ if (settled) return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ signal.removeEventListener("abort", abort);
20
+ worker.removeAllListeners("message");
21
+ // A runtime can defer thread exit until native regex execution returns.
22
+ // Request cleanup without making input wait for that exit.
23
+ worker.unref();
24
+ void worker.terminate().catch((cleanupError: unknown) => {
25
+ console.error("Session regex worker cleanup failed:", cleanupError);
26
+ });
27
+ if (error) reject(error);
28
+ else resolve(matches!);
29
+ };
30
+ const abort = () => finish(new Error("Session search cancelled"));
31
+ signal.addEventListener("abort", abort, { once: true });
32
+ worker.on("message", (reply: WorkerReply) => {
33
+ if (settled) return;
34
+ switch (reply.status) {
35
+ case "ready":
36
+ clearTimeout(timer);
37
+ timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: 25 ms")), 25);
38
+ worker.postMessage({ source, texts });
39
+ break;
40
+ case "matched": finish(undefined, reply.matches); break;
41
+ default: { const unexpected: never = reply; finish(new Error(`Unknown regex worker reply: ${unexpected}`)); }
42
+ }
43
+ });
44
+ worker.once("error", (error) => finish(error));
45
+ worker.once("exit", (code) => {
46
+ if (!settled) finish(new Error(`Session regex worker exited before results: ${code}`));
47
+ });
48
+ });
49
+ }
@@ -1,10 +1,9 @@
1
- import {
2
- DEFAULT_SESSION_SEARCH_LIMIT,
3
- DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS,
4
- } from "../constants.js";
1
+ import { setImmediate as yieldToInput } from "node:timers/promises";
2
+ import { DEFAULT_SESSION_SEARCH_LIMIT } from "../constants.js";
5
3
  import { settingNumber } from "../settings.js";
6
4
  import { stringifyError } from "../util.js";
7
5
  import { sessionDisplayName, sessionResumeTitle, userMessagesForResult } from "./cache.js";
6
+ import { sessionRegexMatches, type SessionRegexMatch } from "./regex.js";
8
7
  import type {
9
8
  QolParsedSessionQuery,
10
9
  QolSessionSearchHit,
@@ -13,10 +12,6 @@ import type {
13
12
  QolSessionUserMessage,
14
13
  } from "./types.js";
15
14
 
16
- export function sessionSearchText(session: QolSessionSearchSession): string {
17
- return [session.id, session.name ?? "", session.cwd, session.path, session.firstMessage, session.allMessagesText].join("\n");
18
- }
19
-
20
15
  export function normalizeSearchText(text: string): string {
21
16
  return text.toLowerCase().replace(/\s+/g, " ").trim();
22
17
  }
@@ -33,161 +28,57 @@ export function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
33
28
  return { error: stringifyError(error), mode: "regex", tokens: [] };
34
29
  }
35
30
  }
36
-
37
31
  const tokens: QolParsedSessionQuery["tokens"] = [];
38
32
  let buffer = "";
39
33
  let inQuote = false;
40
- let unclosed = false;
41
34
  const flush = (kind: "fuzzy" | "phrase") => {
42
35
  const value = buffer.trim();
43
36
  buffer = "";
44
37
  if (value) tokens.push({ kind, value });
45
38
  };
46
-
47
- for (let i = 0; i < trimmed.length; i++) {
48
- const char = trimmed[i]!;
39
+ for (const char of trimmed) {
49
40
  if (char === '"') {
50
- if (inQuote) {
51
- flush("phrase");
52
- inQuote = false;
53
- } else {
54
- flush("fuzzy");
55
- inQuote = true;
56
- }
57
- continue;
58
- }
59
- if (!inQuote && /\s/.test(char)) {
60
- flush("fuzzy");
61
- continue;
62
- }
63
- buffer += char;
41
+ flush(inQuote ? "phrase" : "fuzzy");
42
+ inQuote = !inQuote;
43
+ } else if (!inQuote && /\s/.test(char)) flush("fuzzy");
44
+ else buffer += char;
64
45
  }
65
- if (inQuote) unclosed = true;
66
- if (unclosed) {
67
- return {
68
- mode: "tokens",
69
- tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })),
70
- };
71
- }
72
- flush(inQuote ? "phrase" : "fuzzy");
46
+ if (inQuote) return { mode: "tokens", tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })) };
47
+ flush("fuzzy");
73
48
  return { mode: "tokens", tokens };
74
49
  }
75
50
 
76
- function searchWordLengthPenalty(word: string, query: string): number {
77
- return Math.max(0, word.length - query.length);
78
- }
79
-
80
51
  export function searchStringScore(needle: string, haystack: string): number | undefined {
81
52
  const query = normalizeSearchText(needle);
82
53
  const text = normalizeSearchText(haystack);
83
54
  if (!query) return 0;
84
55
  let best: number | undefined;
85
- const record = (score: number) => {
86
- best = best === undefined ? score : Math.min(best, score);
87
- };
56
+ const record = (score: number) => { best = best === undefined ? score : Math.min(best, score); };
88
57
  if (/^[a-z0-9_]+$/i.test(query)) {
89
58
  for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
90
59
  const word = match[0].toLowerCase();
60
+ const penalty = Math.max(0, word.length - query.length);
91
61
  if (word === query) record(0);
92
- else if (word.startsWith(query)) record(10 + searchWordLengthPenalty(word, query));
93
- else if (word.includes(query)) record(100 + searchWordLengthPenalty(word, query));
62
+ else if (word.startsWith(query)) record(10 + penalty);
63
+ else if (word.includes(query)) record(100 + penalty);
94
64
  }
95
65
  return best;
96
66
  }
97
67
  return text.includes(query) ? 0 : undefined;
98
68
  }
99
69
 
100
- export function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
101
- const text = sessionSearchText(session);
102
- if (parsed.mode === "regex") {
103
- if (!parsed.regex) return { matches: false, score: 0 };
104
- const index = text.search(parsed.regex);
105
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
106
- }
107
- if (parsed.tokens.length === 0) return { matches: true, score: 0 };
108
- let score = 0;
109
- for (const token of parsed.tokens) {
110
- const tokenScore = searchStringScore(token.value, text);
111
- if (tokenScore === undefined) return { matches: false, score: 0 };
112
- score += tokenScore;
113
- }
114
- return { matches: true, score };
115
- }
116
-
117
- function sessionSnippetSource(session: QolSessionSearchSession): string {
118
- return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
119
- }
120
-
121
70
  function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
122
- const safeLead = Math.max(0, Math.min(width, lead));
123
- const safeStart = Math.max(0, start - safeLead);
71
+ const safeStart = Math.max(0, start - Math.max(0, Math.min(width, lead)));
124
72
  const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
125
- const prefix = safeStart > 0 ? "…" : "";
126
- const suffix = safeEnd < text.length ? "…" : "";
127
- return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
128
- }
129
-
130
- export function buildSessionSnippets(session: QolSessionSearchSession, parsed: QolParsedSessionQuery, limit: number): string[] {
131
- const source = sessionSnippetSource(session);
132
- if (!source) return [];
133
- if (parsed.mode === "regex" && parsed.regex) {
134
- parsed.regex.lastIndex = 0;
135
- const match = parsed.regex.exec(source);
136
- return match ? [snippetAround(source, match.index, match[0].length, 220)] : [];
137
- }
138
- if (parsed.tokens.length === 0) return [source.slice(0, 220)];
139
- const snippets: string[] = [];
140
- const lower = source.toLowerCase();
141
- const seen = new Set<string>();
142
- for (const token of parsed.tokens) {
143
- const value = normalizeSearchText(token.value);
144
- if (!value) continue;
145
- const index = lower.indexOf(value.toLowerCase());
146
- if (index < 0) continue;
147
- const snippet = snippetAround(source, index, value.length, 220);
148
- const key = snippet.toLowerCase();
149
- if (!seen.has(key)) {
150
- seen.add(key);
151
- snippets.push(snippet);
152
- }
153
- if (snippets.length >= limit) break;
154
- }
155
- return snippets.length > 0 ? snippets : [source.slice(0, 220)];
156
- }
157
-
158
- export function searchQolSessions(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchResult[] {
159
- const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
160
- const snippetLimit = Math.max(1, Math.floor(settingNumber("sessionSearch.previewSnippets", DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS, cwd)));
161
- const parsed = parseSessionSearchQuery(query);
162
- if (parsed.error) return [];
163
- if (!query.trim()) {
164
- return sessions
165
- .slice()
166
- .sort((a, b) => b.modified.getTime() - a.modified.getTime())
167
- .slice(0, limit)
168
- .map((session) => ({ ...session, rank: 0, snippets: buildSessionSnippets(session, { mode: "tokens", tokens: [] }, 1) }));
169
- }
170
- const results: QolSessionSearchResult[] = [];
171
- for (const session of sessions) {
172
- const match = matchSessionSearch(session, parsed);
173
- if (!match.matches) continue;
174
- results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
175
- }
176
- results.sort((a, b) => a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
177
- return results.slice(0, limit);
73
+ return `${safeStart > 0 ? "…" : ""}${text.slice(safeStart, safeEnd)}${safeEnd < text.length ? "…" : ""}`;
178
74
  }
179
75
 
180
76
  export function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
181
77
  return { ...session, rank, snippets };
182
78
  }
183
79
 
184
- function matchTextSearch(text: string, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
185
- if (parsed.mode === "regex") {
186
- if (!parsed.regex) return { matches: false, score: 0 };
187
- const index = text.search(parsed.regex);
188
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
189
- }
190
- if (parsed.tokens.length === 0) return { matches: true, score: 0 };
80
+ function matchTextSearch(text: string, parsed: QolParsedSessionQuery, regexMatches: Map<string, SessionRegexMatch | null>): { matches: boolean; score: number } {
81
+ if (parsed.mode === "regex") return { matches: regexMatches.get(text) != null, score: 0 };
191
82
  let score = 0;
192
83
  for (const token of parsed.tokens) {
193
84
  const tokenScore = searchStringScore(token.value, text);
@@ -201,24 +92,17 @@ function sessionTitleSearchText(session: QolSessionSearchSession): string {
201
92
  return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
202
93
  }
203
94
 
204
- function matchPromptSearch(_session: QolSessionSearchSession, message: QolSessionUserMessage, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
205
- return matchTextSearch(message.text, parsed);
206
- }
207
-
208
- export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
95
+ export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery, regexMatch?: SessionRegexMatch | null): string {
96
+ // Worker offsets refer to the original prompt, before whitespace collapses.
97
+ if (parsed.mode === "regex" && regexMatch) return snippetAround(message.text, regexMatch.index, Math.min(regexMatch.length, 160), 160, 24).replace(/\s+/g, " ").trim();
209
98
  const source = message.text.replace(/\s+/g, " ").trim();
210
99
  if (!source) return "";
211
- if (parsed.mode === "regex" && parsed.regex) {
212
- parsed.regex.lastIndex = 0;
213
- const match = parsed.regex.exec(source);
214
- return match ? snippetAround(source, match.index, match[0].length, 160, 24) : source.slice(0, 160);
215
- }
216
- if (parsed.tokens.length === 0) return source.slice(0, 160);
100
+ if (parsed.mode === "regex") return source.slice(0, 160);
217
101
  const lower = source.toLowerCase();
218
102
  for (const token of parsed.tokens) {
219
103
  const value = normalizeSearchText(token.value);
220
104
  if (!value) continue;
221
- const index = lower.indexOf(value.toLowerCase());
105
+ const index = lower.indexOf(value);
222
106
  if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
223
107
  }
224
108
  return source.slice(0, 160);
@@ -235,48 +119,58 @@ function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSea
235
119
  || b.message.index - a.message.index;
236
120
  }
237
121
 
238
- export function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchHit[] {
122
+ /** Search only prepared prompts. Yield between batches, cancel obsolete
123
+ * queries, and run all regex matching in a deadline-owned worker. */
124
+ export async function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string, signal: AbortSignal): Promise<QolSessionSearchHit[]> {
125
+ signal.throwIfAborted();
239
126
  const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
240
127
  const parsed = parseSessionSearchQuery(query);
241
- if (parsed.error) return [];
242
- const hits: QolSessionSearchHit[] = [];
243
- if (!query.trim()) {
244
- for (const session of sessions) {
245
- const result = resultFromSession(session);
246
- for (const message of userMessagesForResult(result)) {
247
- const snippet = buildPromptSnippet(message, parsed);
248
- hits.push({ message, rank: 0, result: resultFromSession(session, 0, [snippet]), snippet });
249
- }
250
- }
251
- hits.sort(compareSessionSearchHitsRecent);
252
- return hits.slice(0, limit);
128
+ if (parsed.error) throw new Error(parsed.error);
129
+ const regexMatches = new Map<string, SessionRegexMatch | null>();
130
+ if (parsed.mode === "regex" && parsed.regex) {
131
+ const texts = [...new Set(sessions.flatMap((session) => [sessionTitleSearchText(session), ...userMessagesForResult(resultFromSession(session)).map((message) => message.text)]))];
132
+ const matches = await sessionRegexMatches(parsed.regex.source, texts, signal);
133
+ texts.forEach((text, index) => regexMatches.set(text, matches[index]!));
253
134
  }
135
+ const hits: QolSessionSearchHit[] = [];
136
+ let batchStarted = performance.now();
254
137
  for (const session of sessions) {
138
+ signal.throwIfAborted();
139
+ const messages = userMessagesForResult(resultFromSession(session));
255
140
  let addedPromptHit = false;
256
- for (const message of userMessagesForResult(resultFromSession(session))) {
257
- const match = matchPromptSearch(session, message, parsed);
258
- if (!match.matches) continue;
259
- const snippet = buildPromptSnippet(message, parsed);
260
- hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
261
- addedPromptHit = true;
141
+ for (const message of messages) {
142
+ const match = matchTextSearch(message.text, parsed, regexMatches);
143
+ if (match.matches) {
144
+ const snippet = buildPromptSnippet(message, parsed, regexMatches.get(message.text));
145
+ hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
146
+ addedPromptHit = true;
147
+ }
148
+ if (performance.now() - batchStarted >= 4) {
149
+ await yieldToInput(undefined, { signal });
150
+ batchStarted = performance.now();
151
+ }
262
152
  }
263
153
  if (!addedPromptHit) {
264
- const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
265
- if (!titleMatch.matches) continue;
266
- const messages = userMessagesForResult(resultFromSession(session));
154
+ const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed, regexMatches);
267
155
  const message = messages[messages.length - 1];
268
- if (!message) continue;
269
- const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
270
- hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
156
+ if (titleMatch.matches && message) {
157
+ const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
158
+ hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
159
+ }
160
+ }
161
+ // Only the best resultLimit hits need to stay alive between batches.
162
+ if (hits.length > limit * 2) {
163
+ hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
164
+ hits.length = limit;
271
165
  }
272
166
  }
167
+ signal.throwIfAborted();
273
168
  hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
274
169
  return hits.slice(0, limit);
275
170
  }
276
171
 
277
172
  export function formatSessionSearchDate(date: Date): string {
278
- const now = Date.now();
279
- const diffMs = Math.max(0, now - date.getTime());
173
+ const diffMs = Math.max(0, Date.now() - date.getTime());
280
174
  const diffMins = Math.floor(diffMs / 60_000);
281
175
  const diffHours = Math.floor(diffMs / 3_600_000);
282
176
  const diffDays = Math.floor(diffMs / 86_400_000);
@@ -11,6 +11,9 @@ export interface QolSessionSearchSession {
11
11
  name?: string;
12
12
  parentSessionPath?: string;
13
13
  path: string;
14
+ /** Prepared once per index load, not read from disk during a query. */
15
+ canonicalCwd?: string;
16
+ userMessages?: QolSessionUserMessage[];
14
17
  }
15
18
 
16
19
  /** The session-search index as the overlay sees it: still loading, loaded,
package/extensions/qol.ts CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  QolEditor,
40
40
  } from "./qol/editor.js";
41
41
  import { runHandoff } from "./qol/handoff.js";
42
- import { imageContentForPath, resolveSubmittedImagePaths } from "./qol/images.js";
42
+ import { imageContentsForPaths, resolveSubmittedImagePaths } from "./qol/images.js";
43
43
  import {
44
44
  clearTmuxWindowMark,
45
45
  notifyQuestionOpened,
@@ -226,12 +226,12 @@ export default function qol(pi: ExtensionAPI): void {
226
226
  thinkingTimerStore.enabled = !!ctx?.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx?.cwd);
227
227
  };
228
228
 
229
- // Pi's Loader starts no animation timer for a single-frame indicator.
230
- // Keep static mode available for terminals where frequent redraws flash,
231
- // whether the indicator is embedded in QOL or in Pi's standalone row.
229
+ // Pi's Loader starts no animation timer for a single-frame indicator, so
230
+ // static mode, the default, draws no frames of its own whether the
231
+ // indicator is embedded in QOL or in Pi's standalone row.
232
232
  const applyWorkingIndicatorMode = (ctx: ExtensionContext): void => {
233
233
  if (!ctx.hasUI) return;
234
- const mode = settingString("workingIndicator.mode", "animated", ctx.cwd);
234
+ const mode = settingString("workingIndicator.mode", "static", ctx.cwd);
235
235
  if (mode === "static") {
236
236
  ctx.ui.setWorkingVisible(true);
237
237
  ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
@@ -847,15 +847,24 @@ export default function qol(pi: ExtensionAPI): void {
847
847
  if (event?.toolName === "tasks_write") maybeNotifyTaskCompletion(ctx, event.details?.state);
848
848
  });
849
849
 
850
- pi.on("input", async (event) => {
850
+ pi.on("input", async (event, ctx) => {
851
851
  clearTmuxWindowMark(pi);
852
852
  if (event.source === "extension") return { action: "continue" };
853
853
  const text = event.text ?? "";
854
- const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
854
+ const paths = resolveSubmittedImagePaths(text, ctx.cwd);
855
855
  if (paths.length === 0) return { action: "continue" };
856
- const images = paths.map(imageContentForPath).filter(Boolean);
857
- if (images.length === 0) return { action: "continue" };
858
- return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
856
+ try {
857
+ const images = await imageContentsForPaths(paths, event.images ?? []);
858
+ return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
859
+ } catch (error) {
860
+ const message = `Image submission refused: ${stringifyError(error)}`;
861
+ ctx.ui.notify(message, "error");
862
+ // Print and JSON modes reserve stderr for diagnostics; input exceptions
863
+ // would let Pi continue submission instead of refusing the image.
864
+ if (!ctx.hasUI) console.error(message);
865
+ if (ctx.hasUI && ctx.ui.getEditorText() === "") ctx.ui.setEditorText(text);
866
+ return { action: "handled" };
867
+ }
859
868
  });
860
869
 
861
870
  if (settingBoolean("enableSessionNameCommand", true)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -310,9 +310,9 @@
310
310
  {
311
311
  "key": "sessionAutoRename.maxTokens",
312
312
  "label": "Auto-rename output tokens",
313
- "description": "Maximum output tokens requested from the naming model.",
313
+ "description": "Maximum output tokens requested from the naming model, reasoning tokens included.",
314
314
  "type": "number",
315
- "default": 96,
315
+ "default": 128,
316
316
  "category": "Session Auto-Rename",
317
317
  "apply": "live"
318
318
  },
@@ -966,13 +966,13 @@
966
966
  {
967
967
  "key": "workingIndicator.mode",
968
968
  "label": "Working indicator mode",
969
- "description": "Control Pi's built-in streaming spinner. The default animated spinner ticks every 80ms; in terminals where rendered content overflows the viewport (chat scrolled past the top, /tree open, etc.) each tick can trigger a pi-tui full-screen redraw and visible flash. Use 'static' for a single non-animating dot that keeps the working indicator visible without the 80ms timer. It appears before the project name when the QOL statusline is enabled, or in Pi's working row otherwise.",
969
+ "description": "Control Pi's built-in streaming spinner. The default 'static' shows a single non-animating dot that keeps the working indicator visible without a timer. 'animated' restores Pi's spinner, which ticks every 80ms; in terminals where rendered content overflows the viewport (chat scrolled past the top, /tree open, etc.) each tick can trigger a pi-tui full-screen redraw and visible flash. It appears before the project name when the QOL statusline is enabled, or in Pi's working row otherwise.",
970
970
  "type": "enum",
971
971
  "enumValues": [
972
972
  "animated",
973
973
  "static"
974
974
  ],
975
- "default": "animated",
975
+ "default": "static",
976
976
  "category": "Thinking",
977
977
  "apply": "reload",
978
978
  "requiresReload": true