@vanillagreen/pi-qol 1.0.4 → 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
 
@@ -21,9 +21,26 @@ const QUESTION_SERVICE_SYMBOL = Symbol.for("vstack.pi-questions.service");
21
21
  const QOL_NOTIFICATION_SERVICE_SYMBOL = Symbol.for("vstack.pi-qol.notification-service");
22
22
  const VSTACK_MODAL_LOCK_SYMBOL = Symbol.for("vstack.pi.modal-lock");
23
23
  const CAVEMAN_BRIDGE_SYMBOL = Symbol.for("vstack.pi.caveman");
24
+ const PI_AGENTS_STATUSLINE_SYMBOL = Symbol.for("vstack.pi-agents-tmux.statusline");
24
25
  const CAVEMAN_ICON_ACTIVE = "\uee9a";
25
26
  const CAVEMAN_ICON_INACTIVE = "\u{f19e0}";
26
27
 
28
+ type AgentAsciiColor = "red" | "green" | "yellow" | "blue" | "magenta" | "cyan";
29
+
30
+ interface PiAgentsStatuslineBridge {
31
+ getCurrentSubagent(cwd?: string): { name: string; color?: string } | undefined;
32
+ }
33
+
34
+ const AGENT_ASCII_COLOR_SEQUENCE: AgentAsciiColor[] = ["magenta", "green", "blue", "cyan", "yellow", "red"];
35
+ const AGENT_ASCII_BG: Record<AgentAsciiColor, number> = {
36
+ red: 41,
37
+ green: 42,
38
+ yellow: 43,
39
+ blue: 44,
40
+ magenta: 45,
41
+ cyan: 46,
42
+ };
43
+
27
44
  interface CavemanBridge {
28
45
  isActive(): boolean;
29
46
  getMode(): string;
@@ -41,6 +58,52 @@ function readCavemanBridge(): CavemanBridge | undefined {
41
58
  const value = host[CAVEMAN_BRIDGE_SYMBOL];
42
59
  return value && typeof value === "object" ? (value as CavemanBridge) : undefined;
43
60
  }
61
+
62
+ function readPiAgentsStatuslineBridge(): PiAgentsStatuslineBridge | undefined {
63
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
64
+ const value = host[PI_AGENTS_STATUSLINE_SYMBOL];
65
+ return value && typeof value === "object" && typeof (value as PiAgentsStatuslineBridge).getCurrentSubagent === "function"
66
+ ? (value as PiAgentsStatuslineBridge)
67
+ : undefined;
68
+ }
69
+
70
+ function normalizeAgentAsciiColor(value: string | undefined): AgentAsciiColor | undefined {
71
+ const normalized = value?.trim().toLowerCase().replace(/[^a-z]/g, "");
72
+ switch (normalized) {
73
+ case "red": return "red";
74
+ case "green": return "green";
75
+ case "yellow":
76
+ case "orange": return "yellow";
77
+ case "blue": return "blue";
78
+ case "magenta":
79
+ case "purple":
80
+ case "violet": return "magenta";
81
+ case "cyan":
82
+ case "teal": return "cyan";
83
+ default: return undefined;
84
+ }
85
+ }
86
+
87
+ function fallbackAgentAsciiColor(name: string): AgentAsciiColor {
88
+ let hash = 0;
89
+ for (const char of name) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
90
+ return AGENT_ASCII_COLOR_SEQUENCE[hash % AGENT_ASCII_COLOR_SEQUENCE.length] ?? "magenta";
91
+ }
92
+
93
+ function ansiAgentBg(color: AgentAsciiColor, text: string): string {
94
+ return `\x1b[30;${AGENT_ASCII_BG[color]}m${text}\x1b[39;49m`;
95
+ }
96
+
97
+ function subagentStatuslineMarker(cwd: string, maxInnerWidth = 24): { plain: string; styled: string } | undefined {
98
+ const bridgeInfo = readPiAgentsStatuslineBridge()?.getCurrentSubagent(cwd);
99
+ const envName = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
100
+ const rawName = bridgeInfo?.name?.trim() || envName;
101
+ if (!rawName) return undefined;
102
+ const color = normalizeAgentAsciiColor(bridgeInfo?.color) ?? normalizeAgentAsciiColor(process.env.PI_SUBAGENT_CHILD_COLOR) ?? fallbackAgentAsciiColor(rawName);
103
+ const inner = truncateToWidth(rawName, Math.max(1, maxInnerWidth), "…");
104
+ const plain = ` ${inner} `;
105
+ return { plain, styled: ansiAgentBg(color, plain) };
106
+ }
44
107
  const THINKING_TIMER_STORE_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.store");
45
108
  const THINKING_TIMER_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.patch");
46
109
  const SESSION_SEARCH_PENDING_SYMBOL = Symbol.for("vstack.pi-qol.session-search.pending-context");
@@ -62,7 +125,7 @@ const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
62
125
  const DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH = 120;
63
126
  const DEFAULT_SESSION_SEARCH_LIMIT = 40;
64
127
  const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
65
- const DEFAULT_SESSION_SEARCH_SHORTCUT = "f3";
128
+ const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
66
129
  const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
67
130
  const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
68
131
  const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
@@ -1257,25 +1320,29 @@ function cavemanIconTone(mode: string, active: boolean): "muted" | "text" | "suc
1257
1320
 
1258
1321
  function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">): string {
1259
1322
  const { label: contextLabel, percent } = statuslineContextInfo(ctx);
1260
- const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModelName(ctx)} / `;
1323
+ const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModelName(ctx)}`;
1324
+ const statusSeparator = " / ";
1261
1325
  const thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
