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