@vanillagreen/pi-qol 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -1307,6 +1307,14 @@ function setTmuxWindowOption(target: string, option: string, value: string): voi
1307
1307
  execFile("tmux", ["set-option", "-wq", "-t", target, option, value], { timeout: 1000 }, () => undefined);
1308
1308
  }
1309
1309
 
1310
+ function readTmuxWindowName(target: string, callback: (name: string | undefined) => void): void {
1311
+ execFile("tmux", ["display-message", "-p", "-t", target, "#{window_name}"], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
1312
+ }
1313
+
1314
+ function setTmuxWindowName(target: string, name: string): void {
1315
+ execFile("tmux", ["rename-window", "-t", target, name], { timeout: 1000 }, () => undefined);
1316
+ }
1317
+
1310
1318
  function cavemanIconTone(mode: string, active: boolean): "muted" | "text" | "success" | "thinkingHigh" | "error" {
1311
1319
  if (!active) return "muted";
1312
1320
  switch (mode) {
@@ -1371,6 +1379,32 @@ function compactionProfileInstructions(profile: QolSummaryProfile): string {
1371
1379
  return "Be complete but not verbose. Preserve enough detail for a future assistant to continue without the old transcript.";
1372
1380
  }
1373
1381
 
1382
+ function stripThinkingForSummary(messages: Message[]): Message[] {
1383
+ return messages.map((message) => {
1384
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
1385
+ return {
1386
+ ...message,
1387
+ content: message.content.filter((part: any) => part?.type !== "thinking"),
1388
+ };
1389
+ });
1390
+ }
1391
+
1392
+ function serializeMessagesForSummary(messages: AgentMessage[]): string {
1393
+ return serializeConversation(stripThinkingForSummary(convertToLlm(messages)));
1394
+ }
1395
+
1396
+ function customMessageContentToText(content: unknown): string {
1397
+ if (typeof content === "string") return content;
1398
+ if (!Array.isArray(content)) return "";
1399
+ const parts: string[] = [];
1400
+ for (const part of content) {
1401
+ if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
1402
+ else if (part?.type === "image") parts.push(`[image${typeof part.mimeType === "string" ? ` ${part.mimeType}` : ""}]`);
1403
+ else if (part?.type) parts.push(`[${String(part.type)}]`);
1404
+ }
1405
+ return parts.join("\n").trim();
1406
+ }
1407
+
1374
1408
  function buildSummaryPrompt(options: {
1375
1409
  conversationText: string;
1376
1410
  customInstructions?: string;
@@ -1497,7 +1531,7 @@ async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<a
1497
1531
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1498
1532
  compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
1499
1533
  try {
1500
- const conversationText = serializeConversation(convertToLlm(messages));
1534
+ const conversationText = serializeMessagesForSummary(messages);
1501
1535
  const result = await generateQolSummary(ctx, {
1502
1536
  conversationText,
1503
1537
  customInstructions: event.customInstructions,
@@ -1529,10 +1563,10 @@ async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<a
1529
1563
  }
1530
1564
 
1531
1565
  function summarizeEntryForBranch(entry: any): string[] {
1532
- if (entry?.type === "message" && entry.message) return [serializeConversation(convertToLlm([entry.message]))];
1566
+ if (entry?.type === "message" && entry.message) return [serializeMessagesForSummary([entry.message])];
1533
1567
  if (entry?.type === "compaction" && typeof entry.summary === "string") return [`[Compaction summary]: ${entry.summary}`];
1534
1568
  if (entry?.type === "branch_summary" && typeof entry.summary === "string") return [`[Branch summary]: ${entry.summary}`];
1535
- if (entry?.type === "custom_message") return [`[Custom message${entry.customType ? `:${entry.customType}` : ""}]: ${typeof entry.content === "string" ? entry.content : JSON.stringify(entry.data ?? {})}`];
1569
+ if (entry?.type === "custom_message") return [`[Custom message${entry.customType ? `:${entry.customType}` : ""}]: ${customMessageContentToText(entry.content) || "[empty]"}`];
1536
1570
  return [];
1537
1571
  }
1538
1572
 
@@ -2131,39 +2165,6 @@ function getQuestionService(): QuestionServiceLike | undefined {
2131
2165
  return undefined;
2132
2166
  }
2133
2167
 
2134
- function entryToHandoffMessage(entry: SessionEntry): AgentMessage | undefined {
2135
- if (entry.type === "message") return entry.message;
2136
- if (entry.type === "compaction") {
2137
- return {
2138
- role: "compactionSummary",
2139
- summary: entry.summary,
2140
- tokensBefore: entry.tokensBefore,
2141
- timestamp: new Date(entry.timestamp).getTime(),
2142
- };
2143
- }
2144
- return undefined;
2145
- }
2146
-
2147
- function getHandoffMessages(branch: SessionEntry[]): AgentMessage[] {
2148
- let compactionIndex = -1;
2149
- for (let i = branch.length - 1; i >= 0; i -= 1) {
2150
- if (branch[i]?.type === "compaction") {
2151
- compactionIndex = i;
2152
- break;
2153
- }
2154
- }
2155
- if (compactionIndex < 0) return branch.map(entryToHandoffMessage).filter((message): message is AgentMessage => message !== undefined);
2156
-
2157
- const compaction = branch[compactionIndex];
2158
- const firstKeptIndex = compaction?.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
2159
- const compactedBranch = [
2160
- compaction,
2161
- ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []),
2162
- ...branch.slice(compactionIndex + 1),
2163
- ];
2164
- return compactedBranch.map(entryToHandoffMessage).filter((message): message is AgentMessage => message !== undefined);
2165
- }
2166
-
2167
2168
  async function runHandoff(args: string, ctx: ExtensionCommandContext): Promise<void> {
2168
2169
  if (!ctx.hasUI) {
2169
2170
  ctx.ui.notify("handoff requires interactive mode", "error");
@@ -2181,15 +2182,15 @@ async function runHandoff(args: string, ctx: ExtensionCommandContext): Promise<v
2181
2182
  return;
2182
2183
  }
2183
2184
 
2184
- const messages = getHandoffMessages(ctx.sessionManager.getBranch());
2185
+ const built = (ctx.sessionManager as any).buildSessionContext?.();
2186
+ const messages = Array.isArray(built?.messages) ? built.messages as AgentMessage[] : [];
2185
2187
 
2186
2188
  if (messages.length === 0) {
2187
2189
  ctx.ui.notify("No conversation to hand off", "error");
2188
2190
  return;
2189
2191
  }
2190
2192
 
2191
- const llmMessages = convertToLlm(messages);
2192
- const conversationText = serializeConversation(llmMessages);
2193
+ const conversationText = serializeMessagesForSummary(messages);
2193
2194
  const currentSessionFile = ctx.sessionManager.getSessionFile();
2194
2195
 
2195
2196
  const releaseModalLock = acquireVstackModalLock();
@@ -2272,7 +2273,7 @@ function statusMessage(ctx: ExtensionContext): string {
2272
2273
  return [
2273
2274
  "Pi QOL status",
2274
2275
  `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"}`,
2276
+ `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2276
2277
  `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
2277
2278
  `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
2278
2279
  `Image chips: ${settingBoolean("showImageChips", true, ctx.cwd) ? "filled (placeholders and existing image paths)" : "off"}`,
@@ -2288,23 +2289,12 @@ function statusMessage(ctx: ExtensionContext): string {
2288
2289
  `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
2290
  `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
2291
  `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.",
2292
+ "If shift+enter still submits, configure your terminal/tmux to send a distinct shift+enter sequence or use the fallback key.",
2292
2293
  ].join("\n");
2293
2294
  }
2294
2295
 
2295
2296
 
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
- };
2297
+ type QolSessionSearchScope = "current" | "all";
2308
2298
 
2309
2299
  interface QolSessionSearchSession {
2310
2300
  allMessagesText: string;
@@ -2324,6 +2314,13 @@ interface QolSessionSearchResult extends QolSessionSearchSession {
2324
2314
  snippets: string[];
2325
2315
  }
2326
2316
 
2317
+ interface QolSessionSearchHit {
2318
+ message: QolSessionUserMessage;
2319
+ rank: number;
2320
+ result: QolSessionSearchResult;
2321
+ snippet: string;
2322
+ }
2323
+
2327
2324
  interface QolParsedSessionQuery {
2328
2325
  mode: "tokens" | "regex";
2329
2326
  tokens: Array<{ kind: "fuzzy" | "phrase"; value: string }>;
@@ -2334,8 +2331,9 @@ interface QolParsedSessionQuery {
2334
2331
  interface QolSessionSearchState {
2335
2332
  cursor: number;
2336
2333
  query: string;
2337
- results: QolSessionSearchResult[];
2334
+ results: QolSessionSearchHit[];
2338
2335
  selected: number;
2336
+ scope: QolSessionSearchScope;
2339
2337
  total: number;
2340
2338
  }
2341
2339
 
@@ -2354,35 +2352,56 @@ interface QolSessionMessagesState {
2354
2352
  }
2355
2353
 
2356
2354
  interface QolSessionActionState {
2357
- actionIndex: number;
2358
2355
  message: QolSessionUserMessage;
2359
2356
  result: QolSessionSearchResult;
2360
2357
  }
2361
2358
 
2362
- interface QolSessionFocusState {
2363
- cursor: number;
2359
+ interface QolSessionContextConfirmState {
2364
2360
  message: QolSessionUserMessage;
2365
- prompt: string;
2366
2361
  result: QolSessionSearchResult;
2362
+ returnScreen: "search" | "messages" | "actions";
2367
2363
  type: "summarize" | "newSession";
2368
2364
  }
2369
2365
 
2366
+ interface QolSessionForkConfirmState {
2367
+ messages: QolSessionUserMessage[];
2368
+ result: QolSessionSearchResult;
2369
+ returnScreen: "search" | "messages" | "actions";
2370
+ selected: number;
2371
+ }
2372
+
2370
2373
  interface QolSessionSearchPendingMessage {
2371
2374
  content: string;
2372
2375
  details: Record<string, unknown>;
2373
2376
  }
2374
2377
 
2375
2378
  interface QolSessionPaletteAction {
2376
- type: "cancel" | "resume" | "copy" | "summarize" | "newSession";
2379
+ type: "cancel" | "resume" | "fork" | "copy" | "summarize" | "newSession";
2377
2380
  customPrompt?: string;
2381
+ keepCurrentModel?: boolean;
2378
2382
  message?: QolSessionUserMessage;
2379
2383
  result?: QolSessionSearchResult;
2380
2384
  }
2381
2385
 
2386
+ function pinSessionModel(sessionPath: string, model: NonNullable<ExtensionContext["model"]>, thinkingLevel?: string): void {
2387
+ const manager = SessionManager.open(sessionPath);
2388
+ const context = manager.buildSessionContext();
2389
+ if (context.model?.provider !== model.provider || context.model?.modelId !== model.id) {
2390
+ manager.appendModelChange(model.provider, model.id);
2391
+ }
2392
+ if (thinkingLevel) {
2393
+ const branch = manager.getBranch();
2394
+ const lastThinking = [...branch].reverse().find((entry: any) => entry?.type === "thinking_level_change") as { thinkingLevel?: string } | undefined;
2395
+ if (lastThinking?.thinkingLevel !== thinkingLevel) manager.appendThinkingLevelChange(thinkingLevel as any);
2396
+ }
2397
+ }
2398
+
2382
2399
  let qolSessionSearchCache: QolSessionSearchSession[] = [];
2383
2400
  let qolSessionSearchLoadedAt = 0;
2384
2401
  let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
2385
2402
  const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
2403
+ const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
2404
+ let qolSessionSearchPendingActionCounter = 0;
2386
2405
 
2387
2406
  function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
2388
2407
  return (globalThis as unknown as Record<PropertyKey, unknown>)[SESSION_SEARCH_PENDING_SYMBOL] as QolSessionSearchPendingMessage | undefined;
@@ -2422,11 +2441,12 @@ function resolveSettingsRelativePath(value: string, settingsPath: string): strin
2422
2441
  }
2423
2442
 
2424
2443
  function sessionSearchShortcut(cwd?: string): string | undefined {
2425
- // Legacy escape hatch from the original Ctrl+F setting. If users disabled it,
2444
+ // Legacy escape hatch from the original ctrl+f setting. If users disabled it,
2426
2445
  // keep shortcuts disabled even though the default shortcut is now conflict-free.
2427
2446
  if (!settingBoolean("sessionSearch.ctrlFShortcut", true, cwd)) return undefined;
2428
2447
  const shortcut = settingStringAllowEmpty("sessionSearch.shortcutKey", DEFAULT_SESSION_SEARCH_SHORTCUT, cwd).trim().toLowerCase();
2429
2448
  if (!shortcut || shortcut === "none" || shortcut === "off" || shortcut === "false") return undefined;
2449
+ if (shortcut === "f3") return DEFAULT_SESSION_SEARCH_SHORTCUT;
2430
2450
  return shortcut;
2431
2451
  }
2432
2452
 
@@ -2448,6 +2468,23 @@ function configuredSessionDir(cwd: string): string | undefined {
2448
2468
  return configured;
2449
2469
  }
2450
2470
 
2471
+ function canonicalPathForSessionSearch(path: string | undefined): string | undefined {
2472
+ if (!path) return undefined;
2473
+ try {
2474
+ return realpathSync.native(path);
2475
+ } catch {
2476
+ return resolve(path);
2477
+ }
2478
+ }
2479
+
2480
+ function sameSessionSearchProject(sessionCwd: string | undefined, cwd: string): boolean {
2481
+ return canonicalPathForSessionSearch(sessionCwd) === canonicalPathForSessionSearch(cwd);
2482
+ }
2483
+
2484
+ function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
2485
+ return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
2486
+ }
2487
+
2451
2488
  async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
2452
2489
  const customSessionDir = configuredSessionDir(ctx.cwd);
2453
2490
  const infos = customSessionDir
@@ -2932,6 +2969,16 @@ function messageContentText(content: unknown): string {
2932
2969
  }).filter(Boolean).join(" ");
2933
2970
  }
2934
2971
 
2972
+ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
2973
+ if (typeof message?.timestamp === "number" && Number.isFinite(message.timestamp)) return message.timestamp;
2974
+ if (typeof entry?.timestamp === "number" && Number.isFinite(entry.timestamp)) return entry.timestamp;
2975
+ if (typeof entry?.timestamp === "string") {
2976
+ const parsed = new Date(entry.timestamp).getTime();
2977
+ if (!Number.isNaN(parsed)) return parsed;
2978
+ }
2979
+ return undefined;
2980
+ }
2981
+
2935
2982
  function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2936
2983
  const cached = qolSessionUserMessagesCache.get(sessionPath);
2937
2984
  if (cached) return cached;
@@ -2951,7 +2998,7 @@ function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2951
2998
  index: messages.length + 1,
2952
2999
  parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
2953
3000
  text,
2954
- timestamp: typeof message.timestamp === "number" ? message.timestamp : undefined,
3001
+ timestamp: sessionMessageTimestamp(entry, message),
2955
3002
  });
2956
3003
  }
2957
3004
  } catch {
@@ -3042,21 +3089,28 @@ function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
3042
3089
  return { mode: "tokens", tokens };
3043
3090
  }
3044
3091
 
3045
- function simpleFuzzyScore(needle: string, haystack: string): number | undefined {
3046
- const query = needle.toLowerCase();
3047
- const text = haystack.toLowerCase();
3092
+ function searchWordLengthPenalty(word: string, query: string): number {
3093
+ return Math.max(0, word.length - query.length);
3094
+ }
3095
+
3096
+ function searchStringScore(needle: string, haystack: string): number | undefined {
3097
+ const query = normalizeSearchText(needle);
3098
+ const text = normalizeSearchText(haystack);
3048
3099
  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;
3100
+ let best: number | undefined;
3101
+ const record = (score: number) => {
3102
+ best = best === undefined ? score : Math.min(best, score);
3103
+ };
3104
+ if (/^[a-z0-9_]+$/i.test(query)) {
3105
+ for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
3106
+ const word = match[0].toLowerCase();
3107
+ if (word === query) record(0);
3108
+ else if (word.startsWith(query)) record(10 + searchWordLengthPenalty(word, query));
3109
+ else if (word.includes(query)) record(100 + searchWordLengthPenalty(word, query));
3110
+ }
3111
+ return best;
3058
3112
  }
3059
- return score + 1000;
3113
+ return text.includes(query) ? 0 : undefined;
3060
3114
  }
3061
3115
 
3062
3116
  function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
@@ -3064,21 +3118,12 @@ function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedS
3064
3118
  if (parsed.mode === "regex") {
3065
3119
  if (!parsed.regex) return { matches: false, score: 0 };
3066
3120
  const index = text.search(parsed.regex);
3067
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: index * 0.1 };
3121
+ return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
3068
3122
  }