1262
1326
  const thinkingChunk = thinkingLevel;
1263
- const contextChunk = ` (${contextLabel})`;
1327
+ const contextChunk = ` ${contextLabel}`;
1264
1328
  const cavemanBridge = readCavemanBridge();
1265
1329
  const cavemanVisible = !!cavemanBridge && (cavemanBridge.isStatusBadgeEnabled?.(ctx.cwd) ?? true);
1266
1330
  const caveman = cavemanVisible ? cavemanBridge : undefined;
1267
1331
  const cavemanActive = caveman?.isActive() ?? false;
1268
1332
  const cavemanGlyph = caveman ? (cavemanActive ? CAVEMAN_ICON_ACTIVE : CAVEMAN_ICON_INACTIVE) : "";
1269
1333
  const cavemanTone = cavemanIconTone(caveman?.getMode() ?? "off", cavemanActive);
1270
- const cavemanSegment = caveman ? ` / ${cavemanGlyph}` : "";
1271
- const contextSeparator = caveman ? " / " : "";
1272
- const leftPlain = `${projectChunk}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}`;
1273
- const rightPlain = percent === null ? "…%" : `${percent}%`;
1334
+ const cavemanSegment = caveman ? `${statusSeparator}${cavemanGlyph}` : "";
1335
+ const contextSeparator = caveman ? ` ${statusSeparator}` : "";
1336
+ const leftPlain = `${projectChunk}${statusSeparator}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}`;
1337
+ const percentPlain = percent === null ? "…%" : `${percent}%`;
1338
+ const subagentMarker = subagentStatuslineMarker(ctx.cwd);
1339
+ const rightPlain = subagentMarker ? `${percentPlain} ${subagentMarker.plain}` : percentPlain;
1274
1340
  const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
1341
+ const separatorColored = theme.fg("muted", statusSeparator);
1275
1342
  const leftColored = caveman
1276
- ? `${theme.fg("accent", projectChunk)}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", " / ")}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("accent", `${contextSeparator}${contextChunk.trimStart()}`)}`
1277
- : `${theme.fg("accent", projectChunk)}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", contextChunk)}`;
1278
- const right = theme.fg(percentColor, rightPlain);
1343
+ ? `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${separatorColored}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("muted", contextSeparator)}${theme.fg("accent", contextChunk.trimStart())}`
1344
+ : `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", contextChunk)}`;
1345
+ const right = subagentMarker ? `${theme.fg(percentColor, percentPlain)} ${subagentMarker.styled}` : theme.fg(percentColor, percentPlain);
1279
1346
  const minimumGap = 1;
1280
1347
  const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
1281
1348
  const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
