@vanillagreen/pi-qol 1.1.0 → 1.1.1

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/extensions/qol.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { AgentMessage } from "@mariozechner/pi-agent-core";
2
- import { complete, type Message } from "@mariozechner/pi-ai";
3
- import { AssistantMessageComponent, BorderedLoader, convertToLlm, CustomEditor, serializeConversation, SessionManager, Theme, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type KeybindingsManager, type SessionEntry } from "@mariozechner/pi-coding-agent";
4
- import type { AutocompleteItem, AutocompleteSuggestions, EditorTheme, TUI } from "@mariozechner/pi-tui";
5
- import { matchesKey, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { complete, type Message } from "@earendil-works/pi-ai";
3
+ import { AssistantMessageComponent, BorderedLoader, convertToLlm, CustomEditor, serializeConversation, SessionManager, Theme, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type KeybindingsManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import type { AutocompleteItem, AutocompleteSuggestions, EditorTheme, TUI } from "@earendil-works/pi-tui";
5
+ import { matchesKey, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
6
  import { execFile, execFileSync } from "node:child_process";
7
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
10
 
@@ -125,7 +125,7 @@ const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
125
125
  const DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH = 120;
126
126
  const DEFAULT_SESSION_SEARCH_LIMIT = 40;
127
127
  const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
128
- const DEFAULT_SESSION_SEARCH_SHORTCUT = "f3";
128
+ const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
129
129
  const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
130
130
  const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
131
131
  const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
@@ -2272,7 +2272,7 @@ function statusMessage(ctx: ExtensionContext): string {
2272
2272
  return [
2273
2273
  "Pi QOL status",
2274
2274
  `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
2275
- `Shift+Enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2275
+ `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2276
2276
  `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
2277
2277
  `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
2278
2278
  `Image chips: ${settingBoolean("showImageChips", true, ctx.cwd) ? "filled (placeholders and existing image paths)" : "off"}`,
@@ -2288,23 +2288,12 @@ function statusMessage(ctx: ExtensionContext): string {
2288
2288
  `Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
2289
2289
  `Permission gate: ${settingBoolean("permissionGate.enabled", false, ctx.cwd) ? `enabled (${permissionGateCommands(ctx.cwd).join(", ") || "none configured"}; preview ${boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, ctx.cwd)} lines/${boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, ctx.cwd)} chars)` : "disabled"}`,
2290
2290
  `Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
2291
- "If Shift+Enter still submits, configure your terminal/tmux to send a distinct Shift+Enter sequence or use the fallback key.",
2291
+ "If shift+enter still submits, configure your terminal/tmux to send a distinct shift+enter sequence or use the fallback key.",
2292
2292
  ].join("\n");
2293
2293
  }
2294
2294
 
2295
2295
 
2296
- type QolSessionSortMode = "recent" | "relevance";
2297
-
2298
- type QolSessionAction = "resume" | "copy" | "summarize" | "newSession" | "back";
2299
-
2300
- const QOL_SESSION_ACTIONS: QolSessionAction[] = ["resume", "copy", "summarize", "newSession", "back"];
2301
- const QOL_SESSION_ACTION_LABELS: Record<QolSessionAction, string> = {
2302
- resume: "Resume Session From Here",
2303
- copy: "Copy Prompt",
2304
- summarize: "Inject Context",
2305
- newSession: "New Session + Context",
2306
- back: "Back",
2307
- };
2296
+ type QolSessionSearchScope = "current" | "all";
2308
2297
 
2309
2298
  interface QolSessionSearchSession {
2310
2299
  allMessagesText: string;
@@ -2324,6 +2313,13 @@ interface QolSessionSearchResult extends QolSessionSearchSession {
2324
2313
  snippets: string[];
2325
2314
  }
2326
2315
 
2316
+ interface QolSessionSearchHit {
2317
+ message: QolSessionUserMessage;
2318
+ rank: number;
2319
+ result: QolSessionSearchResult;
2320
+ snippet: string;
2321
+ }
2322
+
2327
2323
  interface QolParsedSessionQuery {
2328
2324
  mode: "tokens" | "regex";
2329
2325
  tokens: Array<{ kind: "fuzzy" | "phrase"; value: string }>;
@@ -2334,8 +2330,9 @@ interface QolParsedSessionQuery {
2334
2330
  interface QolSessionSearchState {
2335
2331
  cursor: number;
2336
2332
  query: string;
2337
- results: QolSessionSearchResult[];
2333
+ results: QolSessionSearchHit[];
2338
2334
  selected: number;
2335
+ scope: QolSessionSearchScope;
2339
2336
  total: number;
2340
2337
  }
2341
2338
 
@@ -2354,26 +2351,31 @@ interface QolSessionMessagesState {
2354
2351
  }
2355
2352
 
2356
2353
  interface QolSessionActionState {
2357
- actionIndex: number;
2358
2354
  message: QolSessionUserMessage;
2359
2355
  result: QolSessionSearchResult;
2360
2356
  }
2361
2357
 
2362
- interface QolSessionFocusState {
2363
- cursor: number;
2358
+ interface QolSessionContextConfirmState {
2364
2359
  message: QolSessionUserMessage;
2365
- prompt: string;
2366
2360
  result: QolSessionSearchResult;
2361
+ returnScreen: "search" | "messages" | "actions";
2367
2362
  type: "summarize" | "newSession";
2368
2363
  }
2369
2364
 
2365
+ interface QolSessionForkConfirmState {
2366
+ messages: QolSessionUserMessage[];
2367
+ result: QolSessionSearchResult;
2368
+ returnScreen: "search" | "messages" | "actions";
2369
+ selected: number;
2370
+ }
2371
+
2370
2372
  interface QolSessionSearchPendingMessage {
2371
2373
  content: string;
2372
2374
  details: Record<string, unknown>;
2373
2375
  }
2374
2376
 
2375
2377
  interface QolSessionPaletteAction {
2376
- type: "cancel" | "resume" | "copy" | "summarize" | "newSession";
2378
+ type: "cancel" | "resume" | "fork" | "copy" | "summarize" | "newSession";
2377
2379
  customPrompt?: string;
2378
2380
  message?: QolSessionUserMessage;
2379
2381
  result?: QolSessionSearchResult;
@@ -2383,6 +2385,8 @@ let qolSessionSearchCache: QolSessionSearchSession[] = [];
2383
2385
  let qolSessionSearchLoadedAt = 0;
2384
2386
  let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
2385
2387
  const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
2388
+ const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
2389
+ let qolSessionSearchPendingActionCounter = 0;
2386
2390
 
2387
2391
  function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
2388
2392
  return (globalThis as unknown as Record<PropertyKey, unknown>)[SESSION_SEARCH_PENDING_SYMBOL] as QolSessionSearchPendingMessage | undefined;
@@ -2422,11 +2426,12 @@ function resolveSettingsRelativePath(value: string, settingsPath: string): strin
2422
2426
  }
2423
2427
 
2424
2428
  function sessionSearchShortcut(cwd?: string): string | undefined {
2425
- // Legacy escape hatch from the original Ctrl+F setting. If users disabled it,
2429
+ // Legacy escape hatch from the original ctrl+f setting. If users disabled it,
2426
2430
  // keep shortcuts disabled even though the default shortcut is now conflict-free.
2427
2431
  if (!settingBoolean("sessionSearch.ctrlFShortcut", true, cwd)) return undefined;
2428
2432
  const shortcut = settingStringAllowEmpty("sessionSearch.shortcutKey", DEFAULT_SESSION_SEARCH_SHORTCUT, cwd).trim().toLowerCase();
2429
2433
  if (!shortcut || shortcut === "none" || shortcut === "off" || shortcut === "false") return undefined;
2434
+ if (shortcut === "f3") return DEFAULT_SESSION_SEARCH_SHORTCUT;
2430
2435
  return shortcut;
2431
2436
  }
2432
2437
 
@@ -2448,6 +2453,23 @@ function configuredSessionDir(cwd: string): string | undefined {
2448
2453
  return configured;
2449
2454
  }
2450
2455
 
2456
+ function canonicalPathForSessionSearch(path: string | undefined): string | undefined {
2457
+ if (!path) return undefined;
2458
+ try {
2459
+ return realpathSync.native(path);
2460
+ } catch {
2461
+ return resolve(path);
2462
+ }
2463
+ }
2464
+
2465
+ function sameSessionSearchProject(sessionCwd: string | undefined, cwd: string): boolean {
2466
+ return canonicalPathForSessionSearch(sessionCwd) === canonicalPathForSessionSearch(cwd);
2467
+ }
2468
+
2469
+ function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
2470
+ return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
2471
+ }
2472
+
2451
2473
  async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
2452
2474
  const customSessionDir = configuredSessionDir(ctx.cwd);
2453
2475
  const infos = customSessionDir
@@ -2932,6 +2954,16 @@ function messageContentText(content: unknown): string {
2932
2954
  }).filter(Boolean).join(" ");
2933
2955
  }
2934
2956
 
2957
+ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
2958
+ if (typeof message?.timestamp === "number" && Number.isFinite(message.timestamp)) return message.timestamp;
2959
+ if (typeof entry?.timestamp === "number" && Number.isFinite(entry.timestamp)) return entry.timestamp;
2960
+ if (typeof entry?.timestamp === "string") {
2961
+ const parsed = new Date(entry.timestamp).getTime();
2962
+ if (!Number.isNaN(parsed)) return parsed;
2963
+ }
2964
+ return undefined;
2965
+ }
2966
+
2935
2967
  function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2936
2968
  const cached = qolSessionUserMessagesCache.get(sessionPath);
2937
2969
  if (cached) return cached;
@@ -2951,7 +2983,7 @@ function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2951
2983
  index: messages.length + 1,
2952
2984
  parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
2953
2985
  text,
2954
- timestamp: typeof message.timestamp === "number" ? message.timestamp : undefined,
2986
+ timestamp: sessionMessageTimestamp(entry, message),
2955
2987
  });
2956
2988
  }
2957
2989
  } catch {
@@ -3042,21 +3074,28 @@ function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
3042
3074
  return { mode: "tokens", tokens };
3043
3075
  }
3044
3076
 
3045
- function simpleFuzzyScore(needle: string, haystack: string): number | undefined {
3046
- const query = needle.toLowerCase();
3047
- const text = haystack.toLowerCase();
3077
+ function searchWordLengthPenalty(word: string, query: string): number {
3078
+ return Math.max(0, word.length - query.length);
3079
+ }
3080
+
3081
+ function searchStringScore(needle: string, haystack: string): number | undefined {
3082
+ const query = normalizeSearchText(needle);
3083
+ const text = normalizeSearchText(haystack);
3048
3084
  if (!query) return 0;
3049
- const direct = text.indexOf(query);
3050
- if (direct >= 0) return direct * 0.1;
3051
- let pos = 0;
3052
- let score = 0;
3053
- for (const char of query) {
3054
- const found = text.indexOf(char, pos);
3055
- if (found < 0) return undefined;
3056
- score += found - pos + 1;
3057
- pos = found + 1;
3085
+ let best: number | undefined;
3086
+ const record = (score: number) => {
3087
+ best = best === undefined ? score : Math.min(best, score);
3088
+ };
3089
+ if (/^[a-z0-9_]+$/i.test(query)) {
3090
+ for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
3091
+ const word = match[0].toLowerCase();
3092
+ if (word === query) record(0);
3093
+ else if (word.startsWith(query)) record(10 + searchWordLengthPenalty(word, query));
3094
+ else if (word.includes(query)) record(100 + searchWordLengthPenalty(word, query));
3095
+ }
3096
+ return best;
3058
3097
  }
3059
- return score + 1000;
3098
+ return text.includes(query) ? 0 : undefined;
3060
3099
  }
3061
3100
 
3062
3101
  function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
@@ -3064,21 +3103,12 @@ function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedS
3064
3103
  if (parsed.mode === "regex") {
3065
3104
  if (!parsed.regex) return { matches: false, score: 0 };
3066
3105
  const index = text.search(parsed.regex);
3067
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: index * 0.1 };
3106
+ return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
3068
3107
  }
3069
3108
  if (parsed.tokens.length === 0) return { matches: true, score: 0 };
3070
3109
  let score = 0;
3071
- let normalized: string | undefined;
3072
3110
  for (const token of parsed.tokens) {
3073
- if (token.kind === "phrase") {
3074
- normalized ??= normalizeSearchText(text);
3075
- const phrase = normalizeSearchText(token.value);
3076
- const index = normalized.indexOf(phrase);
3077
- if (index < 0) return { matches: false, score: 0 };
3078
- score += index * 0.1;
3079
- continue;
3080
- }
3081
- const tokenScore = simpleFuzzyScore(token.value, text);
3111
+ const tokenScore = searchStringScore(token.value, text);
3082
3112
  if (tokenScore === undefined) return { matches: false, score: 0 };
3083
3113
  score += tokenScore;
3084
3114
  }
@@ -3093,9 +3123,10 @@ function sessionSnippetSource(session: QolSessionSearchSession): string {
3093
3123
  return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
3094
3124
  }
3095
3125
 
3096
- function snippetAround(text: string, start: number, length: number, width: number): string {
3097
- const safeStart = Math.max(0, start - Math.floor(width / 3));
3098
- const safeEnd = Math.min(text.length, start + length + Math.floor((width * 2) / 3));
3126
+ function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
3127
+ const safeLead = Math.max(0, Math.min(width, lead));
3128
+ const safeStart = Math.max(0, start - safeLead);
3129
+ const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
3099
3130
  const prefix = safeStart > 0 ? "…" : "";
3100
3131
  const suffix = safeEnd < text.length ? "…" : "";
3101
3132
  return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
@@ -3147,11 +3178,107 @@ function searchQolSessions(sessions: QolSessionSearchSession[], query: string, c
3147
3178
  if (!match.matches) continue;
3148
3179
  results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
3149
3180
  }
3150
- const sortMode = settingString("sessionSearch.sortMode", "relevance", cwd) === "recent" ? "recent" : "relevance" as QolSessionSortMode;
3151
- results.sort((a, b) => sortMode === "recent" ? b.modified.getTime() - a.modified.getTime() : a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
3181
+ results.sort((a, b) => a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
3152
3182
  return results.slice(0, limit);
3153
3183
  }
3154
3184
 
3185
+ function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
3186
+ return { ...session, rank, snippets };
3187
+ }
3188
+
3189
+ function matchTextSearch(text: string, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
3190
+ if (parsed.mode === "regex") {
3191
+ if (!parsed.regex) return { matches: false, score: 0 };
3192
+ const index = text.search(parsed.regex);
3193
+ return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
3194
+ }
3195
+ if (parsed.tokens.length === 0) return { matches: true, score: 0 };
3196
+ let score = 0;
3197
+ for (const token of parsed.tokens) {
3198
+ const tokenScore = searchStringScore(token.value, text);
3199
+ if (tokenScore === undefined) return { matches: false, score: 0 };
3200
+ score += tokenScore;
3201
+ }
3202
+ return { matches: true, score };
3203
+ }
3204
+
3205
+ function sessionTitleSearchText(session: QolSessionSearchSession): string {
3206
+ return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
3207
+ }
3208
+
3209
+ function matchPromptSearch(_session: QolSessionSearchSession, message: QolSessionUserMessage, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
3210
+ return matchTextSearch(message.text, parsed);
3211
+ }
3212
+
3213
+ function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
3214
+ const source = message.text.replace(/\s+/g, " ").trim();
3215
+ if (!source) return "";
3216
+ if (parsed.mode === "regex" && parsed.regex) {
3217
+ parsed.regex.lastIndex = 0;
3218
+ const match = parsed.regex.exec(source);
3219
+ return match ? snippetAround(source, match.index, match[0].length, 160, 24) : source.slice(0, 160);
3220
+ }
3221
+ if (parsed.tokens.length === 0) return source.slice(0, 160);
3222
+ const lower = source.toLowerCase();
3223
+ for (const token of parsed.tokens) {
3224
+ const value = normalizeSearchText(token.value);
3225
+ if (!value) continue;
3226
+ const index = lower.indexOf(value.toLowerCase());
3227
+ if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
3228
+ }
3229
+ return source.slice(0, 160);
3230
+ }
3231
+
3232
+ function promptRecencyTime(hit: QolSessionSearchHit): number {
3233
+ if (typeof hit.message.timestamp === "number" && Number.isFinite(hit.message.timestamp)) return hit.message.timestamp;
3234
+ return hit.result.modified.getTime();
3235
+ }
3236
+
3237
+ function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSearchHit): number {
3238
+ return promptRecencyTime(b) - promptRecencyTime(a)
3239
+ || b.result.modified.getTime() - a.result.modified.getTime()
3240
+ || b.message.index - a.message.index;
3241
+ }
3242
+
3243
+ function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchHit[] {
3244
+ const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
3245
+ const parsed = parseSessionSearchQuery(query);
3246
+ if (parsed.error) return [];
3247
+ const hits: QolSessionSearchHit[] = [];
3248
+ if (!query.trim()) {
3249
+ for (const session of sessions) {
3250
+ const result = resultFromSession(session);
3251
+ for (const message of userMessagesForResult(result)) {
3252
+ const snippet = buildPromptSnippet(message, parsed);
3253
+ hits.push({ message, rank: 0, result: resultFromSession(session, 0, [snippet]), snippet });
3254
+ }
3255
+ }
3256
+ hits.sort(compareSessionSearchHitsRecent);
3257
+ return hits.slice(0, limit);
3258
+ }
3259
+ for (const session of sessions) {
3260
+ let addedPromptHit = false;
3261
+ for (const message of userMessagesForResult(resultFromSession(session))) {
3262
+ const match = matchPromptSearch(session, message, parsed);
3263
+ if (!match.matches) continue;
3264
+ const snippet = buildPromptSnippet(message, parsed);
3265
+ hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
3266
+ addedPromptHit = true;
3267
+ }
3268
+ if (!addedPromptHit) {
3269
+ const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
3270
+ if (!titleMatch.matches) continue;
3271
+ const messages = userMessagesForResult(resultFromSession(session));
3272
+ const message = messages[messages.length - 1];
3273
+ if (!message) continue;
3274
+ const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
3275
+ hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
3276
+ }
3277
+ }
3278
+ hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
3279
+ return hits.slice(0, limit);
3280
+ }
3281
+
3155
3282
  function styleSessionSnippet(snippet: string, query: string, theme: Theme): string {
3156
3283
  let styled = snippet;
3157
3284
  const parsed = parseSessionSearchQuery(query);
@@ -3211,11 +3338,14 @@ function isPrintableInput(data: string): boolean {
3211
3338
  }
3212
3339
 
3213
3340
  class QolSessionSearchComponent {
3214
- private screen: "search" | "messages" | "actions" | "focus" = "search";
3341
+ private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" = "search";
3215
3342
  private searchState: QolSessionSearchState;
3216
3343
  private messagesState: QolSessionMessagesState | undefined;
3217
3344
  private actionState: QolSessionActionState | undefined;
3218
- private focusState: QolSessionFocusState | undefined;
3345
+ private actionReturnScreen: "search" | "messages" = "messages";
3346
+ private contextConfirmState: QolSessionContextConfirmState | undefined;
3347
+ private forkConfirmState: QolSessionForkConfirmState | undefined;
3348
+
3219
3349
 
3220
3350
  constructor(
3221
3351
  private readonly done: (action: QolSessionPaletteAction) => void,
@@ -3226,12 +3356,15 @@ class QolSessionSearchComponent {
3226
3356
  initialQuery = "",
3227
3357
  ) {
3228
3358
  const query = initialQuery.trim();
3359
+ const scope = defaultSessionSearchScope(cwd);
3360
+ const scopedSessions = this.sessionsForScope(scope);
3229
3361
  this.searchState = {
3230
3362
  cursor: query.length,
3231
3363
  query,
3232
- results: searchQolSessions(sessions, query, cwd),
3364
+ results: searchQolSessionHits(scopedSessions, query, cwd),
3233
3365
  selected: 0,
3234
- total: sessions.length,
3366
+ scope,
3367
+ total: scopedSessions.length,
3235
3368
  };
3236
3369
  }
3237
3370
 
@@ -3240,16 +3373,19 @@ class QolSessionSearchComponent {
3240
3373
  private maxOverlayRows(): number {
3241
3374
  const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
3242
3375
  const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
3243
- return Math.max(8, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
3376
+ return Math.max(9, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
3377
+ }
3378
+
3379
+ private compactSearchLayout(): boolean {
3380
+ return this.maxOverlayRows() < 18;
3244
3381
  }
3245
3382
 
3246
3383
  private searchMaxVisibleRows(): number {
3247
3384
  const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
3248
- // Search results render as 3 content rows plus a divider between rows.
3249
- // The remaining chrome is title, search box, help text, scroll status,
3250
- // footer, and frame. Keep the rendered line count within overlay maxHeight
3251
- // so Pi does not clip the bottom of the popup on short terminals.
3252
- const responsive = Math.max(1, Math.floor((this.maxOverlayRows() - 12) / 4));
3385
+ // Each hit is two rows plus a separator between hits. Chrome is aggressively
3386
+ // reduced on short terminals so the popup can shrink to one visible hit.
3387
+ const chromeRows = this.compactSearchLayout() ? 7 : 11;
3388
+ const responsive = Math.max(1, Math.floor((this.maxOverlayRows() - chromeRows) / 3));
3253
3389
  return Math.max(1, Math.min(configured, responsive));
3254
3390
  }
3255
3391
 
@@ -3261,20 +3397,31 @@ class QolSessionSearchComponent {
3261
3397
  return Math.max(1, Math.min(configured, responsive));
3262
3398
  }
3263
3399
 
3400
+ private forkConfirmMaxVisibleRows(): number {
3401
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
3402
+ const responsive = Math.max(1, this.maxOverlayRows() - 13);
3403
+ return Math.max(1, Math.min(configured, responsive));
3404
+ }
3405
+
3264
3406
  render(width: number): string[] {
3265
3407
  const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
3266
3408
  const renderWidth = Math.min(Math.max(48, width), configured);
3267
3409
  if (this.screen === "messages" && this.messagesState) return this.renderMessages(renderWidth, this.messagesState);
3268
3410
  if (this.screen === "actions" && this.actionState) return this.renderActions(renderWidth, this.actionState);
3269
- if (this.screen === "focus" && this.focusState) return this.renderFocus(renderWidth, this.focusState);
3411
+ if (this.screen === "confirmContext" && this.contextConfirmState) return this.renderContextConfirm(renderWidth, this.contextConfirmState);
3412
+ if (this.screen === "confirmFork" && this.forkConfirmState) return this.renderForkConfirm(renderWidth, this.forkConfirmState);
3270
3413
  return this.renderSearch(renderWidth);
3271
3414
  }
3272
3415
 
3273
3416
  handleInput(data: string): void {
3274
- if (this.screen === "messages") this.handleMessagesInput(data);
3275
- else if (this.screen === "actions") this.handleActionInput(data);
3276
- else if (this.screen === "focus") this.handleFocusInput(data);
3277
- else this.handleSearchInput(data);
3417
+ if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
3418
+ else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
3419
+ else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
3420
+ else if (this.screen === "confirmFork" && this.forkConfirmState) this.handleForkConfirmInput(data);
3421
+ else {
3422
+ this.screen = "search";
3423
+ this.handleSearchInput(data);
3424
+ }
3278
3425
  this.tui.requestRender();
3279
3426
  }
3280
3427
 
@@ -3282,12 +3429,29 @@ class QolSessionSearchComponent {
3282
3429
  this.searchState.selected = Math.max(0, Math.min(this.searchState.selected, Math.max(0, this.searchState.results.length - 1)));
3283
3430
  }
3284
3431
 
3432
+ private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
3433
+ if (scope === "all") return this.sessions;
3434
+ return this.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
3435
+ }
3436
+
3437
+ private updateResults(resetSelection = true): void {
3438
+ const state = this.searchState;
3439
+ const scopedSessions = this.sessionsForScope(state.scope);
3440
+ state.results = searchQolSessionHits(scopedSessions, state.query, this.cwd);
3441
+ state.total = scopedSessions.length;
3442
+ if (resetSelection) state.selected = 0;
3443
+ this.clampSelection();
3444
+ }
3445
+
3446
+ private toggleScope(): void {
3447
+ this.searchState.scope = this.searchState.scope === "current" ? "all" : "current";
3448
+ this.updateResults(true);
3449
+ }
3450
+
3285
3451
  private updateQuery(query: string, cursor: number): void {
3286
3452
  this.searchState.query = query;
3287
3453
  this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
3288
- this.searchState.results = searchQolSessions(this.sessions, query, this.cwd);
3289
- this.searchState.selected = 0;
3290
- this.clampSelection();
3454
+ this.updateResults(true);
3291
3455
  }
3292
3456
 
3293
3457
  private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
@@ -3302,12 +3466,15 @@ class QolSessionSearchComponent {
3302
3466
  return Math.max(0, messages.length - 1);
3303
3467
  }
3304
3468
 
3305
- private openMessages(result: QolSessionSearchResult): void {
3469
+ private openMessages(result: QolSessionSearchResult, preferredMessage?: QolSessionUserMessage): void {
3306
3470
  const messages = userMessagesForResult(result);
3471
+ const preferredIndex = preferredMessage
3472
+ ? messages.findIndex((message) => message.index === preferredMessage.index || (message.entryId && message.entryId === preferredMessage.entryId))
3473
+ : -1;
3307
3474
  this.messagesState = {
3308
3475
  messages,
3309
3476
  result,
3310
- selected: this.selectedMessageIndex(messages, this.searchState.query),
3477
+ selected: preferredIndex >= 0 ? preferredIndex : this.selectedMessageIndex(messages, this.searchState.query),
3311
3478
  };
3312
3479
  this.screen = "messages";
3313
3480
  }
@@ -3316,16 +3483,101 @@ class QolSessionSearchComponent {
3316
3483
  return `Focus on prompt #${message.index}: ${message.text}`;
3317
3484
  }
3318
3485
 
3486
+ private openContextConfirm(type: "summarize" | "newSession", result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
3487
+ this.contextConfirmState = { message, result, returnScreen, type };
3488
+ this.screen = "confirmContext";
3489
+ }
3490
+
3491
+ private returnFromContextConfirm(state: QolSessionContextConfirmState): void {
3492
+ this.contextConfirmState = undefined;
3493
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
3494
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
3495
+ else this.screen = "search";
3496
+ }
3497
+
3498
+ private confirmContextAction(state: QolSessionContextConfirmState): void {
3499
+ this.done({ customPrompt: this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
3500
+ }
3501
+
3502
+ private openForkConfirm(result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
3503
+ const messages = userMessagesForResult(result);
3504
+ const preferredIndex = messages.findIndex((candidate) => candidate.index === message.index || (message.entryId && candidate.entryId === message.entryId));
3505
+ this.forkConfirmState = {
3506
+ messages,
3507
+ result,
3508
+ returnScreen,
3509
+ selected: preferredIndex >= 0 ? preferredIndex : 0,
3510
+ };
3511
+ this.screen = "confirmFork";
3512
+ }
3513
+
3514
+ private returnFromForkConfirm(state: QolSessionForkConfirmState): void {
3515
+ this.forkConfirmState = undefined;
3516
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
3517
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
3518
+ else this.screen = "search";
3519
+ }
3520
+
3521
+ private confirmForkAction(state: QolSessionForkConfirmState): void {
3522
+ const message = state.messages[state.selected];
3523
+ if (!message) return;
3524
+ this.done({ message, result: state.result, type: "fork" });
3525
+ }
3526
+
3527
+ private returnFromActions(): void {
3528
+ this.actionState = undefined;
3529
+ if (this.actionReturnScreen === "messages" && this.messagesState) this.screen = "messages";
3530
+ else this.screen = "search";
3531
+ }
3532
+
3319
3533
  private handleSearchInput(data: string): void {
3320
3534
  const state = this.searchState;
3321
3535
  if (matchesKey(data, "escape")) {
3322
3536
  this.done({ type: "cancel" });
3323
3537
  return;
3324
3538
  }
3539
+ if (matchesKey(data, "tab")) {
3540
+ this.toggleScope();
3541
+ return;
3542
+ }
3325
3543
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3326
- const result = state.results[state.selected];
3327
- if (!result) return;
3328
- this.openMessages(result);
3544
+ const hit = state.results[state.selected];
3545
+ if (!hit) return;
3546
+ this.openMessages(hit.result, hit.message);
3547
+ return;
3548
+ }
3549
+ if (matchesKey(data, "alt+c")) {
3550
+ const hit = state.results[state.selected];
3551
+ if (hit) this.done({ message: hit.message, result: hit.result, type: "copy" });
3552
+ return;
3553
+ }
3554
+ if (matchesKey(data, "alt+f")) {
3555
+ const hit = state.results[state.selected];
3556
+ if (hit) this.openForkConfirm(hit.result, hit.message, "search");
3557
+ return;
3558
+ }
3559
+ if (matchesKey(data, "alt+r")) {
3560
+ const hit = state.results[state.selected];
3561
+ if (hit) this.done({ result: hit.result, type: "resume" });
3562
+ return;
3563
+ }
3564
+ if (matchesKey(data, "alt+i")) {
3565
+ const hit = state.results[state.selected];
3566
+ if (hit) this.openContextConfirm("summarize", hit.result, hit.message, "search");
3567
+ return;
3568
+ }
3569
+ if (matchesKey(data, "alt+n")) {
3570
+ const hit = state.results[state.selected];
3571
+ if (hit) this.openContextConfirm("newSession", hit.result, hit.message, "search");
3572
+ return;
3573
+ }
3574
+ if (matchesKey(data, "alt+a")) {
3575
+ const hit = state.results[state.selected];
3576
+ if (hit) {
3577
+ this.actionState = { message: hit.message, result: hit.result };
3578
+ this.actionReturnScreen = "search";
3579
+ this.screen = "actions";
3580
+ }
3329
3581
  return;
3330
3582
  }
3331
3583
  if (matchesKey(data, "up")) {
@@ -3333,7 +3585,7 @@ class QolSessionSearchComponent {
3333
3585
  return;
3334
3586
  }
3335
3587
  if (matchesKey(data, "down")) {
3336
- state.selected = Math.min(state.results.length - 1, state.selected + 1);
3588
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + 1);
3337
3589
  return;
3338
3590
  }
3339
3591
  if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
@@ -3341,7 +3593,7 @@ class QolSessionSearchComponent {
3341
3593
  return;
3342
3594
  }
3343
3595
  if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3344
- state.selected = Math.min(state.results.length - 1, state.selected + this.searchMaxVisibleRows());
3596
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + this.searchMaxVisibleRows());
3345
3597
  return;
3346
3598
  }
3347
3599
  if (matchesKey(data, "left")) {
@@ -3394,14 +3646,35 @@ class QolSessionSearchComponent {
3394
3646
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3395
3647
  const message = state.messages[state.selected];
3396
3648
  if (!message) return;
3397
- this.actionState = { actionIndex: 0, message, result: state.result };
3649
+ this.actionState = { message, result: state.result };
3650
+ this.actionReturnScreen = "messages";
3398
3651
  this.screen = "actions";
3399
3652
  return;
3400
3653
  }
3401
- if (data.toLowerCase() === "r") {
3654
+ if (matchesKey(data, "alt+r")) {
3402
3655
  this.done({ result: state.result, type: "resume" });
3403
3656
  return;
3404
3657
  }
3658
+ if (matchesKey(data, "alt+c")) {
3659
+ const message = state.messages[state.selected];
3660
+ if (message) this.done({ message, result: state.result, type: "copy" });
3661
+ return;
3662
+ }
3663
+ if (matchesKey(data, "alt+f")) {
3664
+ const message = state.messages[state.selected];
3665
+ if (message) this.openForkConfirm(state.result, message, "messages");
3666
+ return;
3667
+ }
3668
+ if (matchesKey(data, "alt+i")) {
3669
+ const message = state.messages[state.selected];
3670
+ if (message) this.openContextConfirm("summarize", state.result, message, "messages");
3671
+ return;
3672
+ }
3673
+ if (matchesKey(data, "alt+n")) {
3674
+ const message = state.messages[state.selected];
3675
+ if (message) this.openContextConfirm("newSession", state.result, message, "messages");
3676
+ return;
3677
+ }
3405
3678
  if (matchesKey(data, "up")) {
3406
3679
  state.selected = Math.max(0, state.selected - 1);
3407
3680
  return;
@@ -3431,146 +3704,198 @@ class QolSessionSearchComponent {
3431
3704
  const state = this.actionState;
3432
3705
  if (!state) return;
3433
3706
  if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3434
- this.screen = "messages";
3707
+ this.returnFromActions();
3435
3708
  return;
3436
3709
  }
3437
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
3438
- state.actionIndex = (state.actionIndex + 1) % QOL_SESSION_ACTIONS.length;
3710
+ if (matchesKey(data, "alt+c")) {
3711
+ this.done({ message: state.message, result: state.result, type: "copy" });
3439
3712
  return;
3440
3713
  }
3441
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
3442
- state.actionIndex = (state.actionIndex - 1 + QOL_SESSION_ACTIONS.length) % QOL_SESSION_ACTIONS.length;
3714
+ if (matchesKey(data, "alt+f")) {
3715
+ this.openForkConfirm(state.result, state.message, "actions");
3443
3716
  return;
3444
3717
  }
3445
- if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3446
- const action = QOL_SESSION_ACTIONS[state.actionIndex];
3447
- if (action === "back") {
3448
- this.screen = "messages";
3449
- return;
3450
- }
3451
- if (action === "resume" || action === "copy") {
3452
- this.done({ message: state.message, result: state.result, type: action });
3453
- return;
3454
- }
3455
- this.focusState = { cursor: 0, message: state.message, prompt: "", result: state.result, type: action };
3456
- this.screen = "focus";
3718
+ if (matchesKey(data, "alt+r")) {
3719
+ this.done({ result: state.result, type: "resume" });
3720
+ return;
3721
+ }
3722
+ if (matchesKey(data, "alt+i")) {
3723
+ this.openContextConfirm("summarize", state.result, state.message, "actions");
3724
+ return;
3725
+ }
3726
+ if (matchesKey(data, "alt+n")) {
3727
+ this.openContextConfirm("newSession", state.result, state.message, "actions");
3728
+ return;
3457
3729
  }
3458
3730
  }
3459
3731
 
3460
- private handleFocusInput(data: string): void {
3461
- const state = this.focusState;
3732
+ private handleContextConfirmInput(data: string): void {
3733
+ const state = this.contextConfirmState;
3462
3734
  if (!state) return;
3463
- if (matchesKey(data, "escape")) {
3464
- this.screen = "actions";
3735
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3736
+ this.returnFromContextConfirm(state);
3465
3737
  return;
3466
3738
  }
3467
3739
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3468
- this.done({ customPrompt: state.prompt.trim() || this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
3469
- return;
3740
+ this.confirmContextAction(state);
3470
3741
  }
3471
- if (matchesKey(data, "left")) {
3472
- state.cursor = Math.max(0, state.cursor - 1);
3473
- return;
3474
- }
3475
- if (matchesKey(data, "right")) {
3476
- state.cursor = Math.min(state.prompt.length, state.cursor + 1);
3742
+ }
3743
+
3744
+ private handleForkConfirmInput(data: string): void {
3745
+ const state = this.forkConfirmState;
3746
+ if (!state) return;
3747
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3748
+ this.returnFromForkConfirm(state);
3477
3749
  return;
3478
3750
  }
3479
- if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3480
- state.cursor = 0;
3751
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3752
+ this.confirmForkAction(state);
3481
3753
  return;
3482
3754
  }
3483
- if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3484
- state.cursor = state.prompt.length;
3755
+ if (matchesKey(data, "up")) {
3756
+ state.selected = Math.max(0, state.selected - 1);
3485
3757
  return;
3486
3758
  }
3487
- if (matchesKey(data, "ctrl+u")) {
3488
- state.prompt = "";
3489
- state.cursor = 0;
3759
+ if (matchesKey(data, "down")) {
3760
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + 1);
3490
3761
  return;
3491
3762
  }
3492
- if (matchesKey(data, "ctrl+w") || matchesKey(data, "alt+backspace")) {
3493
- const before = state.prompt.slice(0, state.cursor);
3494
- const after = state.prompt.slice(state.cursor);
3495
- let i = before.length;
3496
- while (i > 0 && /\s/.test(before[i - 1]!)) i -= 1;
3497
- while (i > 0 && !/\s/.test(before[i - 1]!)) i -= 1;
3498
- state.prompt = `${before.slice(0, i)}${after}`;
3499
- state.cursor = i;
3763
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3764
+ state.selected = Math.max(0, state.selected - this.forkConfirmMaxVisibleRows());
3500
3765
  return;
3501
3766
  }
3502
- if (matchesKey(data, "backspace")) {
3503
- if (state.cursor > 0) {
3504
- state.prompt = `${state.prompt.slice(0, state.cursor - 1)}${state.prompt.slice(state.cursor)}`;
3505
- state.cursor -= 1;
3506
- }
3767
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3768
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + this.forkConfirmMaxVisibleRows());
3507
3769
  return;
3508
3770
  }
3509
- if (matchesKey(data, "delete")) {
3510
- if (state.cursor < state.prompt.length) state.prompt = `${state.prompt.slice(0, state.cursor)}${state.prompt.slice(state.cursor + 1)}`;
3771
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3772
+ state.selected = 0;
3511
3773
  return;
3512
3774
  }
3513
- if (isPrintableInput(data)) {
3514
- state.prompt = `${state.prompt.slice(0, state.cursor)}${data}${state.prompt.slice(state.cursor)}`;
3515
- state.cursor += data.length;
3775
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3776
+ state.selected = Math.max(0, state.messages.length - 1);
3516
3777
  }
3517
3778
  }
3518
3779
 
3519
3780
  private renderSearch(width: number): string[] {
3520
- const { bottom, divider, empty, filledRow, inner, row, selectedRow, top } = boxParts(width, this.theme);
3781
+ const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
3521
3782
  const state = this.searchState;
3522
- const lines: string[] = [top("Session Search", `${state.results.length}/${state.total} shown`), empty()];
3783
+ const compact = this.compactSearchLayout();
3784
+ const lines: string[] = [top("Session Search", `${state.results.length} hits`), empty()];
3523
3785
  const accent = (s: string) => this.theme.fg("accent", s);
3524
3786
  const dim = (s: string) => this.theme.fg("dim", s);
3525
3787
  const muted = (s: string) => this.theme.fg("muted", s);
3526
- const pair = (left: string, right: string) => {
3527
- const leftWidth = Math.max(1, inner - visibleWidth(right) - 1);
3528
- const clippedLeft = truncateToWidth(left, leftWidth, "…");
3529
- const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3530
- return `${clippedLeft}${" ".repeat(gap)}${right}`;
3531
- };
3532
3788
  const cursorChar = state.query[state.cursor] ?? " ";
3533
3789
  const queryDisplay = `${state.query.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.query.slice(state.cursor + (state.cursor < state.query.length ? 1 : 0))}`;
3790
+ lines.push(row(this.renderScopeTabs(inner)));
3791
+ if (!compact) lines.push(empty());
3534
3792
  lines.push(filledRow(` > ${queryDisplay}`));
3535
- lines.push(row(dim(`${state.total} sessions · re:<pattern> regex · "phrase" exact`)));
3536
- lines.push(empty(), divider(), empty());
3793
+ if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
3794
+ lines.push(divider());
3795
+
3537
3796
  if (state.results.length === 0) {
3538
- lines.push(row(muted(state.query.trim() ? "No sessions match your search" : "No sessions found")), empty());
3797
+ lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
3539
3798
  } else {
3540
- const maxVisible = this.searchMaxVisibleRows();
3541
- const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3542
- const end = Math.min(start + maxVisible, state.results.length);
3543
- const rowBudget = Math.max(10, inner);
3544
- for (let i = start; i < end; i++) {
3545
- const result = state.results[i]!;
3546
- const selected = i === state.selected;
3547
- const title = sessionResumeTitle(result);
3548
- const rightPlain = `${promptCountLabel(sessionUserPromptCount(result))} · ${formatSessionSearchDate(result.modified)}`;
3549
- const right = selected ? this.theme.fg("text", rightPlain) : dim(rightPlain);
3550
- const titleBudget = Math.max(12, rowBudget - visibleWidth(right) - 1);
3551
- const titleText = truncateToWidth(title, titleBudget, "…");
3552
- const left = selected ? this.theme.bold(accent(titleText)) : this.theme.bold(titleText);
3553
- const titleRow = pair(left, right);
3554
- lines.push(selected ? selectedRow(titleRow) : row(titleRow));
3555
-
3556
- const folder = shortPathForUi(result.cwd || dirname(result.path));
3557
- const project = sessionDisplayName(result);
3558
- lines.push(row(muted(truncateToWidth(`${project} · ${folder}`, inner, "…"))));
3559
-
3560
- const snippet = state.query.trim() ? (result.snippets[0] || lastUserMessageSnippet(result)) : lastUserMessageSnippet(result);
3561
- const label = state.query.trim() ? "match" : "last";
3562
- const snippetPrefix = `${dim(`${label}:`)} `;
3563
- lines.push(row(`${snippetPrefix}${truncateToWidth(styleSessionSnippet(snippet, state.query, this.theme), inner - visibleWidth(snippetPrefix), "…")}`));
3564
- if (i < end - 1) lines.push(row(this.theme.fg("borderMuted", "─".repeat(Math.max(8, inner)))));
3565
- }
3566
- if (state.results.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent sessions"}`)));
3799
+ lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
3800
+ }
3801
+
3802
+ lines.push(divider());
3803
+ if (compact) {
3804
+ lines.push(row(`${ansiYellow("alt+c/f/r/i/n/a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
3805
+ } else {
3806
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
3807
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")} ${ansiYellow("alt+a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
3567
3808
  }
3568
- lines.push(divider(), empty());
3569
- lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompts")} ${ansiYellow("ctrl+u")} ${dim("clear")} ${ansiYellow("esc")} ${dim("close")}`));
3570
3809
  lines.push(bottom());
3571
3810
  return lines;
3572
3811
  }
3573
3812
 
3813
+ private renderSearchHitPane(
3814
+ inner: number,
3815
+ row: (content?: string) => string,
3816
+ dim: (s: string) => string,
3817
+ muted: (s: string) => string,
3818
+ accent: (s: string) => string,
3819
+ ): string[] {
3820
+ const state = this.searchState;
3821
+ const compact = this.compactSearchLayout();
3822
+ const maxVisible = this.searchMaxVisibleRows();
3823
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3824
+ const end = Math.min(start + maxVisible, state.results.length);
3825
+ const gap = ` ${dim("│")} `;
3826
+ const twoPane = inner >= 72;
3827
+ const rightWidth = twoPane ? Math.max(24, Math.floor(inner * 0.43)) : 0;
3828
+ const leftWidth = twoPane ? Math.max(24, inner - rightWidth - visibleWidth(gap)) : inner;
3829
+ const paneRows = Math.max(1, (end - start) * 3 - 1);
3830
+ const selectedHit = state.results[state.selected];
3831
+ const rightLines = twoPane ? this.renderPromptPreviewLines(selectedHit, rightWidth, paneRows, dim, muted, accent) : [];
3832
+ const fixed = (content: string, colWidth: number, selected = false): string => {
3833
+ const safe = content.replace(/[\r\n\t]+/g, " ");
3834
+ const clipped = truncateToWidth(safe, colWidth, "");
3835
+ const padded = clipped + " ".repeat(Math.max(0, colWidth - visibleWidth(clipped)));
3836
+ return selected ? this.theme.bg("selectedBg", padded) : padded;
3837
+ };
3838
+ const leftLines: string[] = [];
3839
+ for (let i = start; i < end; i++) {
3840
+ const hit = state.results[i]!;
3841
+ const selected = i === state.selected;
3842
+ const promptDate = new Date(promptRecencyTime(hit));
3843
+ const metadata = dim(`#${hit.message.index} · ${formatSessionSearchDate(promptDate)}`);
3844
+ const titleText = truncateToWidth(sessionResumeTitle(hit.result), Math.max(8, leftWidth - visibleWidth(metadata) - 1), "…");
3845
+ const title = accent(titleText);
3846
+ const snippet = styleSessionSnippet(hit.snippet || hit.message.text, state.query, this.theme);
3847
+ leftLines.push(fixed(selected ? this.theme.bold(snippet) : snippet, leftWidth, selected));
3848
+ leftLines.push(fixed(`${title} ${metadata}`, leftWidth, selected));
3849
+ if (i < end - 1) leftLines.push(fixed(dim("─".repeat(Math.max(8, leftWidth))), leftWidth));
3850
+ }
3851
+ const output: string[] = [];
3852
+ for (let i = 0; i < leftLines.length; i++) {
3853
+ if (twoPane) output.push(row(`${leftLines[i] ?? ""}${gap}${rightLines[i] ?? " ".repeat(rightWidth)}`));
3854
+ else output.push(row(leftLines[i] ?? ""));
3855
+ }
3856
+ if (!twoPane && selectedHit) {
3857
+ output.push(row(dim("─".repeat(Math.max(8, inner)))));
3858
+ for (const line of this.renderPromptPreviewLines(selectedHit, inner, Math.min(5, paneRows), dim, muted, accent)) output.push(row(line));
3859
+ }
3860
+ if (!compact && state.results.length > maxVisible) output.push(row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent prompts"}`)));
3861
+ return output;
3862
+ }
3863
+
3864
+ private renderPromptPreviewLines(
3865
+ hit: QolSessionSearchHit | undefined,
3866
+ width: number,
3867
+ maxLines: number,
3868
+ dim: (s: string) => string,
3869
+ muted: (s: string) => string,
3870
+ accent: (s: string) => string,
3871
+ ): string[] {
3872
+ const fixed = (content: string): string => {
3873
+ const clipped = truncateToWidth(content.replace(/[\r\n\t]+/g, " "), width, "…");
3874
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
3875
+ };
3876
+ if (!hit) return Array.from({ length: maxLines }, () => fixed(""));
3877
+ const title = accent("Prompt preview");
3878
+ const cwd = muted(truncateToWidth(shortPathForUi(hit.result.cwd || hit.result.path), width, "…"));
3879
+ const bodyBudget = Math.max(1, maxLines - 3);
3880
+ const body = wrapVisible(styleSessionSnippet(hit.message.text, this.searchState.query, this.theme), width, bodyBudget);
3881
+ const lines = [fixed(title), fixed(cwd), fixed(dim("─".repeat(Math.max(8, width)))), ...body.map(fixed)];
3882
+ while (lines.length < maxLines) lines.push(fixed(""));
3883
+ return lines.slice(0, maxLines);
3884
+ }
3885
+
3886
+ private renderScopeTabs(inner: number): string {
3887
+ const tabs: { id: QolSessionSearchScope; label: string }[] = [
3888
+ { id: "current", label: "Project" },
3889
+ { id: "all", label: "All" },
3890
+ ];
3891
+ const parts = tabs.map((tab) => {
3892
+ const label = ` ${truncateToWidth(tab.label, 18, "…")} `;
3893
+ if (tab.id === this.searchState.scope) return this.theme.fg("accent", this.theme.inverse(this.theme.bold(label)));
3894
+ return this.theme.bg("selectedBg", this.theme.fg("accent", label));
3895
+ });
3896
+ return truncateToWidth(parts.join(" "), inner, "");
3897
+ }
3898
+
3574
3899
  private renderMessages(width: number, state: QolSessionMessagesState): string[] {
3575
3900
  const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3576
3901
  const accent = (s: string) => this.theme.fg("accent", s);
@@ -3584,11 +3909,10 @@ class QolSessionSearchComponent {
3584
3909
  const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3585
3910
  return `${clippedLeft}${" ".repeat(gap)}${right}`;
3586
3911
  };
3587
- const right = dim(`${promptCountLabel(state.messages.length)} · ${formatSessionSearchDate(result.modified)}`);
3912
+ const right = dim(formatSessionSearchDate(result.modified));
3588
3913
  const title = truncateToWidth(sessionResumeTitle(result), Math.max(12, inner - visibleWidth(right) - 1), "…");
3589
3914
  lines.push(row(pair(this.theme.bold(accent(title)), right)));
3590
3915
  lines.push(row(muted(truncateToWidth(shortPathForUi(result.cwd || result.path), inner, "…"))));
3591
- lines.push(row(dim(`${state.messages.length} user prompt${state.messages.length === 1 ? "" : "s"} · enter opens prompt actions · r resumes latest session state`)));
3592
3916
  lines.push(empty(), divider(), empty());
3593
3917
 
3594
3918
  const maxVisible = this.messageMaxVisibleRows();
@@ -3604,9 +3928,9 @@ class QolSessionSearchComponent {
3604
3928
  const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
3605
3929
  lines.push(selected ? selectedRow(messageRow) : row(messageRow));
3606
3930
  }
3607
- if (state.messages.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.messages.length} user prompts`)));
3608
3931
  lines.push(divider(), empty());
3609
- lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompt actions")} ${ansiYellow("r")} ${dim("resume session")} ${ansiYellow("esc")} ${dim("sessions")}`));
3932
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
3933
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
3610
3934
  lines.push(bottom());
3611
3935
  return lines;
3612
3936
  }
@@ -3624,46 +3948,85 @@ class QolSessionSearchComponent {
3624
3948
  lines.push(row(dim("Selected prompt")));
3625
3949
  const wrapped = wrapVisible(state.message.text, inner, 4);
3626
3950
  for (const line of wrapped) lines.push(row(line));
3627
- lines.push(empty());
3628
- lines.push(row(dim("Resume Session From Here restores this prompt in the selected session.")));
3629
- lines.push(row(dim("Inject Context adds a summary here; New Session + Context imports it into a new session.")));
3630
3951
  lines.push(empty(), divider(), empty());
3631
- const actions = QOL_SESSION_ACTIONS.map((action, index) => {
3632
- const label = QOL_SESSION_ACTION_LABELS[action];
3633
- return index === state.actionIndex ? this.theme.bold(accent(`[${label}]`)) : dim(`[${label}]`);
3634
- });
3635
- for (const line of wrapVisible(actions.join(" "), inner, 2)) lines.push(row(line));
3952
+ const helpLines = [
3953
+ "Fork session from here opens the source session at the point before this prompt and places the prompt in the editor; submit to branch from there.",
3954
+ "Copy Prompt copies this prompt into your current editor.",
3955
+ "Inject + Context summarizes the source session into the current session, focused on this prompt.",
3956
+ "New + Context creates a fresh session and imports that focused summary there.",
3957
+ ];
3958
+ for (const help of helpLines) {
3959
+ for (const line of wrapVisible(dim(help), inner, 2)) lines.push(row(line));
3960
+ }
3961
+ lines.push(empty(), divider(), empty());
3962
+ lines.push(row(`${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
3963
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
3964
+ lines.push(bottom());
3965
+ return lines;
3966
+ }
3967
+
3968
+ private renderForkConfirm(width: number, state: QolSessionForkConfirmState): string[] {
3969
+ const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3970
+ const accent = (s: string) => this.theme.fg("accent", s);
3971
+ const dim = (s: string) => this.theme.fg("dim", s);
3972
+ const muted = (s: string) => this.theme.fg("muted", s);
3973
+ const lines: string[] = [top("Fork Session", `${state.selected + 1}/${state.messages.length}`), empty()];
3974
+ const title = truncateToWidth(sessionResumeTitle(state.result), inner, "…");
3975
+ lines.push(row(this.theme.bold(accent(title))));
3976
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
3977
+ lines.push(empty(), divider(), empty());
3978
+ for (const line of wrapVisible(dim("Choose the prompt to fork from. Pi will open the source session before that prompt and place the prompt in the editor."), inner, 2)) {
3979
+ lines.push(row(line));
3980
+ }
3636
3981
  lines.push(empty());
3637
- lines.push(row(`${ansiYellow("tab")}/${ansiYellow("←→")} ${dim("cycle")} ${ansiYellow("enter")} ${dim("choose")} ${ansiYellow("esc")} ${dim("prompts")}`));
3982
+
3983
+ const maxVisible = this.forkConfirmMaxVisibleRows();
3984
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
3985
+ const end = Math.min(start + maxVisible, state.messages.length);
3986
+ for (let i = start; i < end; i++) {
3987
+ const message = state.messages[i]!;
3988
+ const selected = i === state.selected;
3989
+ const numberPlain = `#${message.index}`;
3990
+ const number = selected ? this.theme.fg("text", numberPlain) : dim(numberPlain);
3991
+ const textWidth = Math.max(12, inner - visibleWidth(number) - 1);
3992
+ const text = truncateToWidth(message.text, textWidth, "…");
3993
+ const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
3994
+ lines.push(selected ? selectedRow(messageRow) : row(messageRow));
3995
+ }
3996
+ if (state.messages.length > maxVisible) lines.push(row(dim(`${state.selected + 1}/${state.messages.length} prompts`)));
3997
+ lines.push(divider(), empty());
3998
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
3638
3999
  lines.push(bottom());
3639
4000
  return lines;
3640
4001
  }
3641
4002
 
3642
- private renderFocus(width: number, state: QolSessionFocusState): string[] {
3643
- const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
4003
+ private renderContextConfirm(width: number, state: QolSessionContextConfirmState): string[] {
4004
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
3644
4005
  const accent = (s: string) => this.theme.fg("accent", s);
3645
4006
  const dim = (s: string) => this.theme.fg("dim", s);
3646
4007
  const muted = (s: string) => this.theme.fg("muted", s);
3647
- const action = state.type === "newSession" ? "New Session + Context" : "Inject Context";
3648
- const lines: string[] = [top("Context Focus", action), empty()];
3649
- lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`-> ${action}`)}`));
3650
- lines.push(row(muted(truncateToWidth(`Default focus: #${state.message.index} ${state.message.text}`, inner, "…"))));
4008
+ const action = state.type === "newSession" ? "New + Context" : "Inject + Context";
4009
+ const verb = state.type === "newSession" ? "create new session with context" : "inject context";
4010
+ const lines: string[] = [top(action), empty()];
4011
+ lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`#${state.message.index}`)}`));
4012
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
3651
4013
  lines.push(empty(), divider(), empty());
3652
- const prefix = "> ";
3653
- const textWidth = Math.max(10, inner - visibleWidth(prefix));
3654
- if (!state.prompt) {
3655
- lines.push(filledRow(`${prefix}${this.theme.inverse(" ")}${muted(" optional focus override; Enter uses the selected prompt")}`));
3656
- } else {
3657
- const cursorChar = state.prompt[state.cursor] ?? " ";
3658
- const text = `${state.prompt.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.prompt.slice(state.cursor + (state.cursor < state.prompt.length ? 1 : 0))}`;
3659
- const wrapped = wrapVisible(text, textWidth, 5);
3660
- for (let i = 0; i < wrapped.length; i++) lines.push(filledRow(`${i === 0 ? prefix : " ".repeat(visibleWidth(prefix))}${wrapped[i]}`));
4014
+ const details = [
4015
+ "This summarizes the entire source session, including messages after this prompt.",
4016
+ "The selected prompt is used as the focus for the summary, not as a cutoff point.",
4017
+ state.type === "newSession"
4018
+ ? "New + Context creates a fresh session and imports that focused summary there."
4019
+ : "Inject + Context adds that focused summary to your current session.",
4020
+ ];
4021
+ for (const detail of details) {
4022
+ for (const line of wrapVisible(dim(detail), inner, 2)) lines.push(row(line));
3661
4023
  }
3662
4024
  lines.push(empty(), divider(), empty());
3663
- lines.push(row(`${ansiYellow("enter")} ${dim(action === "Inject Context" ? "inject context" : "create session with context")} ${ansiYellow("esc")} ${dim("actions")}`));
4025
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
3664
4026
  lines.push(bottom());
3665
4027
  return lines;
3666
4028
  }
4029
+
3667
4030
  }
3668
4031
 
3669
4032
  function wrapVisible(text: string, width: number, maxLines: number): string[] {
@@ -3728,7 +4091,6 @@ class QolSessionSearchLoadingComponent {
3728
4091
  empty(),
3729
4092
  divider(),
3730
4093
  empty(),
3731
- row(`${ansiYellow("esc")} ${dim("cancel")} ${ansiYellow("ctrl+c")} ${dim("cancel")}`),
3732
4094
  bottom(),
3733
4095
  ];
3734
4096
  }
@@ -3851,7 +4213,7 @@ async function buildSessionSearchContextMessageWithLoader(ctx: ExtensionContext,
3851
4213
 
3852
4214
  async function injectSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3853
4215
  const title = sessionDisplayName(result);
3854
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject Context");
4216
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject + Context");
3855
4217
  pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { deliverAs: "followUp", triggerTurn: false });
3856
4218
  ctx.ui.notify(`Session context injected: ${title}`, "info");
3857
4219
  }
@@ -3860,9 +4222,48 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
3860
4222
  return ctx as ExtensionContext & Partial<ExtensionCommandContext>;
3861
4223
  }
3862
4224
 
4225
+ function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
4226
+ const id = `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
4227
+ qolSessionSearchPendingActions.set(id, action);
4228
+ ctx.ui.setEditorText(`/search:resume-pending ${id}`);
4229
+ ctx.ui.notify(`${sessionDisplayName(action.result!)} — press Enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
4230
+ }
4231
+
4232
+ async function runSessionSearchResumeOrFork(ctx: ExtensionContext, action: QolSessionPaletteAction): Promise<boolean> {
4233
+ if (!action.result || (action.type !== "resume" && action.type !== "fork")) return true;
4234
+ const commandCtx = asCommandContext(ctx);
4235
+ if (typeof commandCtx.switchSession !== "function") return false;
4236
+ const targetTitle = sessionDisplayName(action.result);
4237
+ const selectedMessage = action.type === "fork" ? action.message : undefined;
4238
+ let replacementStarted = false;
4239
+ try {
4240
+ const result = await commandCtx.switchSession(action.result.path, {
4241
+ withSession: async (replacementCtx: any) => {
4242
+ replacementStarted = true;
4243
+ if (selectedMessage) {
4244
+ const manager = replacementCtx.sessionManager as any;
4245
+ if (selectedMessage.entryId && selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
4246
+ else if (selectedMessage.entryId && selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
4247
+ replacementCtx.ui.setEditorText(selectedMessage.text);
4248
+ const timer = setTimeout(() => {
4249
+ replacementCtx.ui.notify(`Forked session: ${targetTitle} at prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
4250
+ }, 0);
4251
+ timer.unref?.();
4252
+ return;
4253
+ }
4254
+ replacementCtx.ui.notify(`Resumed session: ${targetTitle}`, "info");
4255
+ },
4256
+ });
4257
+ if (result.cancelled) ctx.ui.notify(selectedMessage ? "Fork cancelled" : "Resume cancelled", "info");
4258
+ } catch (error) {
4259
+ if (!replacementStarted) ctx.ui.notify(`${selectedMessage ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
4260
+ }
4261
+ return true;
4262
+ }
4263
+
3863
4264
  async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3864
4265
  const title = sessionDisplayName(result);
3865
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New Session + Context");
4266
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New + Context");
3866
4267
 
3867
4268
  const commandCtx = asCommandContext(ctx);
3868
4269
  if (typeof commandCtx.newSession === "function") {
@@ -3891,7 +4292,7 @@ async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: Extensio
3891
4292
  }
3892
4293
  setPendingSessionSearchMessage(message);
3893
4294
  ctx.ui.setEditorText("/new");
3894
- ctx.ui.notify(`${title} — press Enter to create a new session with this context`, "info");
4295
+ ctx.ui.notify(`${title} — press enter to create a new session with this context`, "info");
3895
4296
  }
3896
4297
 
3897
4298
  async function consumePendingSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, reason: unknown): Promise<void> {
@@ -3925,35 +4326,8 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3925
4326
  releaseModalLock();
3926
4327
  }
3927
4328
  if (!action || action.type === "cancel" || !action.result) return;
3928
- if (action.type === "resume") {
3929
- const commandCtx = asCommandContext(ctx);
3930
- if (typeof commandCtx.switchSession === "function") {
3931
- const targetTitle = sessionDisplayName(action.result);
3932
- const selectedMessage = action.message;
3933
- let replacementStarted = false;
3934
- try {
3935
- const result = await commandCtx.switchSession(action.result.path, {
3936
- withSession: async (replacementCtx: any) => {
3937
- replacementStarted = true;
3938
- if (selectedMessage?.entryId) {
3939
- const manager = replacementCtx.sessionManager as any;
3940
- if (selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
3941
- else if (selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
3942
- replacementCtx.ui.setEditorText(selectedMessage.text);
3943
- replacementCtx.ui.notify(`Resumed ${targetTitle} from prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
3944
- return;
3945
- }
3946
- replacementCtx.ui.notify(`Resumed session: ${targetTitle}`, "info");
3947
- },
3948
- });
3949
- if (result.cancelled) ctx.ui.notify("Resume cancelled", "info");
3950
- } catch (error) {
3951
- if (!replacementStarted) ctx.ui.notify(`Resume failed: ${stringifyError(error)}`, "error");
3952
- }
3953
- return;
3954
- }
3955
- ctx.ui.setEditorText(`/resume ${quoteSessionSearchArg(action.result.path)}`);
3956
- ctx.ui.notify(`${sessionDisplayName(action.result)} — press Enter to resume`, "info");
4329
+ if (action.type === "resume" || action.type === "fork") {
4330
+ if (!(await runSessionSearchResumeOrFork(ctx, action))) queueSessionSearchCommandAction(ctx, action);
3957
4331
  return;
3958
4332
  }