3069
3123
  if (parsed.tokens.length === 0) return { matches: true, score: 0 };
3070
3124
  let score = 0;
3071
- let normalized: string | undefined;
3072
3125
  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);
3126
+ const tokenScore = searchStringScore(token.value, text);
3082
3127
  if (tokenScore === undefined) return { matches: false, score: 0 };
3083
3128
  score += tokenScore;
3084
3129
  }
@@ -3093,9 +3138,10 @@ function sessionSnippetSource(session: QolSessionSearchSession): string {
3093
3138
  return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
3094
3139
  }
3095
3140
 
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));
3141
+ function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
3142
+ const safeLead = Math.max(0, Math.min(width, lead));
3143
+ const safeStart = Math.max(0, start - safeLead);
3144
+ const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
3099
3145
  const prefix = safeStart > 0 ? "…" : "";
3100
3146
  const suffix = safeEnd < text.length ? "…" : "";
3101
3147
  return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
@@ -3147,11 +3193,107 @@ function searchQolSessions(sessions: QolSessionSearchSession[], query: string, c
3147
3193
  if (!match.matches) continue;
3148
3194
  results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
3149
3195
  }
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());
3196
+ results.sort((a, b) => a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
3152
3197
  return results.slice(0, limit);
3153
3198
  }
3154
3199
 