@@ -2205,7 +2272,7 @@ function statusMessage(ctx: ExtensionContext): string {
2205
2272
  return [
2206
2273
  "Pi QOL status",
2207
2274
  `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
2208
- `Shift+Enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2275
+ `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2209
2276
  `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
2210
2277
  `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
2211
2278
  `Image chips: ${settingBoolean("showImageChips", true, ctx.cwd) ? "filled (placeholders and existing image paths)" : "off"}`,
@@ -2221,23 +2288,12 @@ function statusMessage(ctx: ExtensionContext): string {
2221
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"}`,
2222
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"}`,
2223
2290
  `Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
2224
- "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.",
2225
2292
  ].join("\n");
2226
2293
  }
2227
2294
 
2228
2295
 
2229
- type QolSessionSortMode = "recent" | "relevance";
2230
-
2231
- type QolSessionAction = "resume" | "copy" | "summarize" | "newSession" | "back";
2232
-
2233
- const QOL_SESSION_ACTIONS: QolSessionAction[] = ["resume", "copy", "summarize", "newSession", "back"];
2234
- const QOL_SESSION_ACTION_LABELS: Record<QolSessionAction, string> = {
2235
- resume: "Resume Session From Here",
2236
- copy: "Copy Prompt",
2237
- summarize: "Inject Context",
2238
- newSession: "New Session + Context",
2239
- back: "Back",
2240
- };
2296
+ type QolSessionSearchScope = "current" | "all";
2241
2297
 
2242
2298
  interface QolSessionSearchSession {
2243
2299
  allMessagesText: string;
@@ -2257,6 +2313,13 @@ interface QolSessionSearchResult extends QolSessionSearchSession {
2257
2313
  snippets: string[];
2258
2314
  }
2259
2315
 
2316
+ interface QolSessionSearchHit {
2317
+ message: QolSessionUserMessage;
2318
+ rank: number;
2319
+ result: QolSessionSearchResult;
2320
+ snippet: string;
2321
+ }
2322
+
2260
2323
  interface QolParsedSessionQuery {
2261
2324
  mode: "tokens" | "regex";
2262
2325
  tokens: Array<{ kind: "fuzzy" | "phrase"; value: string }>;
@@ -2267,8 +2330,9 @@ interface QolParsedSessionQuery {
2267
2330
  interface QolSessionSearchState {
2268
2331
  cursor: number;
2269
2332
  query: string;
2270
- results: QolSessionSearchResult[];
2333
+ results: QolSessionSearchHit[];
2271
2334
  selected: number;
2335
+ scope: QolSessionSearchScope;
2272
2336
  total: number;
2273
2337
  }
2274
2338
 
@@ -2287,26 +2351,31 @@ interface QolSessionMessagesState {
2287
2351
  }
2288
2352
 
2289
2353
  interface QolSessionActionState {
2290
- actionIndex: number;
2291
2354
  message: QolSessionUserMessage;
2292
2355
  result: QolSessionSearchResult;
2293
2356
  }
2294
2357
 
2295
- interface QolSessionFocusState {
2296
- cursor: number;
2358
+ interface QolSessionContextConfirmState {
2297
2359
  message: QolSessionUserMessage;
2298
- prompt: string;
2299
2360
  result: QolSessionSearchResult;
2361
+ returnScreen: "search" | "messages" | "actions";
2300
2362
  type: "summarize" | "newSession";
2301
2363
  }
2302
2364
 
2365
+ interface QolSessionForkConfirmState {
2366
+ messages: QolSessionUserMessage[];
2367
+ result: QolSessionSearchResult;
2368
+ returnScreen: "search" | "messages" | "actions";
2369
+ selected: number;
2370
+ }
2371
+
2303
2372
  interface QolSessionSearchPendingMessage {
2304
2373
  content: string;
2305
2374
  details: Record<string, unknown>;
2306
2375
  }
2307
2376
 
2308
2377
  interface QolSessionPaletteAction {
2309
- type: "cancel" | "resume" | "copy" | "summarize" | "newSession";
2378
+ type: "cancel" | "resume" | "fork" | "copy" | "summarize" | "newSession";
2310
2379
  customPrompt?: string;
2311
2380
  message?: QolSessionUserMessage;
2312
2381
  result?: QolSessionSearchResult;
@@ -2316,6 +2385,8 @@ let qolSessionSearchCache: QolSessionSearchSession[] = [];
2316
2385
  let qolSessionSearchLoadedAt = 0;
2317
2386
  let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
2318
2387
  const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
2388
+ const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
2389
+ let qolSessionSearchPendingActionCounter = 0;
2319
2390
 
2320
2391
  function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
2321
2392
  return (globalThis as unknown as Record<PropertyKey, unknown>)[SESSION_SEARCH_PENDING_SYMBOL] as QolSessionSearchPendingMessage | undefined;
@@ -2355,11 +2426,12 @@ function resolveSettingsRelativePath(value: string, settingsPath: string): strin
2355
2426
  }
2356
2427
 
2357
2428
  function sessionSearchShortcut(cwd?: string): string | undefined {
2358
- // 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,
2359
2430
  // keep shortcuts disabled even though the default shortcut is now conflict-free.
2360
2431
  if (!settingBoolean("sessionSearch.ctrlFShortcut", true, cwd)) return undefined;
2361
2432
  const shortcut = settingStringAllowEmpty("sessionSearch.shortcutKey", DEFAULT_SESSION_SEARCH_SHORTCUT, cwd).trim().toLowerCase();
2362
2433
  if (!shortcut || shortcut === "none" || shortcut === "off" || shortcut === "false") return undefined;
2434
+ if (shortcut === "f3") return DEFAULT_SESSION_SEARCH_SHORTCUT;
2363
2435
  return shortcut;
2364
2436
  }
2365
2437
 
@@ -2381,6 +2453,23 @@ function configuredSessionDir(cwd: string): string | undefined {
2381
2453
  return configured;
2382
2454
  }
2383
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
+
2384
2473
  async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
2385
2474
  const customSessionDir = configuredSessionDir(ctx.cwd);
2386
2475
  const infos = customSessionDir
@@ -2456,8 +2545,8 @@ function isPendingQueueHintText(text: string): boolean {
2456
2545
  return plain.startsWith("↳ ") && plain.includes("queued messages");
2457
2546
  }
2458
2547
 
2459
- function pendingQueueLine(text: string): string {
2460
- return ansiGreen(`| ${text}`);
2548
+ function pendingQueuePreviewLine(text: string): string {
2549
+ return ansiGreen(`┃ ${text}`);
2461
2550
  }
2462
2551
 
2463
2552
  function isQueuedMessageStatusText(text: string): boolean {
@@ -2500,7 +2589,8 @@ function installPendingQueueThemePatch(ctx: ExtensionContext): void {
2500
2589
  proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] = patch;
2501
2590
  proto.fg = function patchedQolFg(this: Theme, token: string, text: string): string {
2502
2591
  if (token === "dim" && typeof text === "string" && settingBoolean("pendingQueue.asciiGreen", true, patch.cwd)) {
2503
- if (isPendingQueuePreviewText(text) || isPendingQueueHintText(text)) return pendingQueueLine(text);
2592
+ if (isPendingQueuePreviewText(text)) return pendingQueuePreviewLine(text);
2593
+ if (isPendingQueueHintText(text)) return (patch.originalFg as (this: Theme, token: string, text: string) => string).call(this, token, ` ${text}`);
2504
2594
  }
2505
2595
  return (patch.originalFg as (this: Theme, token: string, text: string) => string).call(this, token, text);
2506
2596
  };
@@ -2864,6 +2954,16 @@ function messageContentText(content: unknown): string {
2864
2954
  }).filter(Boolean).join(" ");
2865
2955
  }
2866
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
+
2867
2967
  function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2868
2968
  const cached = qolSessionUserMessagesCache.get(sessionPath);
2869
2969
  if (cached) return cached;
@@ -2883,7 +2983,7 @@ function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2883
2983
  index: messages.length + 1,
2884
2984
  parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
2885
2985
  text,
2886
- timestamp: typeof message.timestamp === "number" ? message.timestamp : undefined,
2986
+ timestamp: sessionMessageTimestamp(entry, message),
2887
2987
  });
2888
2988
  }
2889
2989
  } catch {
@@ -2974,21 +3074,28 @@ function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
2974
3074
  return { mode: "tokens", tokens };
2975
3075
  }
2976
3076
 
2977
- function simpleFuzzyScore(needle: string, haystack: string): number | undefined {
2978
- const query = needle.toLowerCase();
2979
- 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);
2980
3084
  if (!query) return 0;
2981
- const direct = text.indexOf(query);
2982
- if (direct >= 0) return direct * 0.1;
2983
- let pos = 0;
2984
- let score = 0;
2985
- for (const char of query) {
2986
- const found = text.indexOf(char, pos);
2987
- if (found < 0) return undefined;
2988
- score += found - pos + 1;
2989
- 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;
2990
3097
  }
2991
- return score + 1000;
3098
+ return text.includes(query) ? 0 : undefined;
2992
3099
  }
2993
3100
 
2994
3101
  function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
@@ -2996,21 +3103,12 @@ function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedS
2996
3103
  if (parsed.mode === "regex") {
2997
3104
  if (!parsed.regex) return { matches: false, score: 0 };
2998
3105
  const index = text.search(parsed.regex);
2999
- 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 };
3000
3107
  }
3001
3108
  if (parsed.tokens.length === 0) return { matches: true, score: 0 };
3002
3109
  let score = 0;
3003
- let normalized: string | undefined;
3004
3110
  for (const token of parsed.tokens) {
3005
- if (token.kind === "phrase") {
3006
- normalized ??= normalizeSearchText(text);
3007
- const phrase = normalizeSearchText(token.value);
3008
- const index = normalized.indexOf(phrase);
3009
- if (index < 0) return { matches: false, score: 0 };
3010
- score += index * 0.1;
3011
- continue;
3012
- }
3013
- const tokenScore = simpleFuzzyScore(token.value, text);
3111
+ const tokenScore = searchStringScore(token.value, text);
3014
3112
  if (tokenScore === undefined) return { matches: false, score: 0 };
3015
3113
  score += tokenScore;
3016
3114
  }
@@ -3025,9 +3123,10 @@ function sessionSnippetSource(session: QolSessionSearchSession): string {
3025
3123
  return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
3026
3124
  }
3027
3125
 
3028
- function snippetAround(text: string, start: number, length: number, width: number): string {
3029
- const safeStart = Math.max(0, start - Math.floor(width / 3));
3030
- 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));
3031
3130
  const prefix = safeStart > 0 ? "…" : "";
3032
3131
  const suffix = safeEnd < text.length ? "…" : "";
3033
3132
  return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
@@ -3079,11 +3178,107 @@ function searchQolSessions(sessions: QolSessionSearchSession[], query: string, c
3079
3178
  if (!match.matches) continue;
3080
3179
  results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
3081
3180
  }
3082
- const sortMode = settingString("sessionSearch.sortMode", "relevance", cwd) === "recent" ? "recent" : "relevance" as QolSessionSortMode;
3083
- 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());
3084
3182
  return results.slice(0, limit);
3085
3183
  }
3086
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
+
3087
3282
  function styleSessionSnippet(snippet: string, query: string, theme: Theme): string {
3088
3283
  let styled = snippet;
3089
3284
  const parsed = parseSessionSearchQuery(query);
@@ -3143,11 +3338,14 @@ function isPrintableInput(data: string): boolean {
3143
3338
  }
3144
3339
 
3145
3340
  class QolSessionSearchComponent {
3146
- private screen: "search" | "messages" | "actions" | "focus" = "search";
3341
+ private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" = "search";
3147
3342
  private searchState: QolSessionSearchState;
3148
3343
  private messagesState: QolSessionMessagesState | undefined;
3149
3344
  private actionState: QolSessionActionState | undefined;
3150
- private focusState: QolSessionFocusState | undefined;
3345
+ private actionReturnScreen: "search" | "messages" = "messages";
3346
+ private contextConfirmState: QolSessionContextConfirmState | undefined;
3347
+ private forkConfirmState: QolSessionForkConfirmState | undefined;
3348
+
3151
3349
 
3152
3350
  constructor(
3153
3351
  private readonly done: (action: QolSessionPaletteAction) => void,
@@ -3158,12 +3356,15 @@ class QolSessionSearchComponent {
3158
3356
  initialQuery = "",
3159
3357
  ) {
3160
3358
  const query = initialQuery.trim();
3359
+ const scope = defaultSessionSearchScope(cwd);
3360
+ const scopedSessions = this.sessionsForScope(scope);
3161
3361
  this.searchState = {
3162
3362
  cursor: query.length,
3163
3363
  query,
3164
- results: searchQolSessions(sessions, query, cwd),
3364
+ results: searchQolSessionHits(scopedSessions, query, cwd),
3165
3365
  selected: 0,
3166
- total: sessions.length,
3366
+ scope,
3367
+ total: scopedSessions.length,
3167
3368
  };
3168
3369
  }
3169
3370
 
@@ -3172,16 +3373,19 @@ class QolSessionSearchComponent {
3172
3373
  private maxOverlayRows(): number {
3173
3374
  const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
3174
3375
  const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
3175
- 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;
3176
3381
  }
3177
3382
 
3178
3383
  private searchMaxVisibleRows(): number {
3179
3384
  const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
3180
- // Search results render as 3 content rows plus a divider between rows.
3181
- // The remaining chrome is title, search box, help text, scroll status,
3182
- // footer, and frame. Keep the rendered line count within overlay maxHeight
3183
- // so Pi does not clip the bottom of the popup on short terminals.
3184
- 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));
3185
3389
  return Math.max(1, Math.min(configured, responsive));
3186
3390
  }
3187
3391
 
@@ -3193,20 +3397,31 @@ class QolSessionSearchComponent {
3193
3397
  return Math.max(1, Math.min(configured, responsive));
3194
3398
  }
3195
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
+
3196
3406
  render(width: number): string[] {
3197
3407
  const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
3198
3408
  const renderWidth = Math.min(Math.max(48, width), configured);
3199
3409
  if (this.screen === "messages" && this.messagesState) return this.renderMessages(renderWidth, this.messagesState);
3200
3410
  if (this.screen === "actions" && this.actionState) return this.renderActions(renderWidth, this.actionState);
3201
- 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);
3202
3413
  return this.renderSearch(renderWidth);
3203
3414
  }
3204
3415
 
3205
3416
  handleInput(data: string): void {
3206
- if (this.screen === "messages") this.handleMessagesInput(data);
3207
- else if (this.screen === "actions") this.handleActionInput(data);
3208
- else if (this.screen === "focus") this.handleFocusInput(data);
3209
- 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
+ }
3210
3425
  this.tui.requestRender();
3211
3426
  }
3212
3427
 
@@ -3214,12 +3429,29 @@ class QolSessionSearchComponent {
3214
3429
  this.searchState.selected = Math.max(0, Math.min(this.searchState.selected, Math.max(0, this.searchState.results.length - 1)));
3215
3430
  }
3216
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
+
3217
3451
  private updateQuery(query: string, cursor: number): void {
3218
3452
  this.searchState.query = query;
3219
3453
  this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
3220
- this.searchState.results = searchQolSessions(this.sessions, query, this.cwd);
3221
- this.searchState.selected = 0;
3222
- this.clampSelection();
3454
+ this.updateResults(true);
3223
3455
  }
3224
3456
 
3225
3457
  private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
@@ -3234,12 +3466,15 @@ class QolSessionSearchComponent {
3234
3466
  return Math.max(0, messages.length - 1);
3235
3467
  }
3236
3468
 
3237
- private openMessages(result: QolSessionSearchResult): void {
3469
+ private openMessages(result: QolSessionSearchResult, preferredMessage?: QolSessionUserMessage): void {
3238
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;
3239
3474
  this.messagesState = {
3240
3475
  messages,
3241
3476
  result,
3242
- selected: this.selectedMessageIndex(messages, this.searchState.query),
3477
+ selected: preferredIndex >= 0 ? preferredIndex : this.selectedMessageIndex(messages, this.searchState.query),
3243
3478
  };
3244
3479
  this.screen = "messages";
3245
3480
  }
@@ -3248,16 +3483,101 @@ class QolSessionSearchComponent {
3248
3483
  return `Focus on prompt #${message.index}: ${message.text}`;
3249
3484
  }
3250
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
+
3251
3533
  private handleSearchInput(data: string): void {
3252
3534
  const state = this.searchState;
3253
3535
  if (matchesKey(data, "escape")) {
3254
3536
  this.done({ type: "cancel" });
3255
3537
  return;
3256
3538
  }
3539
+ if (matchesKey(data, "tab")) {
3540
+ this.toggleScope();
3541
+ return;
3542
+ }
3257
3543
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3258
- const result = state.results[state.selected];
3259
- if (!result) return;
3260
- 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
+ }
3261
3581
  return;
3262
3582
  }