3959
4333
  if (action.type === "copy") {
@@ -3967,7 +4341,7 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3967
4341
  await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
3968
4342
  } catch (error) {
3969
4343
  const message = stringifyError(error);
3970
- ctx.ui.notify(/cancelled/i.test(message) ? "Inject context cancelled" : `Inject context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
4344
+ ctx.ui.notify(/cancelled/i.test(message) ? "Inject + Context cancelled" : `Inject + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3971
4345
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3972
4346
  }
3973
4347
  return;
@@ -3977,16 +4351,12 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3977
4351
  await createNewSessionWithSearchContext(pi, ctx, action.result, action.customPrompt);
3978
4352
  } catch (error) {
3979
4353
  const message = stringifyError(error);
3980
- ctx.ui.notify(/cancelled/i.test(message) ? "New Session + Context cancelled" : `New Session + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
4354
+ ctx.ui.notify(/cancelled/i.test(message) ? "New + Context cancelled" : `New + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3981
4355
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3982
4356
  }
3983
4357
  }
3984
4358
  }
3985
4359
 
3986
- function quoteSessionSearchArg(value: string): string {
3987
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3988
- }
3989
-
3990
4360
  function renderSessionSearchContextMessage(message: any, options: any, theme: Theme): Text {
3991
4361
  const raw = typeof message.content === "string"
3992
4362
  ? message.content
@@ -4573,6 +4943,19 @@ export default function qol(pi: ExtensionAPI): void {
4573
4943
  description: "Refresh the session search index",
4574
4944
  handler: async (_args, ctx) => handleSearchCommand("refresh", ctx),
4575
4945
  });
4946
+ pi.registerCommand("search:resume-pending", {
4947
+ description: "Run a pending session-search resume or fork action",
4948
+ handler: async (args, ctx) => {
4949
+ const id = args.trim();
4950
+ const action = qolSessionSearchPendingActions.get(id);
4951
+ if (!action) {
4952
+ ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
4953
+ return;
4954
+ }
4955
+ qolSessionSearchPendingActions.delete(id);
4956
+ if (!(await runSessionSearchResumeOrFork(ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
4957
+ },
4958
+ });
4576
4959
  const shortcut = sessionSearchShortcut();
4577
4960
  if (shortcut) {
4578
4961
  pi.registerShortcut(shortcut, {