3200
+ function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
3201
+ return { ...session, rank, snippets };
3202
+ }
3203
+
3204
+ function matchTextSearch(text: string, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
3205
+ if (parsed.mode === "regex") {
3206
+ if (!parsed.regex) return { matches: false, score: 0 };
3207
+ const index = text.search(parsed.regex);
3208
+ return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
3209
+ }
3210
+ if (parsed.tokens.length === 0) return { matches: true, score: 0 };
3211
+ let score = 0;
3212
+ for (const token of parsed.tokens) {
3213
+ const tokenScore = searchStringScore(token.value, text);
3214
+ if (tokenScore === undefined) return { matches: false, score: 0 };
3215
+ score += tokenScore;
3216
+ }
3217
+ return { matches: true, score };
3218
+ }
3219
+
3220
+ function sessionTitleSearchText(session: QolSessionSearchSession): string {
3221
+ return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
3222
+ }
3223
+
3224
+ function matchPromptSearch(_session: QolSessionSearchSession, message: QolSessionUserMessage, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
3225
+ return matchTextSearch(message.text, parsed);
3226
+ }
3227
+
3228
+ function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
3229
+ const source = message.text.replace(/\s+/g, " ").trim();
3230
+ if (!source) return "";
3231
+ if (parsed.mode === "regex" && parsed.regex) {
3232
+ parsed.regex.lastIndex = 0;
3233
+ const match = parsed.regex.exec(source);
3234
+ return match ? snippetAround(source, match.index, match[0].length, 160, 24) : source.slice(0, 160);
3235
+ }
3236
+ if (parsed.tokens.length === 0) return source.slice(0, 160);
3237
+ const lower = source.toLowerCase();
3238
+ for (const token of parsed.tokens) {
3239
+ const value = normalizeSearchText(token.value);
3240
+ if (!value) continue;
3241
+ const index = lower.indexOf(value.toLowerCase());
3242
+ if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
3243
+ }
3244
+ return source.slice(0, 160);
3245
+ }
3246
+
3247
+ function promptRecencyTime(hit: QolSessionSearchHit): number {
3248
+ if (typeof hit.message.timestamp === "number" && Number.isFinite(hit.message.timestamp)) return hit.message.timestamp;
3249
+ return hit.result.modified.getTime();
3250
+ }
3251
+
3252
+ function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSearchHit): number {
3253
+ return promptRecencyTime(b) - promptRecencyTime(a)
3254
+ || b.result.modified.getTime() - a.result.modified.getTime()
3255
+ || b.message.index - a.message.index;
3256
+ }
3257
+
3258
+ function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchHit[] {
3259
+ const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
3260
+ const parsed = parseSessionSearchQuery(query);
3261
+ if (parsed.error) return [];
3262
+ const hits: QolSessionSearchHit[] = [];
3263
+ if (!query.trim()) {
3264
+ for (const session of sessions) {
3265
+ const result = resultFromSession(session);
3266
+ for (const message of userMessagesForResult(result)) {
3267
+ const snippet = buildPromptSnippet(message, parsed);
3268
+ hits.push({ message, rank: 0, result: resultFromSession(session, 0, [snippet]), snippet });
3269
+ }
3270
+ }
3271
+ hits.sort(compareSessionSearchHitsRecent);
3272
+ return hits.slice(0, limit);
3273
+ }
3274
+ for (const session of sessions) {
3275
+ let addedPromptHit = false;
3276
+ for (const message of userMessagesForResult(resultFromSession(session))) {
3277
+ const match = matchPromptSearch(session, message, parsed);
3278
+ if (!match.matches) continue;
3279
+ const snippet = buildPromptSnippet(message, parsed);
3280
+ hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
3281
+ addedPromptHit = true;
3282
+ }
3283
+ if (!addedPromptHit) {
3284
+ const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
3285
+ if (!titleMatch.matches) continue;
3286
+ const messages = userMessagesForResult(resultFromSession(session));
3287
+ const message = messages[messages.length - 1];
3288
+ if (!message) continue;
3289
+ const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
3290
+ hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
3291
+ }
3292
+ }
3293
+ hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
3294
+ return hits.slice(0, limit);
3295
+ }
3296
+
3155
3297
  function styleSessionSnippet(snippet: string, query: string, theme: Theme): string {
3156
3298
  let styled = snippet;
3157
3299
  const parsed = parseSessionSearchQuery(query);
@@ -3211,11 +3353,14 @@ function isPrintableInput(data: string): boolean {
3211
3353
  }
3212
3354
 
3213
3355
  class QolSessionSearchComponent {
3214
- private screen: "search" | "messages" | "actions" | "focus" = "search";
3356
+ private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" = "search";
3215
3357
  private searchState: QolSessionSearchState;
3216
3358
  private messagesState: QolSessionMessagesState | undefined;
3217
3359
  private actionState: QolSessionActionState | undefined;
3218
- private focusState: QolSessionFocusState | undefined;
3360
+ private actionReturnScreen: "search" | "messages" = "messages";
3361
+ private contextConfirmState: QolSessionContextConfirmState | undefined;
3362
+ private forkConfirmState: QolSessionForkConfirmState | undefined;
3363
+
3219
3364
 
3220
3365
  constructor(
3221
3366
  private readonly done: (action: QolSessionPaletteAction) => void,
@@ -3226,12 +3371,15 @@ class QolSessionSearchComponent {
3226
3371
  initialQuery = "",
3227
3372
  ) {
3228
3373
  const query = initialQuery.trim();
3374
+ const scope = defaultSessionSearchScope(cwd);
3375
+ const scopedSessions = this.sessionsForScope(scope);
3229
3376
  this.searchState = {
3230
3377
  cursor: query.length,
3231
3378
  query,
3232
- results: searchQolSessions(sessions, query, cwd),
3379
+ results: searchQolSessionHits(scopedSessions, query, cwd),
3233
3380
  selected: 0,
3234
- total: sessions.length,
3381
+ scope,
3382
+ total: scopedSessions.length,
3235
3383
  };
3236
3384
  }
3237
3385
 
@@ -3240,16 +3388,19 @@ class QolSessionSearchComponent {
3240
3388
  private maxOverlayRows(): number {
3241
3389
  const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
3242
3390
  const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
3243
- return Math.max(8, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
3391
+ return Math.max(9, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
3392
+ }
3393
+
3394
+ private compactSearchLayout(): boolean {
3395
+ return this.maxOverlayRows() < 18;
3244
3396
  }
3245
3397
 
3246
3398
  private searchMaxVisibleRows(): number {
3247
3399
  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));
3400
+ // Each hit is two rows plus a separator between hits. Chrome is aggressively
3401
+ // reduced on short terminals so the popup can shrink to one visible hit.
3402
+ const chromeRows = this.compactSearchLayout() ? 7 : 11;
3403
+ const responsive = Math.max(1, Math.floor((this.maxOverlayRows() - chromeRows) / 3));
3253
3404
  return Math.max(1, Math.min(configured, responsive));
3254
3405
  }
3255
3406
 
@@ -3261,20 +3412,31 @@ class QolSessionSearchComponent {
3261
3412
  return Math.max(1, Math.min(configured, responsive));
3262
3413
  }
3263
3414
 
3415
+ private forkConfirmMaxVisibleRows(): number {
3416
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
3417
+ const responsive = Math.max(1, this.maxOverlayRows() - 13);
3418
+ return Math.max(1, Math.min(configured, responsive));
3419
+ }
3420
+
3264
3421
  render(width: number): string[] {
3265
3422
  const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
3266
3423
  const renderWidth = Math.min(Math.max(48, width), configured);
3267
3424
  if (this.screen === "messages" && this.messagesState) return this.renderMessages(renderWidth, this.messagesState);
3268
3425
  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);
3426
+ if (this.screen === "confirmContext" && this.contextConfirmState) return this.renderContextConfirm(renderWidth, this.contextConfirmState);
3427
+ if (this.screen === "confirmFork" && this.forkConfirmState) return this.renderForkConfirm(renderWidth, this.forkConfirmState);
3270
3428
  return this.renderSearch(renderWidth);
3271
3429
  }
3272
3430
 
3273
3431
  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);
3432
+ if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
3433
+ else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
3434
+ else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
3435
+ else if (this.screen === "confirmFork" && this.forkConfirmState) this.handleForkConfirmInput(data);
3436
+ else {
3437
+ this.screen = "search";
3438
+ this.handleSearchInput(data);
3439
+ }
3278
3440
  this.tui.requestRender();
3279
3441
  }
3280
3442
 
@@ -3282,12 +3444,29 @@ class QolSessionSearchComponent {
3282
3444
  this.searchState.selected = Math.max(0, Math.min(this.searchState.selected, Math.max(0, this.searchState.results.length - 1)));
3283
3445
  }
3284
3446
 
3447
+ private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
3448
+ if (scope === "all") return this.sessions;
3449
+ return this.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
3450
+ }
3451
+
3452
+ private updateResults(resetSelection = true): void {
3453
+ const state = this.searchState;
3454
+ const scopedSessions = this.sessionsForScope(state.scope);
3455
+ state.results = searchQolSessionHits(scopedSessions, state.query, this.cwd);
3456
+ state.total = scopedSessions.length;
3457
+ if (resetSelection) state.selected = 0;
3458
+ this.clampSelection();
3459
+ }
3460
+
3461
+ private toggleScope(): void {
3462
+ this.searchState.scope = this.searchState.scope === "current" ? "all" : "current";
3463
+ this.updateResults(true);
3464
+ }
3465
+
3285
3466
  private updateQuery(query: string, cursor: number): void {
3286
3467
  this.searchState.query = query;
3287
3468
  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();
3469
+ this.updateResults(true);
3291
3470
  }