3263
3583
  if (matchesKey(data, "up")) {
@@ -3265,7 +3585,7 @@ class QolSessionSearchComponent {
3265
3585
  return;
3266
3586
  }
3267
3587
  if (matchesKey(data, "down")) {
3268
- 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);
3269
3589
  return;
3270
3590
  }
3271
3591
  if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
@@ -3273,7 +3593,7 @@ class QolSessionSearchComponent {
3273
3593
  return;
3274
3594
  }
3275
3595
  if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3276
- 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());
3277
3597
  return;
3278
3598
  }
3279
3599
  if (matchesKey(data, "left")) {
@@ -3326,14 +3646,35 @@ class QolSessionSearchComponent {
3326
3646
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3327
3647
  const message = state.messages[state.selected];
3328
3648
  if (!message) return;
3329
- this.actionState = { actionIndex: 0, message, result: state.result };
3649
+ this.actionState = { message, result: state.result };
3650
+ this.actionReturnScreen = "messages";
3330
3651
  this.screen = "actions";
3331
3652
  return;
3332
3653
  }
3333
- if (data.toLowerCase() === "r") {
3654
+ if (matchesKey(data, "alt+r")) {
3334
3655
  this.done({ result: state.result, type: "resume" });
3335
3656
  return;
3336
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
+ }
3337
3678
  if (matchesKey(data, "up")) {
3338
3679
  state.selected = Math.max(0, state.selected - 1);
3339
3680
  return;
@@ -3363,146 +3704,198 @@ class QolSessionSearchComponent {
3363
3704
  const state = this.actionState;
3364
3705
  if (!state) return;
3365
3706
  if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3366
- this.screen = "messages";
3707
+ this.returnFromActions();
3367
3708
  return;
3368
3709
  }
3369
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
3370
- 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" });
3371
3712
  return;
3372
3713
  }
3373
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
3374
- 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");
3375
3716
  return;
3376
3717
  }
3377
- if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3378
- const action = QOL_SESSION_ACTIONS[state.actionIndex];
3379
- if (action === "back") {
3380
- this.screen = "messages";
3381
- return;
3382
- }
3383
- if (action === "resume" || action === "copy") {
3384
- this.done({ message: state.message, result: state.result, type: action });
3385
- return;
3386
- }
3387
- this.focusState = { cursor: 0, message: state.message, prompt: "", result: state.result, type: action };
3388
- 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;
3389
3729
  }
3390
3730
  }
3391
3731
 
3392
- private handleFocusInput(data: string): void {
3393
- const state = this.focusState;
3732
+ private handleContextConfirmInput(data: string): void {
3733
+ const state = this.contextConfirmState;
3394
3734
  if (!state) return;
3395
- if (matchesKey(data, "escape")) {
3396
- this.screen = "actions";
3735
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3736
+ this.returnFromContextConfirm(state);
3397
3737
  return;
3398
3738
  }
3399
3739
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3400
- this.done({ customPrompt: state.prompt.trim() || this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
3401
- return;
3402
- }
3403
- if (matchesKey(data, "left")) {
3404
- state.cursor = Math.max(0, state.cursor - 1);
3405
- return;
3740
+ this.confirmContextAction(state);
3406
3741
  }
3407
- if (matchesKey(data, "right")) {
3408
- 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);
3409
3749
  return;
3410
3750
  }
3411
- if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3412
- state.cursor = 0;
3751
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3752
+ this.confirmForkAction(state);
3413
3753
  return;
3414
3754
  }
3415
- if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3416
- state.cursor = state.prompt.length;
3755
+ if (matchesKey(data, "up")) {
3756
+ state.selected = Math.max(0, state.selected - 1);
3417
3757
  return;
3418
3758
  }
3419
- if (matchesKey(data, "ctrl+u")) {
3420
- state.prompt = "";
3421
- state.cursor = 0;
3759
+ if (matchesKey(data, "down")) {
3760
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + 1);
3422
3761
  return;
3423
3762
  }
3424
- if (matchesKey(data, "ctrl+w") || matchesKey(data, "alt+backspace")) {
3425
- const before = state.prompt.slice(0, state.cursor);
3426
- const after = state.prompt.slice(state.cursor);
3427
- let i = before.length;
3428
- while (i > 0 && /\s/.test(before[i - 1]!)) i -= 1;
3429
- while (i > 0 && !/\s/.test(before[i - 1]!)) i -= 1;
3430
- state.prompt = `${before.slice(0, i)}${after}`;
3431
- state.cursor = i;
3763
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3764
+ state.selected = Math.max(0, state.selected - this.forkConfirmMaxVisibleRows());
3432
3765
  return;
3433
3766
  }
3434
- if (matchesKey(data, "backspace")) {
3435
- if (state.cursor > 0) {
3436
- state.prompt = `${state.prompt.slice(0, state.cursor - 1)}${state.prompt.slice(state.cursor)}`;
3437
- state.cursor -= 1;
3438
- }
3767
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3768
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + this.forkConfirmMaxVisibleRows());
3439
3769
  return;
3440
3770
  }