3292
3471
 
3293
3472
  private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
@@ -3302,12 +3481,15 @@ class QolSessionSearchComponent {
3302
3481
  return Math.max(0, messages.length - 1);
3303
3482
  }
3304
3483
 
3305
- private openMessages(result: QolSessionSearchResult): void {
3484
+ private openMessages(result: QolSessionSearchResult, preferredMessage?: QolSessionUserMessage): void {
3306
3485
  const messages = userMessagesForResult(result);
3486
+ const preferredIndex = preferredMessage
3487
+ ? messages.findIndex((message) => message.index === preferredMessage.index || (message.entryId && message.entryId === preferredMessage.entryId))
3488
+ : -1;
3307
3489
  this.messagesState = {
3308
3490
  messages,
3309
3491
  result,
3310
- selected: this.selectedMessageIndex(messages, this.searchState.query),
3492
+ selected: preferredIndex >= 0 ? preferredIndex : this.selectedMessageIndex(messages, this.searchState.query),
3311
3493
  };
3312
3494
  this.screen = "messages";
3313
3495
  }
@@ -3316,16 +3498,106 @@ class QolSessionSearchComponent {
3316
3498
  return `Focus on prompt #${message.index}: ${message.text}`;
3317
3499
  }
3318
3500
 
3501
+ private openContextConfirm(type: "summarize" | "newSession", result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
3502
+ this.contextConfirmState = { message, result, returnScreen, type };
3503
+ this.screen = "confirmContext";
3504
+ }
3505
+
3506
+ private returnFromContextConfirm(state: QolSessionContextConfirmState): void {
3507
+ this.contextConfirmState = undefined;
3508
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
3509
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
3510
+ else this.screen = "search";
3511
+ }
3512
+
3513
+ private confirmContextAction(state: QolSessionContextConfirmState): void {
3514
+ this.done({ customPrompt: this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
3515
+ }
3516
+
3517
+ private openForkConfirm(result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
3518
+ const messages = userMessagesForResult(result);
3519
+ const preferredIndex = messages.findIndex((candidate) => candidate.index === message.index || (message.entryId && candidate.entryId === message.entryId));
3520
+ this.forkConfirmState = {
3521
+ messages,
3522
+ result,
3523
+ returnScreen,
3524
+ selected: preferredIndex >= 0 ? preferredIndex : 0,
3525
+ };
3526
+ this.screen = "confirmFork";
3527
+ }
3528
+
3529
+ private returnFromForkConfirm(state: QolSessionForkConfirmState): void {
3530
+ this.forkConfirmState = undefined;
3531
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
3532
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
3533
+ else this.screen = "search";
3534
+ }
3535
+
3536
+ private confirmForkAction(state: QolSessionForkConfirmState): void {
3537
+ const message = state.messages[state.selected];
3538
+ if (!message) return;
3539
+ this.done({ message, result: state.result, type: "fork" });
3540
+ }
3541
+
3542
+ private returnFromActions(): void {
3543
+ this.actionState = undefined;
3544
+ if (this.actionReturnScreen === "messages" && this.messagesState) this.screen = "messages";
3545
+ else this.screen = "search";
3546
+ }
3547
+
3319
3548
  private handleSearchInput(data: string): void {
3320
3549
  const state = this.searchState;
3321
3550
  if (matchesKey(data, "escape")) {
3322
3551
  this.done({ type: "cancel" });
3323
3552
  return;
3324
3553
  }
3554
+ if (matchesKey(data, "tab")) {
3555
+ this.toggleScope();
3556
+ return;
3557
+ }
3325
3558
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3326
- const result = state.results[state.selected];
3327
- if (!result) return;
3328
- this.openMessages(result);
3559
+ const hit = state.results[state.selected];
3560
+ if (!hit) return;
3561
+ this.openMessages(hit.result, hit.message);
3562
+ return;
3563
+ }
3564
+ if (matchesKey(data, "alt+c")) {
3565
+ const hit = state.results[state.selected];
3566
+ if (hit) this.done({ message: hit.message, result: hit.result, type: "copy" });
3567
+ return;
3568
+ }
3569
+ if (matchesKey(data, "alt+f")) {
3570
+ const hit = state.results[state.selected];
3571
+ if (hit) this.openForkConfirm(hit.result, hit.message, "search");
3572
+ return;
3573
+ }
3574
+ if (matchesKey(data, "alt+r")) {
3575
+ const hit = state.results[state.selected];
3576
+ if (hit) this.done({ result: hit.result, type: "resume" });
3577
+ return;
3578
+ }
3579
+ if (matchesKey(data, "alt+m")) {
3580
+ const hit = state.results[state.selected];
3581
+ if (hit) this.done({ keepCurrentModel: true, result: hit.result, type: "resume" });
3582
+ return;
3583
+ }
3584
+ if (matchesKey(data, "alt+i")) {
3585
+ const hit = state.results[state.selected];
3586
+ if (hit) this.openContextConfirm("summarize", hit.result, hit.message, "search");
3587
+ return;
3588
+ }
3589
+ if (matchesKey(data, "alt+n")) {
3590
+ const hit = state.results[state.selected];
3591
+ if (hit) this.openContextConfirm("newSession", hit.result, hit.message, "search");
3592
+ return;
3593
+ }
3594
+ if (matchesKey(data, "alt+a")) {
3595
+ const hit = state.results[state.selected];
3596
+ if (hit) {
3597
+ this.actionState = { message: hit.message, result: hit.result };
3598
+ this.actionReturnScreen = "search";
3599
+ this.screen = "actions";
3600
+ }
3329
3601
  return;
3330
3602
  }
3331
3603
  if (matchesKey(data, "up")) {
@@ -3333,7 +3605,7 @@ class QolSessionSearchComponent {
3333
3605
  return;
3334
3606
  }
3335
3607
  if (matchesKey(data, "down")) {
3336
- state.selected = Math.min(state.results.length - 1, state.selected + 1);
3608
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + 1);
3337
3609
  return;
3338
3610
  }
3339
3611
  if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
@@ -3341,7 +3613,7 @@ class QolSessionSearchComponent {
3341
3613
  return;
3342
3614
  }
3343
3615
  if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3344
- state.selected = Math.min(state.results.length - 1, state.selected + this.searchMaxVisibleRows());
3616
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + this.searchMaxVisibleRows());
3345
3617
  return;
3346
3618
  }
3347
3619
  if (matchesKey(data, "left")) {
@@ -3394,14 +3666,39 @@ class QolSessionSearchComponent {
3394
3666
  if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3395
3667
  const message = state.messages[state.selected];
3396
3668
  if (!message) return;
3397
- this.actionState = { actionIndex: 0, message, result: state.result };
3669
+ this.actionState = { message, result: state.result };
3670
+ this.actionReturnScreen = "messages";
3398
3671
  this.screen = "actions";
3399
3672
  return;
3400
3673
  }
3401
- if (data.toLowerCase() === "r") {
3674
+ if (matchesKey(data, "alt+r")) {
3402
3675
  this.done({ result: state.result, type: "resume" });
3403
3676
  return;
3404
3677
  }
3678
+ if (matchesKey(data, "alt+m")) {
3679
+ this.done({ keepCurrentModel: true, result: state.result, type: "resume" });
3680
+ return;
3681
+ }
3682
+ if (matchesKey(data, "alt+c")) {
3683
+ const message = state.messages[state.selected];
3684
+ if (message) this.done({ message, result: state.result, type: "copy" });
3685
+ return;
3686
+ }
3687
+ if (matchesKey(data, "alt+f")) {
3688
+ const message = state.messages[state.selected];
3689
+ if (message) this.openForkConfirm(state.result, message, "messages");
3690
+ return;
3691
+ }
3692
+ if (matchesKey(data, "alt+i")) {
3693
+ const message = state.messages[state.selected];
3694
+ if (message) this.openContextConfirm("summarize", state.result, message, "messages");
3695
+ return;
3696
+ }
3697
+ if (matchesKey(data, "alt+n")) {
3698
+ const message = state.messages[state.selected];
3699
+ if (message) this.openContextConfirm("newSession", state.result, message, "messages");
3700
+ return;
3701
+ }
3405
3702
  if (matchesKey(data, "up")) {
3406
3703
  state.selected = Math.max(0, state.selected - 1);
3407
3704
  return;
@@ -3431,146 +3728,202 @@ class QolSessionSearchComponent {
3431
3728
  const state = this.actionState;
3432
3729
  if (!state) return;
3433
3730
  if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3434
- this.screen = "messages";
3731
+ this.returnFromActions();
3435
3732
  return;
3436
3733
  }
3437
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
3438
- state.actionIndex = (state.actionIndex + 1) % QOL_SESSION_ACTIONS.length;
3734
+ if (matchesKey(data, "alt+c")) {
3735
+ this.done({ message: state.message, result: state.result, type: "copy" });
3439
3736
  return;
3440
3737
  }
3441
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
3442
- state.actionIndex = (state.actionIndex - 1 + QOL_SESSION_ACTIONS.length) % QOL_SESSION_ACTIONS.length;
3738
+ if (matchesKey(data, "alt+f")) {
3739
+ this.openForkConfirm(state.result, state.message, "actions");
3443
3740
  return;
3444
3741
  }
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";
3742
+ if (matchesKey(data, "alt+r")) {
3743
+ this.done({ result: state.result, type: "resume" });
3744
+ return;
3745
+ }
3746
+ if (matchesKey(data, "alt+m")) {
3747
+ this.done({ keepCurrentModel: true, result: state.result, type: "resume" });
3748
+ return;
3749
+ }
3750
+ if (matchesKey(data, "alt+i")) {
3751
+ this.openContextConfirm("summarize", state.result, state.message, "actions");
3752
+ return;
3753
+ }
3754
+ if (matchesKey(data, "alt+n")) {
3755
+ this.openContextConfirm("newSession", state.result, state.message, "actions");
3756
+ return;
3457
3757
  }
3458
3758
  }
3459
3759
 
3460
- private handleFocusInput(data: string): void {
3461
- const state = this.focusState;
3760
+ private handleContextConfirmInput(data: string): void {
3761
+ const state = this.contextConfirmState;
3462
3762
  if (!state) return;
3463
- if (matchesKey(data, "escape")) {
3464
- this.screen = "actions";
3763
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3764
+ this.returnFromContextConfirm(state);
3465
3765
  return;
3466
3766
  }
3467
3767
  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;
3768
+ this.confirmContextAction(state);
3470
3769
  }
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);
3770
+ }
3771
+
3772
+ private handleForkConfirmInput(data: string): void {
3773
+ const state = this.forkConfirmState;
3774
+ if (!state) return;
3775
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3776
+ this.returnFromForkConfirm(state);
3477
3777
  return;
3478
3778
  }
3479
- if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3480
- state.cursor = 0;
3779
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3780
+ this.confirmForkAction(state);
3481
3781
  return;
3482
3782
  }
3483
- if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3484
- state.cursor = state.prompt.length;
3783
+ if (matchesKey(data, "up")) {
3784
+ state.selected = Math.max(0, state.selected - 1);
3485
3785
  return;
3486
3786
  }
3487
- if (matchesKey(data, "ctrl+u")) {
3488
- state.prompt = "";
3489
- state.cursor = 0;
3787
+ if (matchesKey(data, "down")) {
3788
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + 1);
3490
3789
  return;
3491
3790
  }
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;
3791
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3792
+ state.selected = Math.max(0, state.selected - this.forkConfirmMaxVisibleRows());
3500
3793
  return;
3501
3794
  }
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
- }
3795
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3796
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + this.forkConfirmMaxVisibleRows());
3507
3797
  return;