3441
- if (matchesKey(data, "delete")) {
3442
- 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;
3443
3773
  return;
3444
3774
  }
3445
- if (isPrintableInput(data)) {
3446
- state.prompt = `${state.prompt.slice(0, state.cursor)}${data}${state.prompt.slice(state.cursor)}`;
3447
- state.cursor += data.length;
3775
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3776
+ state.selected = Math.max(0, state.messages.length - 1);
3448
3777
  }
3449
3778
  }
3450
3779
 
3451
3780
  private renderSearch(width: number): string[] {
3452
- 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);
3453
3782
  const state = this.searchState;
3454
- 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()];
3455
3785
  const accent = (s: string) => this.theme.fg("accent", s);
3456
3786
  const dim = (s: string) => this.theme.fg("dim", s);
3457
3787
  const muted = (s: string) => this.theme.fg("muted", s);
3458
- const pair = (left: string, right: string) => {
3459
- const leftWidth = Math.max(1, inner - visibleWidth(right) - 1);
3460
- const clippedLeft = truncateToWidth(left, leftWidth, "…");
3461
- const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3462
- return `${clippedLeft}${" ".repeat(gap)}${right}`;
3463
- };
3464
3788
  const cursorChar = state.query[state.cursor] ?? " ";
3465
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());
3466
3792
  lines.push(filledRow(` > ${queryDisplay}`));