3508
3798
  }
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)}`;
3799
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3800
+ state.selected = 0;
3511
3801
  return;
3512
3802
  }
3513
- if (isPrintableInput(data)) {
3514
- state.prompt = `${state.prompt.slice(0, state.cursor)}${data}${state.prompt.slice(state.cursor)}`;
3515
- state.cursor += data.length;
3803
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3804
+ state.selected = Math.max(0, state.messages.length - 1);
3516
3805
  }
3517
3806
  }
3518
3807
 
3519
3808
  private renderSearch(width: number): string[] {
3520
- const { bottom, divider, empty, filledRow, inner, row, selectedRow, top } = boxParts(width, this.theme);
3809
+ const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
3521
3810
  const state = this.searchState;
3522
- const lines: string[] = [top("Session Search", `${state.results.length}/${state.total} shown`), empty()];
3811
+ const compact = this.compactSearchLayout();
3812
+ const lines: string[] = [top("Session Search", `${state.results.length} hits`), empty()];
3523
3813
  const accent = (s: string) => this.theme.fg("accent", s);
3524
3814
  const dim = (s: string) => this.theme.fg("dim", s);
3525
3815
  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
3816
  const cursorChar = state.query[state.cursor] ?? " ";
3533
3817
  const queryDisplay = `${state.query.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.query.slice(state.cursor + (state.cursor < state.query.length ? 1 : 0))}`;
3818
+ lines.push(row(this.renderScopeTabs(inner)));
3819
+ if (!compact) lines.push(empty());
3534
3820
  lines.push(filledRow(` > ${queryDisplay}`));
3535
- lines.push(row(dim(`${state.total} sessions · re:<pattern> regex · "phrase" exact`)));
3536
- lines.push(empty(), divider(), empty());
3821
+ if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
3822
+ lines.push(divider());
3823
+
3537
3824
  if (state.results.length === 0) {
3538
- lines.push(row(muted(state.query.trim() ? "No sessions match your search" : "No sessions found")), empty());
3825
+ lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
3539
3826
  } 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"}`)));
3827
+ lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
3828
+ }
3829
+
3830
+ lines.push(divider());
3831
+ if (compact) {
3832
+ lines.push(row(`${ansiYellow("alt+c/f/r/m/i/n/a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
3833
+ } else {
3834
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
3835
+ lines.push(row(`${ansiYellow("alt+m")} ${dim("resume+model")} ${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")} ${ansiYellow("alt+a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
3567
3836
  }
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
3837
  lines.push(bottom());
3571
3838
  return lines;
3572
3839
  }
3573
3840
 
3841
+ private renderSearchHitPane(
3842
+ inner: number,
3843
+ row: (content?: string) => string,
3844
+ dim: (s: string) => string,
3845
+ muted: (s: string) => string,
3846
+ accent: (s: string) => string,
3847
+ ): string[] {
3848
+ const state = this.searchState;
3849
+ const compact = this.compactSearchLayout();
3850
+ const maxVisible = this.searchMaxVisibleRows();
3851
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3852
+ const end = Math.min(start + maxVisible, state.results.length);
3853
+ const gap = ` ${dim("│")} `;
3854
+ const twoPane = inner >= 72;
3855
+ const rightWidth = twoPane ? Math.max(24, Math.floor(inner * 0.43)) : 0;
3856
+ const leftWidth = twoPane ? Math.max(24, inner - rightWidth - visibleWidth(gap)) : inner;
3857
+ const paneRows = Math.max(1, (end - start) * 3 - 1);
3858
+ const selectedHit = state.results[state.selected];
3859
+ const rightLines = twoPane ? this.renderPromptPreviewLines(selectedHit, rightWidth, paneRows, dim, muted, accent) : [];
3860
+ const fixed = (content: string, colWidth: number, selected = false): string => {
3861
+ const safe = content.replace(/[\r\n\t]+/g, " ");
3862
+ const clipped = truncateToWidth(safe, colWidth, "");
3863
+ const padded = clipped + " ".repeat(Math.max(0, colWidth - visibleWidth(clipped)));
3864
+ return selected ? this.theme.bg("selectedBg", padded) : padded;
3865
+ };
3866
+ const leftLines: string[] = [];
3867
+ for (let i = start; i < end; i++) {
3868
+ const hit = state.results[i]!;
3869
+ const selected = i === state.selected;
3870
+ const promptDate = new Date(promptRecencyTime(hit));
3871
+ const metadata = dim(`#${hit.message.index} · ${formatSessionSearchDate(promptDate)}`);
3872
+ const titleText = truncateToWidth(sessionResumeTitle(hit.result), Math.max(8, leftWidth - visibleWidth(metadata) - 1), "…");
3873
+ const title = accent(titleText);
3874
+ const snippet = styleSessionSnippet(hit.snippet || hit.message.text, state.query, this.theme);
3875
+ leftLines.push(fixed(selected ? this.theme.bold(snippet) : snippet, leftWidth, selected));
3876
+ leftLines.push(fixed(`${title} ${metadata}`, leftWidth, selected));
3877
+ if (i < end - 1) leftLines.push(fixed(dim("─".repeat(Math.max(8, leftWidth))), leftWidth));
3878
+ }
3879
+ const output: string[] = [];
3880
+ for (let i = 0; i < leftLines.length; i++) {
3881
+ if (twoPane) output.push(row(`${leftLines[i] ?? ""}${gap}${rightLines[i] ?? " ".repeat(rightWidth)}`));
3882
+ else output.push(row(leftLines[i] ?? ""));
3883
+ }
3884
+ if (!twoPane && selectedHit) {
3885
+ output.push(row(dim("─".repeat(Math.max(8, inner)))));
3886
+ for (const line of this.renderPromptPreviewLines(selectedHit, inner, Math.min(5, paneRows), dim, muted, accent)) output.push(row(line));
3887
+ }
3888
+ if (!compact && state.results.length > maxVisible) output.push(row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent prompts"}`)));
3889
+ return output;
3890
+ }
3891
+
3892
+ private renderPromptPreviewLines(
3893
+ hit: QolSessionSearchHit | undefined,
3894
+ width: number,
3895
+ maxLines: number,
3896
+ dim: (s: string) => string,
3897
+ muted: (s: string) => string,
3898
+ accent: (s: string) => string,
3899
+ ): string[] {
3900
+ const fixed = (content: string): string => {
3901
+ const clipped = truncateToWidth(content.replace(/[\r\n\t]+/g, " "), width, "…");
3902
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
3903
+ };
3904
+ if (!hit) return Array.from({ length: maxLines }, () => fixed(""));
3905
+ const title = accent("Prompt preview");
3906
+ const cwd = muted(truncateToWidth(shortPathForUi(hit.result.cwd || hit.result.path), width, "…"));
3907
+ const bodyBudget = Math.max(1, maxLines - 3);
3908
+ const body = wrapVisible(styleSessionSnippet(hit.message.text, this.searchState.query, this.theme), width, bodyBudget);
3909
+ const lines = [fixed(title), fixed(cwd), fixed(dim("─".repeat(Math.max(8, width)))), ...body.map(fixed)];
3910
+ while (lines.length < maxLines) lines.push(fixed(""));
3911
+ return lines.slice(0, maxLines);
3912
+ }
3913
+
3914
+ private renderScopeTabs(inner: number): string {
3915
+ const tabs: { id: QolSessionSearchScope; label: string }[] = [
3916
+ { id: "current", label: "Project" },
3917
+ { id: "all", label: "All" },
3918
+ ];
3919
+ const parts = tabs.map((tab) => {
3920
+ const label = ` ${truncateToWidth(tab.label, 18, "…")} `;
3921
+ if (tab.id === this.searchState.scope) return this.theme.fg("accent", this.theme.inverse(this.theme.bold(label)));
3922
+ return this.theme.bg("selectedBg", this.theme.fg("accent", label));
3923
+ });
3924
+ return truncateToWidth(parts.join(" "), inner, "");
3925
+ }
3926
+
3574
3927
  private renderMessages(width: number, state: QolSessionMessagesState): string[] {
3575
3928
  const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3576
3929
  const accent = (s: string) => this.theme.fg("accent", s);
@@ -3584,11 +3937,10 @@ class QolSessionSearchComponent {
3584
3937
  const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3585
3938
  return `${clippedLeft}${" ".repeat(gap)}${right}`;
3586
3939
  };
3587
- const right = dim(`${promptCountLabel(state.messages.length)} · ${formatSessionSearchDate(result.modified)}`);
3940
+ const right = dim(formatSessionSearchDate(result.modified));
3588
3941
  const title = truncateToWidth(sessionResumeTitle(result), Math.max(12, inner - visibleWidth(right) - 1), "…");
3589
3942
  lines.push(row(pair(this.theme.bold(accent(title)), right)));
3590
3943
  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
3944
  lines.push(empty(), divider(), empty());
3593
3945
 
3594
3946
  const maxVisible = this.messageMaxVisibleRows();
@@ -3604,9 +3956,9 @@ class QolSessionSearchComponent {
3604
3956
  const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
3605
3957
  lines.push(selected ? selectedRow(messageRow) : row(messageRow));
3606
3958
  }
3607
- if (state.messages.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.messages.length} user prompts`)));
3608
3959
  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")}`));
3960
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
3961
+ lines.push(row(`${ansiYellow("alt+m")} ${dim("resume+model")} ${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
3610
3962
  lines.push(bottom());
3611
3963
  return lines;
3612
3964
  }
@@ -3624,46 +3976,85 @@ class QolSessionSearchComponent {
3624
3976
  lines.push(row(dim("Selected prompt")));
3625
3977
  const wrapped = wrapVisible(state.message.text, inner, 4);
3626
3978
  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
3979
  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));
3980
+ const helpLines = [
3981
+ "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.",
3982
+ "Copy Prompt copies this prompt into your current editor.",
3983
+ "Inject + Context summarizes the source session into the current session, focused on this prompt.",
3984
+ "New + Context creates a fresh session and imports that focused summary there.",
3985
+ ];
3986
+ for (const help of helpLines) {
3987
+ for (const line of wrapVisible(dim(help), inner, 2)) lines.push(row(line));
3988
+ }
3989
+ lines.push(empty(), divider(), empty());
3990
+ lines.push(row(`${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")} ${ansiYellow("alt+m")} ${dim("resume+model")}`));
3991
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
3992
+ lines.push(bottom());
3993
+ return lines;
3994
+ }
3995
+
3996
+ private renderForkConfirm(width: number, state: QolSessionForkConfirmState): string[] {
3997
+ const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3998
+ const accent = (s: string) => this.theme.fg("accent", s);
3999
+ const dim = (s: string) => this.theme.fg("dim", s);
4000
+ const muted = (s: string) => this.theme.fg("muted", s);
4001
+ const lines: string[] = [top("Fork Session", `${state.selected + 1}/${state.messages.length}`), empty()];
4002
+ const title = truncateToWidth(sessionResumeTitle(state.result), inner, "…");
4003
+ lines.push(row(this.theme.bold(accent(title))));
4004
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
4005
+ lines.push(empty(), divider(), empty());
4006
+ 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)) {
4007
+ lines.push(row(line));
4008
+ }
3636
4009
  lines.push(empty());
3637
- lines.push(row(`${ansiYellow("tab")}/${ansiYellow("←→")} ${dim("cycle")} ${ansiYellow("enter")} ${dim("choose")} ${ansiYellow("esc")} ${dim("prompts")}`));
4010
+
4011
+ const maxVisible = this.forkConfirmMaxVisibleRows();
4012
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
4013
+ const end = Math.min(start + maxVisible, state.messages.length);
4014
+ for (let i = start; i < end; i++) {
4015
+ const message = state.messages[i]!;
4016
+ const selected = i === state.selected;
4017
+ const numberPlain = `#${message.index}`;
4018
+ const number = selected ? this.theme.fg("text", numberPlain) : dim(numberPlain);
4019
+ const textWidth = Math.max(12, inner - visibleWidth(number) - 1);
4020
+ const text = truncateToWidth(message.text, textWidth, "…");
4021
+ const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
4022
+ lines.push(selected ? selectedRow(messageRow) : row(messageRow));
4023
+ }
4024
+ if (state.messages.length > maxVisible) lines.push(row(dim(`${state.selected + 1}/${state.messages.length} prompts`)));
4025
+ lines.push(divider(), empty());
4026
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
3638
4027
  lines.push(bottom());
3639
4028
  return lines;
3640
4029
  }
3641
4030
 
3642
- private renderFocus(width: number, state: QolSessionFocusState): string[] {
3643
- const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
4031
+ private renderContextConfirm(width: number, state: QolSessionContextConfirmState): string[] {
4032
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
3644
4033
  const accent = (s: string) => this.theme.fg("accent", s);
3645
4034
  const dim = (s: string) => this.theme.fg("dim", s);
3646
4035
  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, "…"))));