3467
- lines.push(row(dim(`${state.total} sessions · re:<pattern> regex · "phrase" exact`)));
3468
- lines.push(empty(), divider(), empty());
3793
+ if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
3794
+ lines.push(divider());
3795
+
3469
3796
  if (state.results.length === 0) {
3470
- 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")));
3471
3798
  } else {
3472
- const maxVisible = this.searchMaxVisibleRows();
3473
- const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3474
- const end = Math.min(start + maxVisible, state.results.length);
3475
- const rowBudget = Math.max(10, inner);
3476
- for (let i = start; i < end; i++) {
3477
- const result = state.results[i]!;
3478
- const selected = i === state.selected;
3479
- const title = sessionResumeTitle(result);
3480
- const rightPlain = `${promptCountLabel(sessionUserPromptCount(result))} · ${formatSessionSearchDate(result.modified)}`;
3481
- const right = selected ? this.theme.fg("text", rightPlain) : dim(rightPlain);
3482
- const titleBudget = Math.max(12, rowBudget - visibleWidth(right) - 1);
3483
- const titleText = truncateToWidth(title, titleBudget, "…");
3484
- const left = selected ? this.theme.bold(accent(titleText)) : this.theme.bold(titleText);
3485
- const titleRow = pair(left, right);
3486
- lines.push(selected ? selectedRow(titleRow) : row(titleRow));
3487
-
3488
- const folder = shortPathForUi(result.cwd || dirname(result.path));
3489
- const project = sessionDisplayName(result);
3490
- lines.push(row(muted(truncateToWidth(`${project} · ${folder}`, inner, "…"))));
3491
-
3492
- const snippet = state.query.trim() ? (result.snippets[0] || lastUserMessageSnippet(result)) : lastUserMessageSnippet(result);
3493
- const label = state.query.trim() ? "match" : "last";
3494
- const snippetPrefix = `${dim(`${label}:`)} `;
3495
- lines.push(row(`${snippetPrefix}${truncateToWidth(styleSessionSnippet(snippet, state.query, this.theme), inner - visibleWidth(snippetPrefix), "…")}`));
3496
- if (i < end - 1) lines.push(row(this.theme.fg("borderMuted", "─".repeat(Math.max(8, inner)))));
3497
- }
3498
- 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")}`));
3499
3808
  }
3500
- lines.push(divider(), empty());
3501
- lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompts")} ${ansiYellow("ctrl+u")} ${dim("clear")} ${ansiYellow("esc")} ${dim("close")}`));
3502
3809
  lines.push(bottom());
3503
3810
  return lines;
3504
3811
  }
3505
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
+
3506
3899
  private renderMessages(width: number, state: QolSessionMessagesState): string[] {
3507
3900
  const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3508
3901
  const accent = (s: string) => this.theme.fg("accent", s);
@@ -3516,11 +3909,10 @@ class QolSessionSearchComponent {
3516
3909
  const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3517
3910
  return `${clippedLeft}${" ".repeat(gap)}${right}`;
3518
3911
  };
3519
- const right = dim(`${promptCountLabel(state.messages.length)} · ${formatSessionSearchDate(result.modified)}`);
3912
+ const right = dim(formatSessionSearchDate(result.modified));
3520
3913
  const title = truncateToWidth(sessionResumeTitle(result), Math.max(12, inner - visibleWidth(right) - 1), "…");
3521
3914
  lines.push(row(pair(this.theme.bold(accent(title)), right)));
3522
3915
  lines.push(row(muted(truncateToWidth(shortPathForUi(result.cwd || result.path), inner, "…"))));
3523
- lines.push(row(dim(`${state.messages.length} user prompt${state.messages.length === 1 ? "" : "s"} · enter opens prompt actions · r resumes latest session state`)));
3524
3916
  lines.push(empty(), divider(), empty());
3525
3917
 
3526
3918
  const maxVisible = this.messageMaxVisibleRows();
@@ -3536,9 +3928,9 @@ class QolSessionSearchComponent {
3536
3928
  const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
3537
3929
  lines.push(selected ? selectedRow(messageRow) : row(messageRow));
3538
3930
  }
3539
- if (state.messages.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.messages.length} user prompts`)));
3540
3931
  lines.push(divider(), empty());
3541
- 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")}`));
3542
3934
  lines.push(bottom());
3543
3935
  return lines;
3544
3936
  }
@@ -3556,46 +3948,85 @@ class QolSessionSearchComponent {
3556
3948
  lines.push(row(dim("Selected prompt")));
3557
3949
  const wrapped = wrapVisible(state.message.text, inner, 4);
3558
3950
  for (const line of wrapped) lines.push(row(line));
3559
- lines.push(empty());
3560
- lines.push(row(dim("Resume Session From Here restores this prompt in the selected session.")));
3561
- lines.push(row(dim("Inject Context adds a summary here; New Session + Context imports it into a new session.")));
3562
3951
  lines.push(empty(), divider(), empty());
3563
- const actions = QOL_SESSION_ACTIONS.map((action, index) => {
3564
- const label = QOL_SESSION_ACTION_LABELS[action];
3565
- return index === state.actionIndex ? this.theme.bold(accent(`[${label}]`)) : dim(`[${label}]`);
3566
- });
3567
- 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
+ }
3568
3981
  lines.push(empty());
3569
- 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")}`));
3570
3999
  lines.push(bottom());
3571
4000
  return lines;
3572
4001
  }
3573
4002
 
3574
- private renderFocus(width: number, state: QolSessionFocusState): string[] {
3575
- 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);
3576
4005
  const accent = (s: string) => this.theme.fg("accent", s);
3577
4006
  const dim = (s: string) => this.theme.fg("dim", s);
3578
4007
  const muted = (s: string) => this.theme.fg("muted", s);