4036
+ const action = state.type === "newSession" ? "New + Context" : "Inject + Context";
4037
+ const verb = state.type === "newSession" ? "create new session with context" : "inject context";
4038
+ const lines: string[] = [top(action), empty()];
4039
+ lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`#${state.message.index}`)}`));
4040
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
3651
4041
  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]}`));
4042
+ const details = [
4043
+ "This summarizes the entire source session, including messages after this prompt.",
4044
+ "The selected prompt is used as the focus for the summary, not as a cutoff point.",
4045
+ state.type === "newSession"
4046
+ ? "New + Context creates a fresh session and imports that focused summary there."
4047
+ : "Inject + Context adds that focused summary to your current session.",
4048
+ ];
4049
+ for (const detail of details) {
4050
+ for (const line of wrapVisible(dim(detail), inner, 2)) lines.push(row(line));
3661
4051
  }
3662
4052
  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")}`));
4053
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
3664
4054
  lines.push(bottom());
3665
4055
  return lines;
3666
4056
  }
4057
+
3667
4058
  }
3668
4059
 
3669
4060
  function wrapVisible(text: string, width: number, maxLines: number): string[] {
@@ -3728,7 +4119,6 @@ class QolSessionSearchLoadingComponent {
3728
4119
  empty(),
3729
4120
  divider(),
3730
4121
  empty(),
3731
- row(`${ansiYellow("esc")} ${dim("cancel")} ${ansiYellow("ctrl+c")} ${dim("cancel")}`),
3732
4122
  bottom(),
3733
4123
  ];
3734
4124
  }