3579
- const action = state.type === "newSession" ? "New Session + Context" : "Inject Context";
3580
- const lines: string[] = [top("Context Focus", action), empty()];
3581
- lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`-> ${action}`)}`));
3582
- 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, "…"))));
3583
4013
  lines.push(empty(), divider(), empty());
3584
- const prefix = "> ";
3585
- const textWidth = Math.max(10, inner - visibleWidth(prefix));
3586
- if (!state.prompt) {
3587
- lines.push(filledRow(`${prefix}${this.theme.inverse(" ")}${muted(" optional focus override; Enter uses the selected prompt")}`));
3588
- } else {
3589
- const cursorChar = state.prompt[state.cursor] ?? " ";
3590
- const text = `${state.prompt.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.prompt.slice(state.cursor + (state.cursor < state.prompt.length ? 1 : 0))}`;
3591
- const wrapped = wrapVisible(text, textWidth, 5);
3592
- 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));
3593
4023
  }
3594
4024
  lines.push(empty(), divider(), empty());
3595
- 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")}`));
3596
4026
  lines.push(bottom());
3597
4027
  return lines;
3598
4028
  }
4029
+
3599
4030
  }
3600
4031
 
3601
4032
  function wrapVisible(text: string, width: number, maxLines: number): string[] {
@@ -3660,7 +4091,6 @@ class QolSessionSearchLoadingComponent {
3660
4091
  empty(),
3661
4092
  divider(),
3662
4093
  empty(),
3663
- row(`${ansiYellow("esc")} ${dim("cancel")} ${ansiYellow("ctrl+c")} ${dim("cancel")}`),
3664
4094
  bottom(),
3665
4095
  ];
3666
4096
  }
@@ -3783,7 +4213,7 @@ async function buildSessionSearchContextMessageWithLoader(ctx: ExtensionContext,
3783
4213
 
3784
4214
  async function injectSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3785
4215
  const title = sessionDisplayName(result);
3786
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject Context");
4216
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject + Context");
3787
4217
  pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { deliverAs: "followUp", triggerTurn: false });
3788
4218
  ctx.ui.notify(`Session context injected: ${title}`, "info");
3789
4219
  }
@@ -3792,9 +4222,48 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
3792
4222
  return ctx as ExtensionContext & Partial<ExtensionCommandContext>;
3793
4223
  }
3794
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
+
3795
4264
  async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3796
4265
  const title = sessionDisplayName(result);
3797
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New Session + Context");
4266
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New + Context");
3798
4267
 
3799
4268
  const commandCtx = asCommandContext(ctx);
3800
4269
  if (typeof commandCtx.newSession === "function") {
@@ -3823,7 +4292,7 @@ async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: Extensio
3823
4292
  }
3824
4293
  setPendingSessionSearchMessage(message);
3825
4294
  ctx.ui.setEditorText("/new");
3826
- 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");
3827
4296
  }
3828
4297
 
3829
4298
  async function consumePendingSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, reason: unknown): Promise<void> {
@@ -3857,35 +4326,8 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3857
4326
  releaseModalLock();
3858
4327
  }
3859
4328
  if (!action || action.type === "cancel" || !action.result) return;
3860
- if (action.type === "resume") {
3861
- const commandCtx = asCommandContext(ctx);
3862
- if (typeof commandCtx.switchSession === "function") {
3863
- const targetTitle = sessionDisplayName(action.result);
3864
- const selectedMessage = action.message;
3865
- let replacementStarted = false;
3866
- try {
3867
- const result = await commandCtx.switchSession(action.result.path, {
3868
- withSession: async (replacementCtx: any) => {
3869
- replacementStarted = true;
3870
- if (selectedMessage?.entryId) {
3871
- const manager = replacementCtx.sessionManager as any;
3872
- if (selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
3873
- else if (selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
3874
- replacementCtx.ui.setEditorText(selectedMessage.text);
3875
- replacementCtx.ui.notify(`Resumed ${targetTitle} from prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
3876
- return;
3877
- }
3878
- replacementCtx.ui.notify(`Resumed session: ${targetTitle}`, "info");
3879
- },
3880
- });
3881
- if (result.cancelled) ctx.ui.notify("Resume cancelled", "info");
3882
- } catch (error) {
3883
- if (!replacementStarted) ctx.ui.notify(`Resume failed: ${stringifyError(error)}`, "error");
3884
- }
3885
- return;
3886
- }
3887
- ctx.ui.setEditorText(`/resume ${quoteSessionSearchArg(action.result.path)}`);
3888
- 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);
3889
4331
  return;
3890
4332
  }
3891
4333
  if (action.type === "copy") {
@@ -3899,7 +4341,7 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3899
4341
  await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
3900
4342
  } catch (error) {
3901
4343
  const message = stringifyError(error);
3902
- 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");
3903
4345
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3904
4346
  }
3905
4347
  return;
@@ -3909,16 +4351,12 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3909
4351
  await createNewSessionWithSearchContext(pi, ctx, action.result, action.customPrompt);
3910
4352
  } catch (error) {
3911
4353
  const message = stringifyError(error);
3912
- 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");
3913
4355
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3914
4356
  }
3915
4357
  }
3916
4358
  }
3917
4359
 
3918
- function quoteSessionSearchArg(value: string): string {
3919
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3920
- }
3921
-
3922
4360
  function renderSessionSearchContextMessage(message: any, options: any, theme: Theme): Text {
3923
4361
  const raw = typeof message.content === "string"
3924
4362
  ? message.content
@@ -4505,6 +4943,19 @@ export default function qol(pi: ExtensionAPI): void {
4505
4943
  description: "Refresh the session search index",
4506
4944
  handler: async (_args, ctx) => handleSearchCommand("refresh", ctx),
4507
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
+ });
4508
4959
  const shortcut = sessionSearchShortcut();
4509
4960
  if (shortcut) {
4510
4961
  pi.registerShortcut(shortcut, {