@@ -3769,7 +4159,7 @@ function messagesForSessionSummary(sessionPath: string): AgentMessage[] {
3769
4159
  async function buildSessionSearchContextMessage(ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string, signal?: AbortSignal): Promise<QolSessionSearchPendingMessage> {
3770
4160
  const messages = messagesForSessionSummary(result.path);
3771
4161
  const fallbackText = result.allMessagesText || result.firstMessage;
3772
- const conversationText = messages.length > 0 ? serializeConversation(convertToLlm(messages)) : fallbackText;
4162
+ const conversationText = messages.length > 0 ? serializeMessagesForSummary(messages) : fallbackText;
3773
4163
  if (!conversationText.trim()) throw new Error("Selected session has no text content to summarize");
3774
4164
  const focus = customPrompt?.trim();
3775
4165
  const summary = await generateQolSummary(ctx, {
@@ -3851,7 +4241,7 @@ async function buildSessionSearchContextMessageWithLoader(ctx: ExtensionContext,
3851
4241
 
3852
4242
  async function injectSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3853
4243
  const title = sessionDisplayName(result);
3854
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject Context");
4244
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject + Context");
3855
4245
  pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { deliverAs: "followUp", triggerTurn: false });
3856
4246
  ctx.ui.notify(`Session context injected: ${title}`, "info");
3857
4247
  }
@@ -3860,9 +4250,52 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
3860
4250
  return ctx as ExtensionContext & Partial<ExtensionCommandContext>;
3861
4251
  }
3862
4252
 
4253
+ function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
4254
+ const id = `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
4255
+ qolSessionSearchPendingActions.set(id, action);
4256
+ ctx.ui.setEditorText(`/search:resume-pending ${id}`);
4257
+ ctx.ui.notify(`${sessionDisplayName(action.result!)} — press Enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
4258
+ }
4259
+
4260
+ async function runSessionSearchResumeOrFork(pi: ExtensionAPI, ctx: ExtensionContext, action: QolSessionPaletteAction): Promise<boolean> {
4261
+ if (!action.result || (action.type !== "resume" && action.type !== "fork")) return true;
4262
+ const commandCtx = asCommandContext(ctx);
4263
+ if (typeof commandCtx.switchSession !== "function") return false;
4264
+ const targetTitle = sessionDisplayName(action.result);
4265
+ const selectedMessage = action.type === "fork" ? action.message : undefined;
4266
+ const currentModel = action.keepCurrentModel ? ctx.model : undefined;
4267
+ const currentThinking = action.keepCurrentModel && typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined;
4268
+ if (currentModel) pinSessionModel(action.result.path, currentModel, currentThinking);
4269
+ let replacementStarted = false;
4270
+ try {
4271
+ const result = await commandCtx.switchSession(action.result.path, {
4272
+ withSession: async (replacementCtx: any) => {
4273
+ replacementStarted = true;
4274
+ if (currentModel) replacementCtx.ui.notify(`Using current model: ${currentModel.provider}/${currentModel.id}`, "info");
4275
+ if (selectedMessage) {
4276
+ const manager = replacementCtx.sessionManager as any;
4277
+ if (selectedMessage.entryId && selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
4278
+ else if (selectedMessage.entryId && selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
4279
+ replacementCtx.ui.setEditorText(selectedMessage.text);
4280
+ const timer = setTimeout(() => {
4281
+ replacementCtx.ui.notify(`Forked session: ${targetTitle} at prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
4282
+ }, 0);
4283
+ timer.unref?.();
4284
+ return;
4285
+ }
4286
+ replacementCtx.ui.notify(`Resumed session: ${targetTitle}${currentModel ? " with current model" : ""}`, "info");
4287
+ },
4288
+ });
4289
+ if (result.cancelled) ctx.ui.notify(selectedMessage ? "Fork cancelled" : "Resume cancelled", "info");
4290
+ } catch (error) {
4291
+ if (!replacementStarted) ctx.ui.notify(`${selectedMessage ? "Fork" : "Resume"} failed: ${stringifyError(error)}`, "error");
4292
+ }
4293
+ return true;
4294
+ }
4295
+
3863
4296
  async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3864
4297
  const title = sessionDisplayName(result);
3865
- const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New Session + Context");
4298
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New + Context");
3866
4299
 
3867
4300
  const commandCtx = asCommandContext(ctx);
3868
4301
  if (typeof commandCtx.newSession === "function") {
@@ -3891,7 +4324,7 @@ async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: Extensio
3891
4324
  }
3892
4325
  setPendingSessionSearchMessage(message);
3893
4326
  ctx.ui.setEditorText("/new");
3894
- ctx.ui.notify(`${title} — press Enter to create a new session with this context`, "info");
4327
+ ctx.ui.notify(`${title} — press enter to create a new session with this context`, "info");
3895
4328
  }
3896
4329
 
3897
4330
  async function consumePendingSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, reason: unknown): Promise<void> {
@@ -3925,35 +4358,8 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3925
4358
  releaseModalLock();
3926
4359
  }
3927
4360
  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");
4361
+ if (action.type === "resume" || action.type === "fork") {
4362
+ if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) queueSessionSearchCommandAction(ctx, action);
3957
4363
  return;
3958
4364
  }
3959
4365
  if (action.type === "copy") {
@@ -3967,7 +4373,7 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3967
4373
  await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
3968
4374
  } catch (error) {
3969
4375
  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");
4376
+ ctx.ui.notify(/cancelled/i.test(message) ? "Inject + Context cancelled" : `Inject + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3971
4377
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3972
4378
  }
3973
4379
  return;
@@ -3977,16 +4383,12 @@ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, ini
3977
4383
  await createNewSessionWithSearchContext(pi, ctx, action.result, action.customPrompt);
3978
4384
  } catch (error) {
3979
4385
  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");
4386
+ ctx.ui.notify(/cancelled/i.test(message) ? "New + Context cancelled" : `New + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3981
4387
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3982
4388
  }
3983
4389
  }
3984
4390
  }
3985
4391
 
3986
- function quoteSessionSearchArg(value: string): string {
3987
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3988
- }
3989
-
3990
4392
  function renderSessionSearchContextMessage(message: any, options: any, theme: Theme): Text {
3991
4393
  const raw = typeof message.content === "string"
3992
4394
  ? message.content
@@ -4182,6 +4584,10 @@ export default function qol(pi: ExtensionAPI): void {
4182
4584
  let tmuxChangedPaneBorderStatus = false;
4183
4585
  let tmuxChangedPaneBorderFormat = false;
4184
4586
  let tmuxLastPaneTitle: string | undefined;
4587
+ let tmuxOriginalWindowNameTitle: string | undefined;
4588
+ let tmuxOriginalAutomaticRename: string | undefined;
4589
+ let tmuxChangedAutomaticRename = false;
4590
+ let tmuxLastWindowNameTitle: string | undefined;
4185
4591
  let lastSessionTitle: string | undefined;
4186
4592
 
4187
4593
  const requestRender = () => activeTui?.requestRender();
@@ -4208,9 +4614,17 @@ export default function qol(pi: ExtensionAPI): void {
4208
4614
  const target = tmuxPaneTitleTarget;
4209
4615
  if (!target) return;
4210
4616
  const nextTitle = sessionTitle ? formatTmuxSessionTitle(sessionTitle) : tmuxOriginalPaneTitle;
4211
- if (nextTitle === undefined || nextTitle === tmuxLastPaneTitle) return;
4212
- tmuxLastPaneTitle = nextTitle;
4213
- setTmuxPaneTitle(target, nextTitle);
4617
+ if (nextTitle !== undefined && nextTitle !== tmuxLastPaneTitle) {
4618
+ tmuxLastPaneTitle = nextTitle;
4619
+ setTmuxPaneTitle(target, nextTitle);
4620
+ }
4621
+ if (settingBoolean("showSessionNameWindow", true, ctx.cwd)) {
4622
+ const nextWindow = sessionTitle ? formatTmuxSessionTitle(sessionTitle) : tmuxOriginalWindowNameTitle;
4623
+ if (nextWindow !== undefined && nextWindow !== tmuxLastWindowNameTitle) {
4624
+ tmuxLastWindowNameTitle = nextWindow;
4625
+ setTmuxWindowName(target, nextWindow);
4626
+ }
4627
+ }
4214
4628
  };
4215
4629
 
4216
4630
  const installSessionTitle = (ctx: ExtensionContext) => {
@@ -4239,6 +4653,21 @@ export default function qol(pi: ExtensionAPI): void {
4239
4653
  setTmuxWindowOption(tmuxTarget, "pane-border-format", TMUX_SESSION_TITLE_BORDER_FORMAT);
4240
4654
  }
4241
4655
  });
4656
+ if (settingBoolean("showSessionNameWindow", true, ctx.cwd)) {
4657
+ readTmuxWindowName(tmuxTarget, (name) => {
4658
+ if (tmuxPaneTitleTarget !== tmuxTarget) return;
4659
+ tmuxOriginalWindowNameTitle = name;
4660
+ syncSessionTitle(ctx);
4661
+ });
4662
+ readTmuxWindowOption(tmuxTarget, "automatic-rename", (value) => {
4663
+ if (tmuxPaneTitleTarget !== tmuxTarget) return;
4664
+ tmuxOriginalAutomaticRename = value;
4665
+ if (value !== "off") {
4666
+ tmuxChangedAutomaticRename = true;
4667
+ setTmuxWindowOption(tmuxTarget, "automatic-rename", "off");
4668
+ }
4669
+ });
4670
+ }
4242
4671
  return;
4243
4672
  }
4244
4673
  ctx.ui.setHeader((tui, theme) => {
@@ -4264,6 +4693,8 @@ export default function qol(pi: ExtensionAPI): void {
4264
4693
  if (tmuxPaneTitleTarget && tmuxOriginalPaneTitle !== undefined) setTmuxPaneTitle(tmuxPaneTitleTarget, tmuxOriginalPaneTitle);
4265
4694
  if (tmuxPaneTitleTarget && tmuxChangedPaneBorderStatus && tmuxOriginalPaneBorderStatus !== undefined) setTmuxWindowOption(tmuxPaneTitleTarget, "pane-border-status", tmuxOriginalPaneBorderStatus);
4266
4695
  if (tmuxPaneTitleTarget && tmuxChangedPaneBorderFormat && tmuxOriginalPaneBorderFormat !== undefined) setTmuxWindowOption(tmuxPaneTitleTarget, "pane-border-format", tmuxOriginalPaneBorderFormat);
4696
+ if (tmuxPaneTitleTarget && tmuxOriginalWindowNameTitle !== undefined && tmuxLastWindowNameTitle !== undefined) setTmuxWindowName(tmuxPaneTitleTarget, tmuxOriginalWindowNameTitle);
4697
+ if (tmuxPaneTitleTarget && tmuxChangedAutomaticRename && tmuxOriginalAutomaticRename !== undefined) setTmuxWindowOption(tmuxPaneTitleTarget, "automatic-rename", tmuxOriginalAutomaticRename);
4267
4698
  tmuxPaneTitleTarget = undefined;
4268
4699
  tmuxOriginalPaneTitle = undefined;
4269
4700
  tmuxOriginalPaneBorderStatus = undefined;
@@ -4271,6 +4702,10 @@ export default function qol(pi: ExtensionAPI): void {
4271
4702
  tmuxChangedPaneBorderStatus = false;
4272
4703
  tmuxChangedPaneBorderFormat = false;
4273
4704
  tmuxLastPaneTitle = undefined;
4705
+ tmuxOriginalWindowNameTitle = undefined;
4706
+ tmuxOriginalAutomaticRename = undefined;
4707
+ tmuxChangedAutomaticRename = false;
4708
+ tmuxLastWindowNameTitle = undefined;
4274
4709
  lastSessionTitle = undefined;
4275
4710
  ctx.ui.setStatus(SESSION_MANAGER_STATUS_KEY, undefined);
4276
4711
  ctx.ui.setWidget("statusline", undefined);
@@ -4573,6 +5008,19 @@ export default function qol(pi: ExtensionAPI): void {
4573
5008
  description: "Refresh the session search index",
4574
5009
  handler: async (_args, ctx) => handleSearchCommand("refresh", ctx),
4575
5010
  });
5011
+ pi.registerCommand("search:resume-pending", {
5012
+ description: "Run a pending session-search resume or fork action",
5013
+ handler: async (args, ctx) => {
5014
+ const id = args.trim();
5015
+ const action = qolSessionSearchPendingActions.get(id);
5016
+ if (!action) {
5017
+ ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
5018
+ return;
5019
+ }
5020
+ qolSessionSearchPendingActions.delete(id);
5021
+ if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
5022
+ },
5023
+ });
4576
5024
  const shortcut = sessionSearchShortcut();
4577
5025
  if (shortcut) {
4578
5026
  pi.registerShortcut(shortcut, {