@vanillagreen/pi-qol 1.0.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.
@@ -0,0 +1,4460 @@
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";
6
+ import { execFile, execFileSync } from "node:child_process";
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
+
11
+ const INSTALL_SYMBOL = Symbol.for("vstack.pi-qol.installed");
12
+ const STATUS_KEY = "qol-attachments";
13
+ const SESSION_SEARCH_STATUS_KEY = "qol-session-search";
14
+ const SESSION_SEARCH_CONTEXT_TYPE = "qol-session-context";
15
+ const CONTEXT_USAGE_MESSAGE_TYPE = "qol-context-usage";
16
+ const SESSION_MANAGER_STATUS_KEY = "session-manager";
17
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".heic", ".heif"]);
18
+ const IMAGE_PATH_PATTERN = /(^|[\s(\[{<"'`])(@?(?:~|\.\.?|\/)[^\s)\]}>"'`]+?\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))(?=$|[\s)\]}>"'`,.;:!?])/gi;
19
+ const QUESTION_SERVICE_SYMBOL = Symbol.for("vstack.pi-questions.service");
20
+ const QOL_NOTIFICATION_SERVICE_SYMBOL = Symbol.for("vstack.pi-qol.notification-service");
21
+ const VSTACK_MODAL_LOCK_SYMBOL = Symbol.for("vstack.pi.modal-lock");
22
+ const THINKING_TIMER_STORE_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.store");
23
+ const THINKING_TIMER_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.patch");
24
+ const SESSION_SEARCH_PENDING_SYMBOL = Symbol.for("vstack.pi-qol.session-search.pending-context");
25
+ const PENDING_QUEUE_THEME_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.pending-queue.theme-patch");
26
+ const STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.status-text-alignment-patch");
27
+ const QUESTION_OPENED_EVENT = "vstack:pi-questions:opened";
28
+ const QUESTION_NOTIFY_DEDUP_MS = 2000;
29
+ const DEFAULT_NOTIFICATION_TITLE = "Pi";
30
+ const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
31
+ const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
32
+ const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
33
+ const DEFAULT_COMPACTION_MODEL = "google/gemini-2.5-flash";
34
+ const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
35
+ const DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS = 200000;
36
+ const DEFAULT_IDLE_COMPACTION_SECONDS = 300;
37
+ const DEFAULT_PERMISSION_GATE_COMMANDS = "rm -Rf";
38
+ const DEFAULT_PERMISSION_GATE_PREVIEW_LINES = 12;
39
+ const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
40
+ const DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH = 120;
41
+ const DEFAULT_SESSION_SEARCH_LIMIT = 40;
42
+ const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
43
+ const DEFAULT_SESSION_SEARCH_SHORTCUT = "f3";
44
+ const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
45
+ const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
46
+ const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
47
+ const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
48
+ const DEFAULT_AUTO_RENAME_FALLBACK_MODEL = "current";
49
+ const DEFAULT_AUTO_RENAME_INPUT_CHARS = 2000;
50
+ const DEFAULT_AUTO_RENAME_NAME_CHARS = 80;
51
+ const DEFAULT_AUTO_RENAME_MAX_TOKENS = 96;
52
+ const DEFAULT_AUTO_RENAME_TIMEOUT_MS = 12_000;
53
+ const DEFAULT_INPUT_BOTTOM_PADDING_LINES = 0;
54
+ const SESSION_TITLE_SYNC_INTERVAL_MS = 1000;
55
+ // tmux trims plain leading/trailing spaces from pane-border-format output, so
56
+ // use NBSP padding. Terminals render it as a space, but tmux keeps it visible.
57
+ const TMUX_SESSION_TITLE_PAD = "\u00a0";
58
+ const TMUX_SESSION_TITLE_BORDER_FORMAT = `${TMUX_SESSION_TITLE_PAD}#{pane_title}${TMUX_SESSION_TITLE_PAD}`;
59
+
60
+ const AUTO_RENAME_SYSTEM_PROMPT = "You create short, descriptive session names for coding-agent chats. Use 2-6 words in Title Case. Respond with only the name, no quotes, explanations, markdown, emoji, or trailing punctuation.";
61
+
62
+ const DEFAULT_AUTO_RENAME_PROMPT = `Generate a short, descriptive title for this Pi coding-agent session based on the first user message.
63
+
64
+ Rules:
65
+ - Use 2-6 words
66
+ - Use Title Case
67
+ - Be specific about the user's task or topic
68
+ - Do not mention Pi unless Pi itself is the task
69
+ - Return only the title
70
+
71
+ First user message:
72
+ {{message}}`;
73
+
74
+ const HANDOFF_SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
75
+
76
+ 1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
77
+ 2. Lists any relevant files that were discussed or modified
78
+ 3. Clearly states the next task based on the user's goal
79
+ 4. Is self-contained - the new thread should be able to proceed without the old conversation
80
+
81
+ Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
82
+
83
+ Example output format:
84
+ ## Context
85
+ We've been working on X. Key decisions:
86
+ - Decision 1
87
+ - Decision 2
88
+
89
+ Files involved:
90
+ - path/to/file1.ts
91
+ - path/to/file2.ts
92
+
93
+ ## Task
94
+ [Clear description of what to do next based on user's goal]`;
95
+
96
+ type VstackConfig = Record<string, unknown>;
97
+
98
+ interface GitState {
99
+ projectName: string;
100
+ branch?: string;
101
+ dirty: boolean;
102
+ inLinkedWorktree: boolean;
103
+ }
104
+
105
+ function expandHome(input: string): string {
106
+ if (input === "~") return homedir();
107
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
108
+ return input;
109
+ }
110
+
111
+ function projectSettingsPath(cwd: string): string {
112
+ let current = resolve(cwd);
113
+ while (true) {
114
+ const candidate = join(current, ".pi", "settings.json");
115
+ if (existsSync(candidate)) return candidate;
116
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".vstack-lock.json"))) return candidate;
117
+ const parent = dirname(current);
118
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
119
+ current = parent;
120
+ }
121
+ }
122
+
123
+ function piSettingsPaths(cwd = process.cwd()): string[] {
124
+ const userDir = resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
125
+ return [join(userDir, "settings.json"), projectSettingsPath(cwd)];
126
+ }
127
+
128
+ function readVstackConfig(cwd?: string): VstackConfig {
129
+ const merged: VstackConfig = {};
130
+ for (const path of piSettingsPaths(cwd)) {
131
+ if (!existsSync(path)) continue;
132
+ try {
133
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
134
+ const config = parsed?.vstack?.extensionManager?.config?.["pi-qol"];
135
+ if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
136
+ } catch {
137
+ // Ignore malformed optional manager config.
138
+ }
139
+ }
140
+ return merged;
141
+ }
142
+
143
+ function settingBoolean(key: string, fallback: boolean, cwd?: string): boolean {
144
+ const value = readVstackConfig(cwd)[key];
145
+ return typeof value === "boolean" ? value : fallback;
146
+ }
147
+
148
+ function settingString(key: string, fallback: string, cwd?: string): string {
149
+ const value = readVstackConfig(cwd)[key];
150
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
151
+ }
152
+
153
+ function settingStringAllowEmpty(key: string, fallback: string, cwd?: string): string {
154
+ const value = readVstackConfig(cwd)[key];
155
+ return typeof value === "string" ? value.trim() : fallback;
156
+ }
157
+
158
+ function newlineFallbackKey(cwd?: string): "ctrl+j" | "none" {
159
+ const configured = settingString("newlineFallbackKey", "ctrl+j", cwd).toLowerCase();
160
+ return configured === "none" ? "none" : "ctrl+j";
161
+ }
162
+
163
+ function settingNumber(key: string, fallback: number, cwd?: string): number {
164
+ const value = readVstackConfig(cwd)[key];
165
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
166
+ return Number.isFinite(parsed) ? parsed : fallback;
167
+ }
168
+
169
+ const ANSI_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
170
+
171
+ function stripAnsi(text: string): string {
172
+ return text.replace(ANSI_PATTERN, "");
173
+ }
174
+
175
+ function acquireVstackModalLock(): () => void {
176
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
177
+ const existing = host[VSTACK_MODAL_LOCK_SYMBOL] as VstackModalLock | undefined;
178
+ const lock = existing && typeof existing.depth === "number" ? existing : { depth: 0 };
179
+ host[VSTACK_MODAL_LOCK_SYMBOL] = lock;
180
+ lock.depth += 1;
181
+ let released = false;
182
+ return () => {
183
+ if (released) return;
184
+ released = true;
185
+ lock.depth = Math.max(0, lock.depth - 1);
186
+ };
187
+ }
188
+
189
+ function styleAutocompleteHintItem(item: AutocompleteItem, theme: Theme): AutocompleteItem {
190
+ const label = stripAnsi(item.label || item.value);
191
+ const styled: AutocompleteItem = { ...item, label: theme.fg("accent", label) };
192
+ if (typeof item.description === "string" && item.description.length > 0) {
193
+ styled.description = theme.fg("text", stripAnsi(item.description));
194
+ }
195
+ return styled;
196
+ }
197
+
198
+ function styleSlashAutocompleteHints(suggestions: AutocompleteSuggestions | null, theme: Theme): AutocompleteSuggestions | null {
199
+ if (!suggestions || !suggestions.prefix.startsWith("/")) return suggestions;
200
+ return { ...suggestions, items: suggestions.items.map((item) => styleAutocompleteHintItem(item, theme)) };
201
+ }
202
+
203
+ function installAutocompleteHintStyling(ctx: ExtensionContext): void {
204
+ if (!ctx.hasUI) return;
205
+ ctx.ui.addAutocompleteProvider((current) => ({
206
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
207
+ return styleSlashAutocompleteHints(await current.getSuggestions(lines, cursorLine, cursorCol, options), ctx.ui.theme);
208
+ },
209
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
210
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
211
+ },
212
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
213
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
214
+ },
215
+ }));
216
+ }
217
+
218
+ const QOL_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
219
+ { value: "status", label: "status", description: "Show QOL status and current settings" },
220
+ { value: "rename", label: "rename", description: "Generate a session name from the first user message" },
221
+ { value: "notify-test", label: "notify-test", description: "Send a test QOL notification" },
222
+ ];
223
+
224
+ const QOL_RENAME_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
225
+ { value: "rename full", label: "rename full", description: "Generate a session name from the full conversation" },
226
+ ];
227
+
228
+ function getQolArgumentCompletions(prefix: string): AutocompleteItem[] | null {
229
+ const query = prefix.trimStart().toLowerCase();
230
+ const items = query.startsWith("rename ") ? QOL_RENAME_ARGUMENT_COMPLETIONS : QOL_ARGUMENT_COMPLETIONS;
231
+ const filtered = items.filter((item) => item.value.toLowerCase().startsWith(query));
232
+ return filtered.length > 0 ? filtered : null;
233
+ }
234
+
235
+ const SESSION_SEARCH_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
236
+ { value: "refresh", label: "refresh", description: "Refresh the session search index" },
237
+ ];
238
+
239
+ function getSessionSearchArgumentCompletions(prefix: string): AutocompleteItem[] | null {
240
+ const query = prefix.trimStart().toLowerCase();
241
+ const filtered = SESSION_SEARCH_ARGUMENT_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(query) || (item.label ?? item.value).toLowerCase().startsWith(query));
242
+ return filtered.length > 0 ? filtered : null;
243
+ }
244
+
245
+ type QolNotificationKind = "ready" | "direction" | "question" | "task-complete" | "critical" | "test";
246
+ type QolNotificationLevel = "info" | "warning" | "error";
247
+
248
+ interface QuestionRequestLike {
249
+ header?: string;
250
+ question?: string;
251
+ }
252
+
253
+ interface QuestionOpenedEventLike {
254
+ requestId?: string;
255
+ request?: QuestionRequestLike;
256
+ source?: string;
257
+ }
258
+
259
+ interface QuestionServiceLike {
260
+ listPending(): unknown[];
261
+ subscribe(listener: (event: any) => void): () => void;
262
+ }
263
+
264
+ interface QolNotificationService {
265
+ notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
266
+ }
267
+
268
+ interface VstackModalLock {
269
+ depth: number;
270
+ }
271
+
272
+ interface ThinkingTimerStore {
273
+ cwd?: string;
274
+ enabled: boolean;
275
+ starts: Map<string, number>;
276
+ durations: Map<string, number>;
277
+ labels: Map<string, Text>;
278
+ theme?: ExtensionContext["ui"]["theme"];
279
+ }
280
+
281
+ interface PermissionGateMatcher {
282
+ label: string;
283
+ pattern: RegExp;
284
+ }
285
+
286
+ function splitPermissionGateCommands(raw: string): string[] {
287
+ return raw
288
+ .split(",")
289
+ .map((entry) => entry.trim())
290
+ .filter(Boolean);
291
+ }
292
+
293
+ function escapeRegex(input: string): string {
294
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
295
+ }
296
+
297
+ function regexFromSlashPattern(entry: string): RegExp | undefined {
298
+ if (!entry.startsWith("/")) return undefined;
299
+ const end = entry.lastIndexOf("/");
300
+ if (end <= 0) return undefined;
301
+ const source = entry.slice(1, end);
302
+ const flags = entry.slice(end + 1);
303
+ try {
304
+ return new RegExp(source, flags || "i");
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ function literalCommandPattern(entry: string): RegExp | undefined {
311
+ const source = entry.split(/\s+/).map(escapeRegex).join("\\s+");
312
+ if (!source) return undefined;
313
+ try {
314
+ return new RegExp(source, "i");
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ function permissionGateCommands(cwd?: string): string[] {
321
+ return splitPermissionGateCommands(settingStringAllowEmpty("permissionGate.commands", DEFAULT_PERMISSION_GATE_COMMANDS, cwd));
322
+ }
323
+
324
+ function permissionGateMatchers(cwd?: string): PermissionGateMatcher[] {
325
+ return permissionGateCommands(cwd)
326
+ .map((entry) => ({ label: entry, pattern: regexFromSlashPattern(entry) ?? literalCommandPattern(entry) }))
327
+ .filter((matcher): matcher is PermissionGateMatcher => matcher.pattern instanceof RegExp);
328
+ }
329
+
330
+ function permissionGateMatch(command: string, cwd?: string): string | undefined {
331
+ for (const matcher of permissionGateMatchers(cwd)) {
332
+ matcher.pattern.lastIndex = 0;
333
+ if (matcher.pattern.test(command)) return matcher.label;
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ function boundedSettingNumber(key: string, fallback: number, min: number, max: number, cwd?: string): number {
339
+ return Math.max(min, Math.min(max, Math.floor(settingNumber(key, fallback, cwd))));
340
+ }
341
+
342
+ function formatCount(count: number, label: string): string {
343
+ return `${count.toLocaleString()} ${label}${count === 1 ? "" : "s"}`;
344
+ }
345
+
346
+ function sanitizePermissionGatePreview(command: string): string {
347
+ return stripAnsi(command)
348
+ .replace(/\r\n/g, "\n")
349
+ .replace(/\r/g, "\n")
350
+ .replace(/\t/g, " ")
351
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "�");
352
+ }
353
+
354
+ function permissionGateCommandPreview(command: string, cwd?: string): { text: string; totalChars: number; totalLines: number; truncated: boolean } {
355
+ const maxLines = boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, cwd);
356
+ const maxChars = boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, cwd);
357
+ const maxLineWidth = boundedSettingNumber("permissionGate.previewLineWidth", DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH, 40, 240, cwd);
358
+ const safeCommand = sanitizePermissionGatePreview(command);
359
+ const commandLines = safeCommand.split("\n");
360
+ let selectedLines = commandLines;
361
+ let omittedLines = 0;
362
+
363
+ if (commandLines.length > maxLines) {
364
+ const headCount = Math.max(1, Math.ceil((maxLines - 1) * 0.65));
365
+ const tailCount = Math.max(1, maxLines - headCount - 1);
366
+ omittedLines = Math.max(0, commandLines.length - headCount - tailCount);
367
+ selectedLines = [
368
+ ...commandLines.slice(0, headCount),
369
+ `… ${formatCount(omittedLines, "line")} omitted …`,
370
+ ...commandLines.slice(-tailCount),
371
+ ];
372
+ }
373
+
374
+ let widthTruncated = false;
375
+ const previewLines = selectedLines.map((line) => {
376
+ if (/^… \d[\d,]* lines? omitted …$/.test(line)) return line;
377
+ if (visibleWidth(line) > maxLineWidth) widthTruncated = true;
378
+ return truncateToWidth(line, maxLineWidth, "…");
379
+ });
380
+
381
+ let text = previewLines.join("\n").trimEnd();
382
+ let charTruncated = false;
383
+ if (text.length > maxChars) {
384
+ const marker = `\n… preview clipped to ${formatCount(maxChars, "char")} …\n`;
385
+ const budget = Math.max(0, maxChars - marker.length);
386
+ const headChars = Math.ceil(budget * 0.6);
387
+ const tailChars = Math.max(0, budget - headChars);
388
+ const tail = tailChars > 0 ? text.slice(-tailChars).trimStart() : "";
389
+ text = `${text.slice(0, headChars).trimEnd()}${marker}${tail}`;
390
+ charTruncated = true;
391
+ }
392
+
393
+ return {
394
+ text: text || "(empty command)",
395
+ totalChars: command.length,
396
+ totalLines: commandLines.length,
397
+ truncated: omittedLines > 0 || widthTruncated || charTruncated,
398
+ };
399
+ }
400
+
401
+ function permissionGatePrompt(matched: string, command: string, cwd?: string): string {
402
+ const preview = permissionGateCommandPreview(command, cwd);
403
+ const matchedLabel = truncateToWidth(sanitizePermissionGatePreview(matched).replace(/\n+/g, " ").trim() || "configured pattern", DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH, "…");
404
+ const commandStats = `${formatCount(preview.totalLines, "line")}, ${formatCount(preview.totalChars, "char")}`;
405
+ return [
406
+ `Permission gate matched: ${matchedLabel}`,
407
+ "",
408
+ `Bash command (${commandStats}${preview.truncated ? "; compact preview" : ""}):`,
409
+ "```sh",
410
+ preview.text,
411
+ "```",
412
+ ...(preview.truncated ? ["Full command is unchanged; only this approval preview was shortened."] : []),
413
+ "",
414
+ "Allow this bash command?",
415
+ ].join("\n");
416
+ }
417
+
418
+ const THINKING_LABEL_DEFAULT = " ";
419
+
420
+ const lastNotificationAt = new Map<string, number>();
421
+ const lastQuestionNotificationAt = new Map<string, number>();
422
+ let tmuxMarkedTarget: string | undefined;
423
+ let tmuxOriginalWindowName: string | undefined;
424
+ let tmuxWindowMarkTimer: ReturnType<typeof setTimeout> | undefined;
425
+
426
+ function getThinkingTimerStore(): ThinkingTimerStore | undefined {
427
+ return (globalThis as unknown as Record<PropertyKey, unknown>)[THINKING_TIMER_STORE_SYMBOL] as ThinkingTimerStore | undefined;
428
+ }
429
+
430
+ function formatThinkingElapsed(ms: number): string {
431
+ const totalSeconds = ms / 1000;
432
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
433
+ const minutes = Math.floor(totalSeconds / 60);
434
+ const seconds = totalSeconds - minutes * 60;
435
+ return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
436
+ }
437
+
438
+ function thinkingLabel(cwd?: string): string {
439
+ const configured = settingString("thinkingLabel.text", THINKING_LABEL_DEFAULT, cwd);
440
+ return configured.trim() ? configured : THINKING_LABEL_DEFAULT;
441
+ }
442
+
443
+ function thinkingTimerLabel(theme: ThinkingTimerStore["theme"], ms: number, cwd?: string): string {
444
+ const base = thinkingLabel(cwd);
445
+ const separator = /\s$/.test(base) ? "" : " ";
446
+ const elapsed = `${separator}${formatThinkingElapsed(ms)}`;
447
+ if (!theme) return `${base}${elapsed}`;
448
+ return theme.italic(theme.fg("muted", base) + theme.fg("dim", elapsed));
449
+ }
450
+
451
+ function hiddenThinkingLabel(theme: ThinkingTimerStore["theme"], cwd?: string): string {
452
+ const base = thinkingLabel(cwd);
453
+ return theme ? theme.fg("muted", base) : base;
454
+ }
455
+
456
+ function thinkingTimerKey(timestamp: number, contentIndex: number): string {
457
+ return `${timestamp}:${contentIndex}`;
458
+ }
459
+
460
+ function installThinkingTimerPatch(): void {
461
+ const proto = AssistantMessageComponent.prototype as unknown as Record<PropertyKey, any>;
462
+ if (proto[THINKING_TIMER_PATCH_SYMBOL]) return;
463
+ const originalUpdateContent = proto.updateContent;
464
+ if (typeof originalUpdateContent !== "function") return;
465
+ proto[THINKING_TIMER_PATCH_SYMBOL] = true;
466
+ proto.updateContent = function patchedUpdateContent(this: any, message: any): void {
467
+ originalUpdateContent.call(this, message);
468
+ try {
469
+ const store = getThinkingTimerStore();
470
+ if (!store?.enabled) return;
471
+ if (!message || !Array.isArray(message.content) || typeof message.timestamp !== "number") return;
472
+ if (!this.hideThinkingBlock) return;
473
+ if (!this.contentContainer || !Array.isArray(this.contentContainer.children)) return;
474
+
475
+ const thinkingIndices: number[] = [];
476
+ for (let i = 0; i < message.content.length; i++) {
477
+ const content = message.content[i];
478
+ if (content?.type === "thinking" && typeof content.thinking === "string" && content.thinking.trim()) thinkingIndices.push(i);
479
+ }
480
+ if (thinkingIndices.length === 0) return;
481
+
482
+ const labelComponents: Text[] = [];
483
+ for (const child of this.contentContainer.children as any[]) {
484
+ if (!child || typeof child !== "object") continue;
485
+ if (typeof child.setText !== "function") continue;
486
+ if (typeof child.text !== "string") continue;
487
+ const expectedLabel = thinkingLabel(store.cwd);
488
+ if (!child.text.includes(expectedLabel) && !child.text.includes("Thinking...")) continue;
489
+ labelComponents.push(child as Text);
490
+ }
491
+ if (labelComponents.length === 0) return;
492
+
493
+ const count = Math.min(thinkingIndices.length, labelComponents.length);
494
+ for (let i = 0; i < count; i++) {
495
+ const contentIndex = thinkingIndices[i]!;
496
+ const label = labelComponents[i]!;
497
+ const key = thinkingTimerKey(message.timestamp, contentIndex);
498
+ store.labels.set(key, label);
499
+ const duration = store.durations.get(key);
500
+ const start = store.starts.get(key);
501
+ const ms = duration ?? (start === undefined ? undefined : Date.now() - start);
502
+ if (ms !== undefined) label.setText(thinkingTimerLabel(store.theme, ms, store.cwd));
503
+ }
504
+ } catch {
505
+ // Rendering must never break because of this optional monkey-patch.
506
+ }
507
+ };
508
+ }
509
+
510
+ function sanitizeNotificationPart(input: string, maxChars = DEFAULT_NOTIFICATION_BODY_MAX_CHARS): string {
511
+ const cleaned = input
512
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
513
+ .replace(/\s+/g, " ")
514
+ .trim();
515
+ return cleaned.length > maxChars ? `${cleaned.slice(0, Math.max(0, maxChars - 1))}…` : cleaned;
516
+ }
517
+
518
+ function windowsToastScript(title: string, body: string): string {
519
+ const escapedTitle = title.replace(/'/g, "''");
520
+ const escapedBody = body.replace(/'/g, "''");
521
+ const type = "Windows.UI.Notifications";
522
+ const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
523
+ const template = `[${type}.ToastTemplateType]::ToastText02`;
524
+ const toast = `[${type}.ToastNotification]::new($xml)`;
525
+ return [
526
+ `${mgr} > $null`,
527
+ `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
528
+ `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${escapedTitle}')) > $null`,
529
+ `$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode('${escapedBody}')) > $null`,
530
+ `[${type}.ToastNotificationManager]::CreateToastNotifier('${escapedTitle}').Show(${toast})`,
531
+ ].join("; ");
532
+ }
533
+
534
+ function tmuxPassthrough(sequence: string): string {
535
+ return `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
536
+ }
537
+
538
+ function sourcePaneTty(): string | undefined {
539
+ try {
540
+ if (!process.env.TMUX_PANE) return undefined;
541
+ const tty = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{pane_tty}"], { encoding: "utf8" }).trim();
542
+ return tty || undefined;
543
+ } catch {
544
+ return undefined;
545
+ }
546
+ }
547
+
548
+ function sourceTmuxSession(): string | undefined {
549
+ try {
550
+ if (!process.env.TMUX_PANE) return undefined;
551
+ const session = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}"], { encoding: "utf8" }).trim();
552
+ return session || undefined;
553
+ } catch {
554
+ return undefined;
555
+ }
556
+ }
557
+
558
+ function sourceTmuxWindowActive(): boolean {
559
+ try {
560
+ if (!process.env.TMUX_PANE) return false;
561
+ const active = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{window_active}"], { encoding: "utf8" }).trim();
562
+ return active === "1";
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+
568
+ function tmuxClientTtys(): string[] {
569
+ try {
570
+ if (!process.env.TMUX) return [];
571
+ const session = sourceTmuxSession();
572
+ const args = ["list-clients", "-F", "#{client_tty}"];
573
+ if (session) args.splice(1, 0, "-t", session);
574
+ const output = execFileSync("tmux", args, { encoding: "utf8" });
575
+ return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
576
+ } catch {
577
+ return [];
578
+ }
579
+ }
580
+
581
+ function writeRawToPaths(paths: string[], output: string): boolean {
582
+ let wrote = false;
583
+ for (const path of paths) {
584
+ try {
585
+ writeFileSync(path, output, "utf8");
586
+ wrote = true;
587
+ } catch {
588
+ // Try remaining paths.
589
+ }
590
+ }
591
+ return wrote;
592
+ }
593
+
594
+ function writeToTerminal(output: string): void {
595
+ const tty = sourcePaneTty();
596
+ try {
597
+ writeFileSync(tty ?? "/dev/tty", output, "utf8");
598
+ return;
599
+ } catch {
600
+ // Fall through to stdout best-effort.
601
+ }
602
+ try {
603
+ if (process.stdout.isTTY) process.stdout.write(output);
604
+ } catch {
605
+ // Notification best-effort only.
606
+ }
607
+ }
608
+
609
+ function writeTerminalSequence(sequence: string, cwd?: string): void {
610
+ if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
611
+ // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
612
+ // Send native terminal notifications straight to attached tmux client TTYs,
613
+ // while BEL still goes through the source pane so tmux marks the right tab.
614
+ if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
615
+ }
616
+ const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
617
+ writeToTerminal(output);
618
+ }
619
+
620
+ function writeTerminalBell(): void {
621
+ // Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
622
+ // This lets tmux set window_bell_flag for the correct source window.
623
+ writeToTerminal("\x07");
624
+ }
625
+
626
+ function notifyOSC777(title: string, body: string, cwd?: string): void {
627
+ writeTerminalSequence(`\x1b]777;notify;${title};${body}\x07`, cwd);
628
+ }
629
+
630
+ function notifyOSC99(title: string, body: string, cwd?: string): void {
631
+ writeTerminalSequence(`\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
632
+ writeTerminalSequence(`\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
633
+ }
634
+
635
+ function notifyWindows(title: string, body: string): void {
636
+ execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
637
+ }
638
+
639
+ function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
640
+ const protocol = settingString("notification.oscProtocol", "auto", cwd);
641
+ if (process.env.WT_SESSION) {
642
+ notifyWindows(title, body);
643
+ return;
644
+ }
645
+ if (protocol === "off") return;
646
+ if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
647
+ notifyOSC99(title, body, cwd);
648
+ return;
649
+ }
650
+ notifyOSC777(title, body, cwd);
651
+ }
652
+
653
+ function notifyTmux(title: string, body: string, cwd?: string): void {
654
+ if (!process.env.TMUX && !process.env.TMUX_PANE) return;
655
+ const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
656
+ const message = `${title}: ${body}`;
657
+ const args = ["display-message", "-d", String(duration)];
658
+ if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
659
+ args.push(message);
660
+ execFile("tmux", args, () => undefined);
661
+ }
662
+
663
+ function clearTmuxWindowMark(): void {
664
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
665
+ tmuxWindowMarkTimer = undefined;
666
+ const target = tmuxMarkedTarget;
667
+ const original = tmuxOriginalWindowName;
668
+ tmuxMarkedTarget = undefined;
669
+ tmuxOriginalWindowName = undefined;
670
+ if (!target || !original) return;
671
+ execFile("tmux", ["rename-window", "-t", target, original], () => undefined);
672
+ }
673
+
674
+ function markTmuxWindow(cwd?: string): void {
675
+ if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
676
+ if (!process.env.TMUX || !process.env.TMUX_PANE) return;
677
+ const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
678
+ const prefix = `${mark} `;
679
+ execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}:#{window_index}"], (targetError, targetStdout) => {
680
+ if (targetError) return;
681
+ const target = targetStdout.trim();
682
+ if (!target) return;
683
+ execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE!, "#W"], (nameError, nameStdout) => {
684
+ if (nameError) return;
685
+ const current = nameStdout.replace(/\r?\n$/, "");
686
+ if (!current) return;
687
+ const original = current.startsWith(prefix) ? current.slice(prefix.length) : current;
688
+ if (!tmuxMarkedTarget || tmuxMarkedTarget !== target) {
689
+ tmuxMarkedTarget = target;
690
+ tmuxOriginalWindowName = original;
691
+ }
692
+ if (!current.startsWith(prefix)) execFile("tmux", ["rename-window", "-t", target, `${prefix}${current}`], () => undefined);
693
+ const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
694
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
695
+ if (duration > 0) {
696
+ tmuxWindowMarkTimer = setTimeout(clearTmuxWindowMark, duration);
697
+ tmuxWindowMarkTimer.unref?.();
698
+ }
699
+ });
700
+ });
701
+ }
702
+
703
+ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
704
+ if (!settingBoolean("notification.enabled", true, cwd)) return false;
705
+ switch (kind) {
706
+ case "ready": return settingBoolean("notification.onAgentReady", true, cwd);
707
+ case "direction": return settingBoolean("notification.onDirectionNeeded", true, cwd);
708
+ case "question": return settingBoolean("notification.onQuestion", true, cwd);
709
+ case "task-complete": return settingBoolean("notification.onTaskComplete", true, cwd);
710
+ case "critical": return settingBoolean("notification.onCritical", true, cwd);
711
+ case "test": return true;
712
+ }
713
+ }
714
+
715
+ function sendQolNotification(ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): void {
716
+ const cwd = ctx?.cwd;
717
+ if (ctx && !ctx.hasUI) return;
718
+ if (!notificationEnabledFor(kind, cwd)) return;
719
+ const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
720
+ const now = Date.now();
721
+ const last = lastNotificationAt.get(key) ?? 0;
722
+ if (cooldownMs > 0 && now - last < cooldownMs) return;
723
+ lastNotificationAt.set(key, now);
724
+
725
+ const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
726
+ const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
727
+ const tmuxWindowActive = sourceTmuxWindowActive();
728
+ if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell();
729
+ if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
730
+ if (!tmuxWindowActive) markTmuxWindow(cwd);
731
+ if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
732
+ if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
733
+ }
734
+
735
+ function questionNotificationTitle(request?: QuestionRequestLike): string {
736
+ if (typeof request?.header === "string" && request.header.trim()) return request.header.trim();
737
+ if (typeof request?.question === "string" && request.question.trim()) return request.question.trim();
738
+ return "Question";
739
+ }
740
+
741
+ function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
742
+ const title = questionNotificationTitle(event.request);
743
+ const key = `${keyPrefix}:${event.requestId ?? title}`;
744
+ const now = Date.now();
745
+ const last = lastQuestionNotificationAt.get(key) ?? 0;
746
+ if (now - last < QUESTION_NOTIFY_DEDUP_MS) return;
747
+ lastQuestionNotificationAt.set(key, now);
748
+ for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
749
+ if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
750
+ }
751
+ sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
752
+ }
753
+
754
+ function stripAtPrefix(path: string): string {
755
+ return path.startsWith("@") ? path.slice(1) : path;
756
+ }
757
+
758
+ function resolveMaybeImagePath(path: string, cwd: string): string | undefined {
759
+ const clean = stripAtPrefix(path);
760
+ const expanded = expandHome(clean);
761
+ const resolved = expanded.startsWith("/") ? expanded : resolve(cwd, expanded);
762
+ const lower = resolved.toLowerCase();
763
+ const dot = lower.lastIndexOf(".");
764
+ if (dot < 0 || !IMAGE_EXTENSIONS.has(lower.slice(dot))) return undefined;
765
+ if (!existsSync(resolved)) return undefined;
766
+ return resolved;
767
+ }
768
+
769
+ function imagePathLabels(text: string, cwd: string): string[] {
770
+ const seen = new Set<string>();
771
+ for (const match of text.matchAll(IMAGE_PATH_PATTERN)) {
772
+ const resolved = resolveMaybeImagePath(match[2] ?? "", cwd);
773
+ if (resolved) seen.add(`Image ${basename(resolved)}`);
774
+ }
775
+ return [...seen].sort();
776
+ }
777
+
778
+ function mimeTypeForPath(path: string): string {
779
+ const lower = path.toLowerCase();
780
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
781
+ if (lower.endsWith(".gif")) return "image/gif";
782
+ if (lower.endsWith(".webp")) return "image/webp";
783
+ if (lower.endsWith(".bmp")) return "image/bmp";
784
+ if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return "image/tiff";
785
+ if (lower.endsWith(".heic")) return "image/heic";
786
+ if (lower.endsWith(".heif")) return "image/heif";
787
+ return "image/png";
788
+ }
789
+
790
+ function imageContentForPath(path: string): { type: "image"; data: string; mimeType: string } | undefined {
791
+ try {
792
+ return { data: readFileSync(path).toString("base64"), mimeType: mimeTypeForPath(path), type: "image" };
793
+ } catch {
794
+ return undefined;
795
+ }
796
+ }
797
+
798
+ function attachmentLabels(text: string, cwd = process.cwd()): string[] {
799
+ const seen = new Set<string>();
800
+ for (const match of text.matchAll(/\[Image\s+#(\d+)\]/gi)) {
801
+ seen.add(`Image #${match[1]}`);
802
+ }
803
+ for (const label of imagePathLabels(text, cwd)) seen.add(label);
804
+ return [...seen].sort((a, b) => Number(a.replace(/\D/g, "")) - Number(b.replace(/\D/g, "")) || a.localeCompare(b));
805
+ }
806
+
807
+ function chip(label: string, theme?: Theme): string {
808
+ const text = ` ${label} `;
809
+ if (theme) return theme.fg("accent", theme.inverse(text));
810
+ return `[${label}]`;
811
+ }
812
+
813
+ interface VisibleMap {
814
+ text: string;
815
+ rawIndexByVisibleIndex: number[];
816
+ }
817
+
818
+ interface VisibleReplacement {
819
+ start: number;
820
+ end: number;
821
+ text: string;
822
+ }
823
+
824
+ function ansiSequenceEnd(input: string, start: number): number {
825
+ const introducer = input[start + 1];
826
+ if (introducer == null) return start + 1;
827
+
828
+ // OSC, DCS, APC, PM, SOS strings are zero-width and end in ST (ESC \\).
829
+ // OSC may also end in BEL.
830
+ if (introducer === "]" || introducer === "P" || introducer === "_" || introducer === "^" || introducer === "X") {
831
+ const st = input.indexOf("\x1b\\", start + 2);
832
+ const bell = introducer === "]" ? input.indexOf("\x07", start + 2) : -1;
833
+ if (bell >= 0 && (st < 0 || bell < st)) return bell + 1;
834
+ return st >= 0 ? st + 2 : input.length;
835
+ }
836
+
837
+ if (introducer === "[") {
838
+ let index = start + 2;
839
+ while (index < input.length) {
840
+ const code = input.charCodeAt(index);
841
+ if (code >= 0x40 && code <= 0x7e) return index + 1;
842
+ index += 1;
843
+ }
844
+ return input.length;
845
+ }
846
+
847
+ return Math.min(start + 2, input.length);
848
+ }
849
+
850
+ function buildVisibleMap(input: string): VisibleMap {
851
+ const rawIndexByVisibleIndex: number[] = [0];
852
+ let text = "";
853
+ let index = 0;
854
+
855
+ while (index < input.length) {
856
+ if (input.charCodeAt(index) === 0x1b) {
857
+ index = ansiSequenceEnd(input, index);
858
+ continue;
859
+ }
860
+
861
+ if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
862
+ text += input[index] ?? "";
863
+ index += 1;
864
+ rawIndexByVisibleIndex[text.length] = index;
865
+ }
866
+
867
+ if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
868
+ return { rawIndexByVisibleIndex, text };
869
+ }
870
+
871
+ function applyVisibleReplacements(input: string, map: VisibleMap, replacements: VisibleReplacement[]): string {
872
+ if (replacements.length === 0) return input;
873
+
874
+ const sorted = replacements
875
+ .filter((replacement) => replacement.end > replacement.start)
876
+ .sort((a, b) => a.start - b.start || b.end - a.end);
877
+
878
+ let output = "";
879
+ let lastRawIndex = 0;
880
+ let lastVisibleIndex = 0;
881
+
882
+ for (const replacement of sorted) {
883
+ if (replacement.start < lastVisibleIndex) continue;
884
+ const rawStart = map.rawIndexByVisibleIndex[replacement.start];
885
+ const rawEnd = map.rawIndexByVisibleIndex[replacement.end];
886
+ if (rawStart == null || rawEnd == null || rawStart < lastRawIndex) continue;
887
+
888
+ output += input.slice(lastRawIndex, rawStart) + replacement.text;
889
+ lastRawIndex = rawEnd;
890
+ lastVisibleIndex = replacement.end;
891
+ }
892
+
893
+ return output + input.slice(lastRawIndex);
894
+ }
895
+
896
+ function imageChipReplacements(visibleText: string, cwd: string, theme?: Theme): VisibleReplacement[] {
897
+ const replacements: VisibleReplacement[] = [];
898
+
899
+ for (const match of visibleText.matchAll(/\[Image\s+#(\d+)\]/gi)) {
900
+ const start = match.index ?? 0;
901
+ replacements.push({
902
+ start,
903
+ end: start + match[0].length,
904
+ text: chip(`Image #${match[1]}`, theme),
905
+ });
906
+ }
907
+
908
+ let imageIndex = 0;
909
+ for (const match of visibleText.matchAll(IMAGE_PATH_PATTERN)) {
910
+ const prefix = match[1] ?? "";
911
+ const rawPath = match[2] ?? "";
912
+ const resolved = resolveMaybeImagePath(rawPath, cwd);
913
+ if (!resolved) continue;
914
+
915
+ imageIndex += 1;
916
+ const start = (match.index ?? 0) + prefix.length;
917
+ replacements.push({
918
+ start,
919
+ end: start + rawPath.length,
920
+ text: chip(`Image ${imageIndex}`, theme),
921
+ });
922
+ }
923
+
924
+ return replacements;
925
+ }
926
+
927
+ function styleImageChips(line: string, cwd: string, theme?: Theme): string {
928
+ if (!settingBoolean("showImageChips", true, cwd)) return line;
929
+ const map = buildVisibleMap(line);
930
+ const replacements = imageChipReplacements(map.text, cwd, theme);
931
+ return replacements.length === 0 ? line : applyVisibleReplacements(line, map, replacements);
932
+ }
933
+
934
+ function statusText(ctx: ExtensionContext, text: string): string | undefined {
935
+ if (!settingBoolean("showAttachmentCountInStatus", true, ctx.cwd)) return undefined;
936
+ const count = attachmentLabels(text, ctx.cwd).length;
937
+ return count > 0 ? `images:${count}` : undefined;
938
+ }
939
+
940
+ function handleQolEditorInput(editor: CustomEditor, ctx: ExtensionContext, data: string): void {
941
+ const fallback = newlineFallbackKey(ctx.cwd);
942
+ const newlineEnabled = settingBoolean("newlineOnShiftEnter", true, ctx.cwd);
943
+ const isShiftEnter = matchesKey(data, "shift+enter") || matchesKey(data, "shift+return");
944
+ const isFallback = fallback !== "none" && matchesKey(data, fallback);
945
+ CustomEditor.prototype.handleInput.call(editor, newlineEnabled && (isShiftEnter || isFallback) ? "\n" : data);
946
+ }
947
+
948
+ /// Push the latest attachment-count status only when it actually changes.
949
+ /// Called from render() so we follow the rainbow-editor pattern (pull from
950
+ /// render, don't push from every keystroke) and avoid the per-keystroke
951
+ /// setStatus side effect that previously contributed to resize/input races.
952
+ function syncQolEditorStatus(
953
+ ctx: ExtensionContext,
954
+ text: string,
955
+ cache: { last?: string },
956
+ ): void {
957
+ const next = statusText(ctx, text);
958
+ if (next === cache.last) return;
959
+ cache.last = next;
960
+ try {
961
+ ctx.ui.setStatus(STATUS_KEY, next);
962
+ } catch {
963
+ // best-effort visual; don't let a status push break render
964
+ }
965
+ }
966
+
967
+ class QolEditor extends CustomEditor {
968
+ private readonly statusCache: { last?: string } = {};
969
+
970
+ constructor(
971
+ tui: TUI,
972
+ editorTheme: EditorTheme,
973
+ keybindings: KeybindingsManager,
974
+ private readonly ctx: ExtensionContext,
975
+ ) {
976
+ super(tui, editorTheme, keybindings);
977
+ }
978
+
979
+ handleInput(data: string): void {
980
+ handleQolEditorInput(this, this.ctx, data);
981
+ }
982
+
983
+ render(width: number): string[] {
984
+ syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
985
+ return super.render(width).map((line) => truncateToWidth(styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme), width, ""));
986
+ }
987
+ }
988
+
989
+ function isEditorBorderLine(line: string): boolean {
990
+ const visible = stripAnsi(line).trim();
991
+ return visible.length > 0 && /^[─━╭╮╰╯┌┐└┘]+$/.test(visible);
992
+ }
993
+
994
+ class QolCompactPromptEditor extends CustomEditor {
995
+ private readonly statusCache: { last?: string } = {};
996
+
997
+ constructor(
998
+ tui: TUI,
999
+ editorTheme: EditorTheme,
1000
+ keybindings: KeybindingsManager,
1001
+ private readonly inputBottomPaddingLines: number,
1002
+ private readonly ctx: ExtensionContext,
1003
+ ) {
1004
+ super(tui, editorTheme, keybindings, { paddingX: 0 });
1005
+ }
1006
+
1007
+ handleInput(data: string): void {
1008
+ handleQolEditorInput(this, this.ctx, data);
1009
+ }
1010
+
1011
+ render(width: number): string[] {
1012
+ syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
1013
+ const prompt = this.borderColor("π");
1014
+ const prefix = `${prompt} `;
1015
+ const prefixWidth = visibleWidth("π ");
1016
+ const continuationPrefix = " ".repeat(prefixWidth);
1017
+ const innerWidth = Math.max(1, width - prefixWidth);
1018
+ const rendered = super.render(innerWidth);
1019
+
1020
+ const inputLines: string[] = [];
1021
+ let completionLines: string[] = [];
1022
+ for (let index = 1; index < rendered.length; index++) {
1023
+ const line = rendered[index] ?? "";
1024
+ if (isEditorBorderLine(line)) {
1025
+ completionLines = rendered.slice(index + 1);
1026
+ break;
1027
+ }
1028
+ inputLines.push(line);
1029
+ }
1030
+
1031
+ const lines = (inputLines.length > 0 ? inputLines : [""]).map((line, index) => {
1032
+ const linePrefix = index === 0 ? prefix : continuationPrefix;
1033
+ const content = styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme);
1034
+ return truncateToWidth(linePrefix + content, width, "");
1035
+ });
1036
+ for (let index = 0; index < this.inputBottomPaddingLines; index++) lines.push("");
1037
+ for (const line of completionLines) lines.push(truncateToWidth(`${this.ctx.ui.theme.fg("dim", continuationPrefix)}${line}`, width, ""));
1038
+ return lines;
1039
+ }
1040
+ }
1041
+
1042
+ function currentEditorText(ctx: ExtensionContext): string {
1043
+ try {
1044
+ return ctx.ui.getEditorText?.() ?? "";
1045
+ } catch {
1046
+ return "";
1047
+ }
1048
+ }
1049
+
1050
+ /// Resolve every image-path-shaped substring in submitted text to an
1051
+ /// absolute file path. Used by the `input` event to replace the previous
1052
+ /// approach of mutating the editor buffer to `[Image #N]` aliases via a
1053
+ /// 250ms polling timer (which raced with resize/input handling).
1054
+ function resolveSubmittedImagePaths(text: string, cwd: string): string[] {
1055
+ const paths = new Set<string>();
1056
+ for (const match of text.matchAll(IMAGE_PATH_PATTERN)) {
1057
+ const resolved = resolveMaybeImagePath(match[2] ?? "", cwd);
1058
+ if (resolved) paths.add(resolved);
1059
+ }
1060
+ return [...paths];
1061
+ }
1062
+
1063
+ function repoNameFromRemote(remote: string): string | undefined {
1064
+ const trimmed = remote.trim().replace(/\.git$/, "");
1065
+ const match = trimmed.match(/([^/:]+)$/);
1066
+ return match?.[1];
1067
+ }
1068
+
1069
+ function formatModel(ctx: ExtensionContext, pi: ExtensionAPI): string {
1070
+ const model = ctx.model;
1071
+ if (!model) return `no model / ${pi.getThinkingLevel()}`;
1072
+ let name = model.name || model.id;
1073
+ name = name.replace(/^Claude\s+/i, "");
1074
+ name = name.replace(/^claude[-_]/i, "");
1075
+ name = name.replace(/[-_](20\d{6}|latest)$/i, "");
1076
+ name = name.replace(/^gpt[-_]/i, "GPT ");
1077
+ name = name.replace(/[-_]/g, " ");
1078
+ name = name.replace(/\bopus\b/i, "Opus");
1079
+ name = name.replace(/\bsonnet\b/i, "Sonnet");
1080
+ name = name.replace(/\bhaiku\b/i, "Haiku");
1081
+ name = name.replace(/\s+/g, " ").trim();
1082
+ name = name.replace(/\b(Opus|Sonnet|Haiku) (\d) (\d)\b/, "$1 $2.$3");
1083
+ return `${name} / ${pi.getThinkingLevel()}`;
1084
+ }
1085
+
1086
+ function formatWindow(tokens: number | undefined): string {
1087
+ if (!tokens || tokens <= 0) return "?";
1088
+ if (tokens >= 1_000_000) {
1089
+ const value = tokens / 1_000_000;
1090
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
1091
+ }
1092
+ if (tokens >= 1_000) {
1093
+ const value = tokens / 1_000;
1094
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}k`;
1095
+ }
1096
+ return `${tokens}`;
1097
+ }
1098
+
1099
+ function statuslineContextInfo(ctx: ExtensionContext): { label: string; percent: number | null } {
1100
+ const usage = ctx.getContextUsage();
1101
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
1102
+ if (typeof usage?.percent !== "number") return { label: formatWindow(contextWindow), percent: null };
1103
+ const usedPercent = Math.max(0, Math.min(100, Math.round(usage.percent)));
1104
+ return { label: formatWindow(contextWindow), percent: 100 - usedPercent };
1105
+ }
1106
+
1107
+ function gitBadge(state: GitState, showDirtyMarker: boolean): string {
1108
+ if (!state.branch) return "";
1109
+ const icon = state.inLinkedWorktree || state.branch !== "main" ? `🌳 ${state.branch}` : "🦀";
1110
+ return ` (${icon}${state.dirty && showDirtyMarker ? "*" : ""})`;
1111
+ }
1112
+
1113
+ function makeFallbackGitState(cwd: string): GitState {
1114
+ return { projectName: basename(cwd), dirty: false, inLinkedWorktree: false };
1115
+ }
1116
+
1117
+ async function runGit(pi: ExtensionAPI, cwd: string, args: string[]): Promise<string | undefined> {
1118
+ try {
1119
+ const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: settingNumber("gitRefreshTimeoutMs", 1500, cwd) });
1120
+ if (result.code !== 0) return undefined;
1121
+ const stdout = result.stdout.trim();
1122
+ return stdout.length > 0 ? stdout : undefined;
1123
+ } catch {
1124
+ return undefined;
1125
+ }
1126
+ }
1127
+
1128
+ async function refreshGitState(pi: ExtensionAPI, ctx: ExtensionContext): Promise<GitState> {
1129
+ const cwd = ctx.cwd;
1130
+ const topLevel = await runGit(pi, cwd, ["rev-parse", "--show-toplevel"]);
1131
+ if (!topLevel) return makeFallbackGitState(cwd);
1132
+ const [remote, worktreesRaw, branchRaw, shortHead, diffExit] = await Promise.all([
1133
+ runGit(pi, cwd, ["remote", "get-url", "origin"]),
1134
+ runGit(pi, cwd, ["worktree", "list", "--porcelain"]),
1135
+ runGit(pi, cwd, ["branch", "--show-current"]),
1136
+ runGit(pi, cwd, ["rev-parse", "--short", "HEAD"]),
1137
+ pi.exec("git", ["-C", cwd, "diff-index", "--quiet", "HEAD", "--"], { timeout: settingNumber("gitRefreshTimeoutMs", 1500, cwd) })
1138
+ .then((result) => result.code)
1139
+ .catch(() => 0),
1140
+ ]);
1141
+ const firstWorktreeLine = worktreesRaw?.split("\n").find((line) => line.startsWith("worktree "));
1142
+ const mainWorktree = firstWorktreeLine?.slice("worktree ".length).trim();
1143
+ const inLinkedWorktree = Boolean(mainWorktree && mainWorktree !== topLevel);
1144
+ const projectName = repoNameFromRemote(remote ?? "") ?? basename(mainWorktree || topLevel);
1145
+ const branch = branchRaw || shortHead;
1146
+ return { projectName, branch, dirty: diffExit === 1, inLinkedWorktree };
1147
+ }
1148
+
1149
+ function normalizedSessionName(pi: ExtensionAPI): string | undefined {
1150
+ const name = pi.getSessionName();
1151
+ if (!name) return undefined;
1152
+ const normalized = stripAnsi(name).replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
1153
+ return normalized || undefined;
1154
+ }
1155
+
1156
+ function sessionNameHeader(width: number, pi: ExtensionAPI, theme: Pick<Theme, "fg" | "bg">): string[] {
1157
+ const name = normalizedSessionName(pi);
1158
+ if (!name || width < 4) return [];
1159
+ const prefixPlain = "Session ";
1160
+ const prefix = theme.fg("muted", prefixPlain);
1161
+ const innerWidth = Math.max(1, width - visibleWidth(prefixPlain) - 2);
1162
+ const inner = truncateToWidth(name, innerWidth, "…");
1163
+ const plain = ` ${inner} `;
1164
+ const badge = theme.bg("selectedBg", theme.fg("text", plain));
1165
+ return [truncateToWidth(`${prefix}${badge}`, width, "")];
1166
+ }
1167
+
1168
+ function formatTmuxSessionTitle(sessionName: string): string {
1169
+ return `π ${sessionName}`;
1170
+ }
1171
+
1172
+ function tmuxPaneTarget(): string | undefined {
1173
+ return process.env.TMUX && process.env.TMUX_PANE ? process.env.TMUX_PANE : undefined;
1174
+ }
1175
+
1176
+ function readTmuxPaneTitle(target: string, callback: (title: string | undefined) => void): void {
1177
+ execFile("tmux", ["display-message", "-p", "-t", target, "#{pane_title}"], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
1178
+ }
1179
+
1180
+ function readTmuxWindowOption(target: string, option: string, callback: (value: string | undefined) => void): void {
1181
+ execFile("tmux", ["show-options", "-wqv", "-t", target, option], { timeout: 1000 }, (error, stdout) => callback(error ? undefined : stdout.replace(/\r?\n$/, "")));
1182
+ }
1183
+
1184
+ function setTmuxPaneTitle(target: string, title: string): void {
1185
+ execFile("tmux", ["select-pane", "-t", target, "-T", title], { timeout: 1000 }, () => undefined);
1186
+ }
1187
+
1188
+ function setTmuxWindowOption(target: string, option: string, value: string): void {
1189
+ execFile("tmux", ["set-option", "-wq", "-t", target, option, value], { timeout: 1000 }, () => undefined);
1190
+ }
1191
+
1192
+ function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">): string {
1193
+ const { label: contextLabel, percent } = statuslineContextInfo(ctx);
1194
+ const leftPlain = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModel(ctx, pi)} (${contextLabel})`;
1195
+ const rightPlain = percent === null ? "…%" : `${percent}%`;
1196
+ const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
1197
+ const left = theme.fg("accent", leftPlain);
1198
+ const right = theme.fg(percentColor, rightPlain);
1199
+ const minimumGap = 1;
1200
+ const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
1201
+ const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
1202
+ const empty = Math.max(0, gapWidth - filled);
1203
+ const bar = " ".repeat(empty) + theme.fg("warning", "─".repeat(filled));
1204
+ return truncateToWidth(`${left} ${bar} ${right}`, width, "");
1205
+ }
1206
+
1207
+ type QolSummaryProfile = "concise" | "balanced" | "exhaustive";
1208
+ type QolSummaryPurpose = "compaction" | "branch-summary" | "session-search";
1209
+
1210
+ const QOL_COMPACTION_SYSTEM_PROMPT = "You summarize coding-agent sessions for continuation. Preserve exact technical facts, filenames, commands, constraints, decisions, blockers, and next actions. Do not invent details.";
1211
+
1212
+ function compactionNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
1213
+ if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(message, level);
1214
+ }
1215
+
1216
+ function compactionProfile(cwd: string): QolSummaryProfile {
1217
+ const value = settingString("compaction.profile", "balanced", cwd);
1218
+ return value === "concise" || value === "exhaustive" ? value : "balanced";
1219
+ }
1220
+
1221
+ function compactionProfileInstructions(profile: QolSummaryProfile): string {
1222
+ if (profile === "concise") return "Prefer a compact continuation summary. Include only decisions, current state, modified/read files, blockers, and concrete next steps.";
1223
+ if (profile === "exhaustive") return "Be thorough. The summary may replace substantial conversation history, so preserve all relevant implementation details, alternatives considered, exact file paths, commands, errors, and pending work.";
1224
+ return "Be complete but not verbose. Preserve enough detail for a future assistant to continue without the old transcript.";
1225
+ }
1226
+
1227
+ function buildSummaryPrompt(options: {
1228
+ conversationText: string;
1229
+ customInstructions?: string;
1230
+ previousSummary?: string;
1231
+ profile: QolSummaryProfile;
1232
+ purpose: QolSummaryPurpose;
1233
+ }): string {
1234
+ const purposeText = options.purpose === "branch-summary"
1235
+ ? "the branch being left during /tree navigation"
1236
+ : options.purpose === "session-search"
1237
+ ? "the previous session being imported into the current context"
1238
+ : "the conversation span being compacted";
1239
+ const previous = options.previousSummary ? `<previous-summary>\n${options.previousSummary}\n</previous-summary>\n\n` : "";
1240
+ const custom = options.customInstructions?.trim() ? `<custom-instructions>\n${options.customInstructions.trim()}\n</custom-instructions>\n\n` : "";
1241
+ return `${custom}${previous}<conversation>\n${options.conversationText}\n</conversation>\n\nSummarize ${purposeText} for a coding agent that must continue the work.\n\n${compactionProfileInstructions(options.profile)}\n\nUse this markdown shape:\n\n## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements, style, safety, or user preferences]\n\n## Progress\n### Done\n- [x] [Completed work]\n\n### In Progress\n- [ ] [Current partial work]\n\n### Blocked\n- [Blockers or none]\n\n## Key Decisions\n- **[Decision]**: [Rationale]\n\n## Files & Commands\n- [Files read/modified and important commands/results]\n\n## Next Steps\n1. [Most important next action]\n\n## Critical Context\n- [Anything easy to lose but needed later]`;
1242
+ }
1243
+
1244
+ async function summarizeWithRemote(endpoint: string, systemPrompt: string, promptText: string, maxTokens: number, signal?: AbortSignal): Promise<string> {
1245
+ const response = await fetch(endpoint, {
1246
+ body: JSON.stringify({ maxTokens, prompt: promptText, systemPrompt }),
1247
+ headers: { "content-type": "application/json" },
1248
+ method: "POST",
1249
+ signal,
1250
+ });
1251
+ const text = await response.text();
1252
+ if (!response.ok) throw new Error(`Remote compaction endpoint returned ${response.status}: ${text.slice(0, 500)}`);
1253
+ let parsed: unknown;
1254
+ try {
1255
+ parsed = JSON.parse(text);
1256
+ } catch {
1257
+ throw new Error("Remote compaction endpoint did not return JSON");
1258
+ }
1259
+ if (parsed && typeof parsed === "object") {
1260
+ const record = parsed as Record<string, unknown>;
1261
+ if (typeof record.summary === "string") return record.summary;
1262
+ if (typeof record.text === "string") return record.text;
1263
+ }
1264
+ throw new Error("Remote compaction response missing summary");
1265
+ }
1266
+
1267
+ function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
1268
+ if (!configured || configured === "current") return ctx.model;
1269
+ const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
1270
+ const slash = withoutThinking.indexOf("/");
1271
+ if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
1272
+ const providers = [ctx.model?.provider, "google", "openai", "anthropic", "mistral", "moonshot", "cloudflare-ai-gateway", "cloudflare-workers-ai"].filter((value): value is string => typeof value === "string");
1273
+ for (const provider of providers) {
1274
+ const model = ctx.modelRegistry.find(provider, withoutThinking);
1275
+ if (model) return model;
1276
+ }
1277
+ return undefined;
1278
+ }
1279
+
1280
+ function resolveCompactionModel(ctx: ExtensionContext): any | undefined {
1281
+ return resolveConfiguredModel(ctx, settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd));
1282
+ }
1283
+
1284
+ function modelLabel(model: any): string {
1285
+ return model ? `${model.provider}/${model.id}` : "unknown model";
1286
+ }
1287
+
1288
+ async function generateQolSummary(ctx: ExtensionContext, options: {
1289
+ conversationText: string;
1290
+ customInstructions?: string;
1291
+ previousSummary?: string;
1292
+ maxTokens?: number;
1293
+ model?: string;
1294
+ purpose: QolSummaryPurpose;
1295
+ signal?: AbortSignal;
1296
+ }): Promise<{ model: string; summary: string; via: "model" | "remote" }> {
1297
+ const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
1298
+ const promptText = buildSummaryPrompt({
1299
+ conversationText: options.conversationText,
1300
+ customInstructions: options.customInstructions,
1301
+ previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? options.previousSummary : undefined,
1302
+ profile: compactionProfile(ctx.cwd),
1303
+ purpose: options.purpose,
1304
+ });
1305
+
1306
+ const remoteEndpoint = settingString("compaction.remoteEndpoint", "", ctx.cwd);
1307
+ if (settingBoolean("compaction.remoteEnabled", false, ctx.cwd) && remoteEndpoint) {
1308
+ try {
1309
+ const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, maxTokens, options.signal);
1310
+ return { model: remoteEndpoint, summary, via: "remote" };
1311
+ } catch (error) {
1312
+ compactionNotify(ctx, `Remote compaction failed, trying model fallback: ${stringifyError(error)}`, "warning");
1313
+ }
1314
+ }
1315
+
1316
+ const configuredModel = options.model ?? settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd);
1317
+ const model = resolveConfiguredModel(ctx, configuredModel);
1318
+ if (!model) throw new Error(`Summary model not found: ${configuredModel}`);
1319
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1320
+ if (!auth.ok) throw new Error(auth.error);
1321
+ if (!auth.apiKey) throw new Error(`No API key for ${model.provider}`);
1322
+
1323
+ const message: Message = {
1324
+ content: [{ text: promptText, type: "text" }],
1325
+ role: "user",
1326
+ timestamp: Date.now(),
1327
+ };
1328
+ const response = await complete(
1329
+ model,
1330
+ { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
1331
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal: options.signal },
1332
+ );
1333
+ const summary = response.content
1334
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
1335
+ .map((content) => content.text)
1336
+ .join("\n")
1337
+ .trim();
1338
+ return { model: modelLabel(model), summary, via: "model" };
1339
+ }
1340
+
1341
+ function stringifyError(error: unknown): string {
1342
+ return error instanceof Error ? error.message : String(error);
1343
+ }
1344
+
1345
+ async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<any> {
1346
+ if (!settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
1347
+ const preparation = event.preparation ?? {};
1348
+ const messages = [...(preparation.messagesToSummarize ?? []), ...(preparation.turnPrefixMessages ?? [])];
1349
+ if (messages.length === 0) return undefined;
1350
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1351
+ compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
1352
+ try {
1353
+ const conversationText = serializeConversation(convertToLlm(messages));
1354
+ const result = await generateQolSummary(ctx, {
1355
+ conversationText,
1356
+ customInstructions: event.customInstructions,
1357
+ previousSummary: preparation.previousSummary,
1358
+ purpose: "compaction",
1359
+ signal: event.signal,
1360
+ });
1361
+ if (!result.summary.trim()) throw new Error("Compaction summary was empty");
1362
+ compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}`, "info");
1363
+ return {
1364
+ compaction: {
1365
+ details: {
1366
+ messageCount: messages.length,
1367
+ model: result.model,
1368
+ profile: compactionProfile(ctx.cwd),
1369
+ source: "pi-qol",
1370
+ via: result.via,
1371
+ },
1372
+ firstKeptEntryId: preparation.firstKeptEntryId,
1373
+ summary: result.summary,
1374
+ tokensBefore: preparation.tokensBefore,
1375
+ },
1376
+ };
1377
+ } catch (error) {
1378
+ if (event.signal?.aborted) return undefined;
1379
+ compactionNotify(ctx, `QOL compaction failed: ${stringifyError(error)}`, "error");
1380
+ return settingBoolean("compaction.fallbackToDefault", true, ctx.cwd) ? undefined : { cancel: true };
1381
+ }
1382
+ }
1383
+
1384
+ function summarizeEntryForBranch(entry: any): string[] {
1385
+ if (entry?.type === "message" && entry.message) return [serializeConversation(convertToLlm([entry.message]))];
1386
+ if (entry?.type === "compaction" && typeof entry.summary === "string") return [`[Compaction summary]: ${entry.summary}`];
1387
+ if (entry?.type === "branch_summary" && typeof entry.summary === "string") return [`[Branch summary]: ${entry.summary}`];
1388
+ if (entry?.type === "custom_message") return [`[Custom message${entry.customType ? `:${entry.customType}` : ""}]: ${typeof entry.content === "string" ? entry.content : JSON.stringify(entry.data ?? {})}`];
1389
+ return [];
1390
+ }
1391
+
1392
+ async function handleQolBranchSummary(event: any, ctx: ExtensionContext): Promise<any> {
1393
+ if (!settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd)) return undefined;
1394
+ const preparation = event.preparation ?? {};
1395
+ if (preparation.userWantsSummary !== true) return undefined;
1396
+ const entries = Array.isArray(preparation.entriesToSummarize) ? preparation.entriesToSummarize : [];
1397
+ const conversationText = entries.flatMap(summarizeEntryForBranch).join("\n\n").trim();
1398
+ if (!conversationText) return undefined;
1399
+ compactionNotify(ctx, `QOL branch summary: summarizing ${entries.length} entr${entries.length === 1 ? "y" : "ies"}.`, "info");
1400
+ try {
1401
+ const result = await generateQolSummary(ctx, {
1402
+ conversationText,
1403
+ customInstructions: event.customInstructions ?? preparation.customInstructions,
1404
+ purpose: "branch-summary",
1405
+ signal: event.signal,
1406
+ });
1407
+ if (!result.summary.trim()) throw new Error("Branch summary was empty");
1408
+ return {
1409
+ summary: {
1410
+ details: { entryCount: entries.length, model: result.model, profile: compactionProfile(ctx.cwd), source: "pi-qol", via: result.via },
1411
+ summary: result.summary,
1412
+ },
1413
+ };
1414
+ } catch (error) {
1415
+ if (event.signal?.aborted) return undefined;
1416
+ compactionNotify(ctx, `QOL branch summary failed: ${stringifyError(error)}`, "error");
1417
+ return undefined;
1418
+ }
1419
+ }
1420
+
1421
+ function contextUsage(ctx: ExtensionContext): { contextWindow?: number; tokens: number } | undefined {
1422
+ const usage = ctx.getContextUsage?.() as { tokens?: unknown; contextWindow?: unknown } | undefined;
1423
+ const tokens = Number(usage?.tokens);
1424
+ if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
1425
+ const contextWindow = Number(usage?.contextWindow ?? ctx.model?.contextWindow);
1426
+ return { contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined, tokens };
1427
+ }
1428
+
1429
+ function compactionTriggerReason(ctx: ExtensionContext): string | undefined {
1430
+ const usage = contextUsage(ctx);
1431
+ if (!usage) return undefined;
1432
+ const tokenLimit = settingNumber("compaction.thresholdTokens", -1, ctx.cwd);
1433
+ if (tokenLimit > 0 && usage.tokens >= tokenLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(tokenLimit).toLocaleString()} token limit`;
1434
+ const percentLimit = settingNumber("compaction.thresholdPercent", -1, ctx.cwd);
1435
+ if (percentLimit > 0 && usage.contextWindow) {
1436
+ const percent = (usage.tokens / usage.contextWindow) * 100;
1437
+ if (percent >= percentLimit) return `${percent.toFixed(1)}% context >= ${percentLimit}% limit`;
1438
+ }
1439
+ const idleLimit = settingNumber("compaction.idleThresholdTokens", DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS, ctx.cwd);
1440
+ if (usage.tokens >= idleLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(idleLimit).toLocaleString()} idle threshold`;
1441
+ return undefined;
1442
+ }
1443
+
1444
+ type QolContextColor = "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted" | "text";
1445
+
1446
+ interface QolContextDetailItem {
1447
+ label: string;
1448
+ tokens: number;
1449
+ description?: string;
1450
+ group?: string;
1451
+ }
1452
+
1453
+ interface QolContextCategory {
1454
+ key: string;
1455
+ label: string;
1456
+ rawTokens: number;
1457
+ tokens: number;
1458
+ color: QolContextColor;
1459
+ icon: string;
1460
+ }
1461
+
1462
+ interface QolContextUsageMessageDetails {
1463
+ usage: {
1464
+ contextWindow?: number;
1465
+ percent?: number;
1466
+ tokens: number;
1467
+ };
1468
+ model: {
1469
+ contextWindow?: number;
1470
+ id: string;
1471
+ label: string;
1472
+ provider: string;
1473
+ thinking?: string;
1474
+ };
1475
+ categories: QolContextCategory[];
1476
+ freeTokens?: number;
1477
+ contextFiles: QolContextDetailItem[];
1478
+ skills: QolContextDetailItem[];
1479
+ customAgents: QolContextDetailItem[];
1480
+ builtinTools: QolContextDetailItem[];
1481
+ extensionTools: QolContextDetailItem[];
1482
+ mcpTools: QolContextDetailItem[];
1483
+ messageStats: {
1484
+ assistant: number;
1485
+ bash: number;
1486
+ branchEntries: number;
1487
+ compact: number;
1488
+ contextMessages: number;
1489
+ custom: number;
1490
+ toolResult: number;
1491
+ user: number;
1492
+ };
1493
+ compactSummaries: QolContextDetailItem[];
1494
+ note?: string;
1495
+ }
1496
+
1497
+ function qcuSafeString(value: unknown): string {
1498
+ if (typeof value === "string") return value;
1499
+ if (value == null) return "";
1500
+ const seen = new WeakSet<object>();
1501
+ try {
1502
+ return JSON.stringify(value, (_key, current) => {
1503
+ if (typeof current === "function") return undefined;
1504
+ if (typeof current === "object" && current !== null) {
1505
+ if (seen.has(current)) return "[Circular]";
1506
+ seen.add(current);
1507
+ }
1508
+ return current;
1509
+ }) ?? "";
1510
+ } catch {
1511
+ return String(value);
1512
+ }
1513
+ }
1514
+
1515
+ function qcuEstimateTokens(value: unknown): number {
1516
+ const text = qcuSafeString(value);
1517
+ if (!text) return 0;
1518
+ return Math.max(1, Math.ceil(text.length / 4));
1519
+ }
1520
+
1521
+ function qcuFormatTokens(value: number | undefined): string {
1522
+ const n = Math.max(0, Math.round(Number(value) || 0));
1523
+ const trim = (input: number) => input.toFixed(input >= 10 ? 0 : 1).replace(/\.0$/, "");
1524
+ if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`;
1525
+ if (n >= 1_000) return `${trim(n / 1_000)}K`;
1526
+ return n.toLocaleString();
1527
+ }
1528
+
1529
+ function qcuPercent(tokens: number, contextWindow?: number): string {
1530
+ if (!contextWindow || contextWindow <= 0) return "--";
1531
+ return `${((tokens / contextWindow) * 100).toFixed(1)}%`;
1532
+ }
1533
+
1534
+ function qcuPadAnsi(text: string, width: number): string {
1535
+ const clipped = truncateToWidth(text, Math.max(0, width), "");
1536
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
1537
+ }
1538
+
1539
+ function qcuOneLine(text: string, maxWidth: number): string {
1540
+ return truncateToWidth(text.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim(), maxWidth, "…");
1541
+ }
1542
+
1543
+ function qcuDetailLabel(item: unknown, fallback: string): string {
1544
+ if (typeof item === "string") return qcuOneLine(item, 90) || fallback;
1545
+ if (!item || typeof item !== "object") return fallback;
1546
+ const obj = item as Record<string, unknown>;
1547
+ const raw = obj.name ?? obj.title ?? obj.label ?? obj.id ?? obj.path ?? obj.file ?? obj.filePath ?? obj.sourcePath ?? obj.description ?? fallback;
1548
+ const label = typeof raw === "string" ? raw : fallback;
1549
+ return qcuOneLine(label, 90) || fallback;
1550
+ }
1551
+
1552
+ function qcuDetailText(item: unknown): string {
1553
+ if (typeof item === "string") return item;
1554
+ if (!item || typeof item !== "object") return qcuSafeString(item);
1555
+ const obj = item as Record<string, unknown>;
1556
+ const direct = obj.content ?? obj.text ?? obj.markdown ?? obj.body ?? obj.instructions ?? obj.description ?? obj.prompt;
1557
+ if (typeof direct === "string") return direct;
1558
+ const pathValue = obj.path ?? obj.file ?? obj.filePath ?? obj.sourcePath;
1559
+ if (typeof pathValue === "string" && isAbsolute(pathValue) && existsSync(pathValue)) {
1560
+ try {
1561
+ return readFileSync(pathValue, "utf8");
1562
+ } catch {
1563
+ // Fall through to a safe structural estimate.
1564
+ }
1565
+ }
1566
+ return qcuSafeString(item);
1567
+ }
1568
+
1569
+ function qcuDetailsFromItems(items: unknown[], fallbackGroup?: string): QolContextDetailItem[] {
1570
+ return items
1571
+ .map((item, index) => {
1572
+ const obj = item && typeof item === "object" ? item as Record<string, unknown> : undefined;
1573
+ const sourceInfo = obj?.sourceInfo && typeof obj.sourceInfo === "object" ? obj.sourceInfo as Record<string, unknown> : undefined;
1574
+ const group = typeof obj?.scope === "string" ? obj.scope : typeof sourceInfo?.scope === "string" ? sourceInfo.scope : typeof sourceInfo?.source === "string" ? sourceInfo.source : fallbackGroup;
1575
+ const description = typeof obj?.description === "string" ? obj.description : undefined;
1576
+ return {
1577
+ description: description ? qcuOneLine(description, 120) : undefined,
1578
+ group,
1579
+ label: qcuDetailLabel(item, `item-${index + 1}`),
1580
+ tokens: qcuEstimateTokens(qcuDetailText(item)),
1581
+ };
1582
+ })
1583
+ .filter((item) => item.tokens > 0 || item.label.length > 0);
1584
+ }
1585
+
1586
+ function qcuPromptOptionArray(options: unknown, keys: string[]): unknown[] {
1587
+ if (!options || typeof options !== "object") return [];
1588
+ const obj = options as Record<string, unknown>;
1589
+ for (const key of keys) {
1590
+ const value = obj[key];
1591
+ if (Array.isArray(value)) return value;
1592
+ }
1593
+ return [];
1594
+ }
1595
+
1596
+ function qcuUniqDetails(items: QolContextDetailItem[]): QolContextDetailItem[] {
1597
+ const byLabel = new Map<string, QolContextDetailItem>();
1598
+ for (const item of items) {
1599
+ const key = `${item.group ?? ""}:${item.label}`;
1600
+ const existing = byLabel.get(key);
1601
+ if (!existing || item.tokens > existing.tokens) byLabel.set(key, item);
1602
+ }
1603
+ return Array.from(byLabel.values()).sort((a, b) => b.tokens - a.tokens || a.label.localeCompare(b.label));
1604
+ }
1605
+
1606
+ function qcuTokenSum(items: QolContextDetailItem[]): number {
1607
+ return items.reduce((sum, item) => sum + Math.max(0, item.tokens), 0);
1608
+ }
1609
+
1610
+ function qcuMessageTokens(message: any): number {
1611
+ if (!message || typeof message !== "object") return 0;
1612
+ if (message.role === "bashExecution" && message.excludeFromContext === true) return 0;
1613
+ if (message.role === "compactionSummary") return qcuEstimateTokens(message.summary ?? "");
1614
+ if (message.role === "branchSummary") return qcuEstimateTokens(message.summary ?? "");
1615
+ if (message.role === "custom") return qcuEstimateTokens(message.content ?? "");
1616
+ const content = message.content;
1617
+ if (typeof content === "string") return qcuEstimateTokens(content);
1618
+ if (!Array.isArray(content)) return qcuEstimateTokens(content);
1619
+ return content.reduce((sum: number, part: any) => {
1620
+ if (part?.type === "text") return sum + qcuEstimateTokens(part.text ?? "");
1621
+ if (part?.type === "thinking") return sum + qcuEstimateTokens(part.thinking ?? "");
1622
+ if (part?.type === "toolCall") return sum + qcuEstimateTokens(part);
1623
+ if (part?.type === "image") return sum + 85;
1624
+ return sum + qcuEstimateTokens(part);
1625
+ }, 0);
1626
+ }
1627
+
1628
+ function qcuBuildMessageStats(ctx: ExtensionContext): { rawMessageTokens: number; rawCompactTokens: number; stats: QolContextUsageMessageDetails["messageStats"]; compactSummaries: QolContextDetailItem[] } {
1629
+ const sm = ctx.sessionManager as any;
1630
+ const branch = Array.isArray(sm.getBranch?.()) ? sm.getBranch() : [];
1631
+ const built = typeof sm.buildSessionContext === "function" ? sm.buildSessionContext() : undefined;
1632
+ const messages = Array.isArray(built?.messages) ? built.messages : branch.filter((entry: any) => entry?.type === "message").map((entry: any) => entry.message);
1633
+ const stats = { assistant: 0, bash: 0, branchEntries: branch.length, compact: 0, contextMessages: messages.length, custom: 0, toolResult: 0, user: 0 };
1634
+ let rawMessageTokens = 0;
1635
+ let rawCompactTokens = 0;
1636
+ for (const message of messages) {
1637
+ const tokens = qcuMessageTokens(message);
1638
+ switch (message?.role) {
1639
+ case "user": stats.user += 1; rawMessageTokens += tokens; break;
1640
+ case "assistant": stats.assistant += 1; rawMessageTokens += tokens; break;
1641
+ case "toolResult": stats.toolResult += 1; rawMessageTokens += tokens; break;
1642
+ case "bashExecution": stats.bash += 1; rawMessageTokens += tokens; break;
1643
+ case "custom": stats.custom += 1; rawMessageTokens += tokens; break;
1644
+ case "compactionSummary":
1645
+ case "branchSummary":
1646
+ stats.compact += 1;
1647
+ rawCompactTokens += tokens;
1648
+ break;
1649
+ default:
1650
+ rawMessageTokens += tokens;
1651
+ }
1652
+ }
1653
+ const compactSummaries = branch
1654
+ .filter((entry: any) => entry?.type === "compaction" || entry?.type === "branch_summary")
1655
+ .map((entry: any) => ({
1656
+ group: entry.type === "compaction" ? "compaction" : "branch",
1657
+ label: entry.type === "compaction" ? `Compaction${entry.tokensBefore ? ` (${qcuFormatTokens(Number(entry.tokensBefore))} before)` : ""}` : "Branch summary",
1658
+ tokens: qcuEstimateTokens(entry.summary ?? ""),
1659
+ }))
1660
+ .reverse();
1661
+ return { compactSummaries, rawCompactTokens, rawMessageTokens, stats };
1662
+ }
1663
+
1664
+ function qcuToolDetails(pi: ExtensionAPI): {
1665
+ builtinTools: QolContextDetailItem[];
1666
+ extensionTools: QolContextDetailItem[];
1667
+ mcpTools: QolContextDetailItem[];
1668
+ rawBuiltinToolTokens: number;
1669
+ rawExtensionToolTokens: number;
1670
+ rawMcpToolTokens: number;
1671
+ } {
1672
+ const api = pi as any;
1673
+ const activeNames = new Set<string>(Array.isArray(api.getActiveTools?.()) ? api.getActiveTools() : []);
1674
+ const allTools = Array.isArray(api.getAllTools?.()) ? api.getAllTools() : [];
1675
+ const activeTools = allTools.filter((tool: any) => typeof tool?.name === "string" && (activeNames.size === 0 || activeNames.has(tool.name)));
1676
+ const details = activeTools.map((tool: any) => {
1677
+ const sourceInfo = tool.sourceInfo && typeof tool.sourceInfo === "object" ? tool.sourceInfo : undefined;
1678
+ const source = typeof sourceInfo?.source === "string" ? sourceInfo.source : undefined;
1679
+ return {
1680
+ description: typeof tool.description === "string" ? qcuOneLine(tool.description, 120) : undefined,
1681
+ group: source,
1682
+ label: tool.name,
1683
+ tokens: qcuEstimateTokens({ description: tool.description, name: tool.name, parameters: tool.parameters, promptGuidelines: tool.promptGuidelines, promptSnippet: tool.promptSnippet }),
1684
+ };
1685
+ });
1686
+ const isMcpTool = (tool: QolContextDetailItem) => /^mcp_{1,2}/i.test(tool.label) || /mcp/i.test(tool.group ?? "");
1687
+ const mcpTools = details.filter(isMcpTool);
1688
+ const builtinTools = details.filter((tool) => !isMcpTool(tool) && (tool.group === "builtin" || tool.group === "sdk"));
1689
+ const extensionTools = details.filter((tool) => !isMcpTool(tool) && tool.group !== "builtin" && tool.group !== "sdk");
1690
+ return {
1691
+ builtinTools: qcuUniqDetails(builtinTools),
1692
+ extensionTools: qcuUniqDetails(extensionTools),
1693
+ mcpTools: qcuUniqDetails(mcpTools),
1694
+ rawBuiltinToolTokens: qcuTokenSum(builtinTools),
1695
+ rawExtensionToolTokens: qcuTokenSum(extensionTools),
1696
+ rawMcpToolTokens: qcuTokenSum(mcpTools),
1697
+ };
1698
+ }
1699
+
1700
+ function qcuSkillDetails(pi: ExtensionAPI, promptOptions: unknown): QolContextDetailItem[] {
1701
+ const fromOptions = qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["skills", "loadedSkills", "availableSkills"]), "loaded");
1702
+ const commands = Array.isArray((pi as any).getCommands?.()) ? (pi as any).getCommands() : [];
1703
+ const fromCommands = commands
1704
+ .filter((command: any) => command?.source === "skill")
1705
+ .map((command: any) => {
1706
+ const sourceInfo = command.sourceInfo && typeof command.sourceInfo === "object" ? command.sourceInfo : undefined;
1707
+ return {
1708
+ description: typeof command.description === "string" ? qcuOneLine(command.description, 120) : undefined,
1709
+ group: typeof sourceInfo?.scope === "string" ? sourceInfo.scope : typeof sourceInfo?.source === "string" ? sourceInfo.source : "skill",
1710
+ label: String(command.name ?? "skill").replace(/^skill:/, ""),
1711
+ tokens: qcuEstimateTokens(`${command.name ?? ""}\n${command.description ?? ""}`),
1712
+ };
1713
+ });
1714
+ return qcuUniqDetails([...fromOptions, ...fromCommands]);
1715
+ }
1716
+
1717
+ function qcuScaleCategories(rawCategories: Array<Omit<QolContextCategory, "tokens">>, totalTokens: number, contextWindow?: number): QolContextCategory[] {
1718
+ const rawTotal = rawCategories.reduce((sum, category) => sum + Math.max(0, category.rawTokens), 0);
1719
+ if (rawTotal <= 0) {
1720
+ return [{ color: "accent", icon: "◉", key: "messages", label: "Messages", rawTokens: totalTokens, tokens: totalTokens }];
1721
+ }
1722
+ const ratio = totalTokens / rawTotal;
1723
+ const categories = rawCategories
1724
+ .map((category) => ({ ...category, tokens: Math.max(0, Math.round(category.rawTokens * ratio)) }))
1725
+ .filter((category) => category.tokens > 0 || category.rawTokens > 0);
1726
+ const scaledTotal = categories.reduce((sum, category) => sum + category.tokens, 0);
1727
+ const delta = totalTokens - scaledTotal;
1728
+ const messages = categories.find((category) => category.key === "messages") ?? categories[categories.length - 1];
1729
+ if (messages && delta !== 0) messages.tokens = Math.max(0, messages.tokens + delta);
1730
+ const used = categories.reduce((sum, category) => sum + category.tokens, 0);
1731
+ if (contextWindow && used < totalTokens) {
1732
+ const other = totalTokens - used;
1733
+ if (other > 0) categories.push({ color: "dim", icon: "◉", key: "other", label: "Other", rawTokens: other, tokens: other });
1734
+ }
1735
+ return categories;
1736
+ }
1737
+
1738
+ function qcuExtractProjectAgents(systemPrompt: string): { agents: QolContextDetailItem[]; tokens: number } {
1739
+ const marker = systemPrompt.includes("## Project Agents") ? "## Project Agents" : "## Project Subagents";
1740
+ const start = systemPrompt.indexOf(marker);
1741
+ if (start < 0) return { agents: [], tokens: 0 };
1742
+ const rest = systemPrompt.slice(start);
1743
+ const next = rest.slice(marker.length).search(/\n##\s+/);
1744
+ const section = next >= 0 ? rest.slice(0, marker.length + next) : rest;
1745
+ const agents = section
1746
+ .split(/\r?\n/)
1747
+ .filter((line) => line.startsWith("- "))
1748
+ .map((line) => {
1749
+ const match = line.match(/^-\s*([^:]+):\s*(.*?)(?:\s*\(([^)]*)\))?$/);
1750
+ return {
1751
+ description: match?.[2] ? qcuOneLine(match[2], 120) : undefined,
1752
+ group: match?.[3]?.includes("project") ? "project" : match?.[3]?.includes("user") ? "user" : "agent",
1753
+ label: qcuOneLine(match?.[1] ?? line.replace(/^[-\s]+/, ""), 90),
1754
+ tokens: qcuEstimateTokens(line),
1755
+ };
1756
+ });
1757
+ return { agents: qcuUniqDetails(agents), tokens: qcuEstimateTokens(section) };
1758
+ }
1759
+
1760
+ function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionCommandContext, promptOptions: unknown): QolContextUsageMessageDetails | undefined {
1761
+ const usage = ctx.getContextUsage?.() as { contextWindow?: unknown; percent?: unknown; tokens?: unknown } | undefined;
1762
+ const tokens = Number(usage?.tokens);
1763
+ if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
1764
+ const model = (ctx as any).model ?? {};
1765
+ const contextWindow = Number(usage?.contextWindow ?? model.contextWindow);
1766
+ const safeContextWindow = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined;
1767
+ const percent = Number(usage?.percent);
1768
+ const systemPrompt = ctx.getSystemPrompt?.() ?? "";
1769
+ const contextFiles = qcuUniqDetails(qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["contextFiles", "agentsFiles", "memoryFiles"]), "context"));
1770
+ const skills = qcuSkillDetails(pi, promptOptions);
1771
+ const extractedAgents = qcuExtractProjectAgents(systemPrompt);
1772
+ const customAgents = qcuUniqDetails([...qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["customAgents", "agents", "agentSnippets"]), "agent"), ...extractedAgents.agents]);
1773
+ const { builtinTools, extensionTools, mcpTools, rawBuiltinToolTokens, rawExtensionToolTokens, rawMcpToolTokens } = qcuToolDetails(pi);
1774
+ const { compactSummaries, rawCompactTokens, rawMessageTokens, stats } = qcuBuildMessageStats(ctx);
1775
+ const rawCompactSummaryTokens = rawCompactTokens > 0 ? rawCompactTokens : qcuTokenSum(compactSummaries);
1776
+ const rawContextFiles = qcuTokenSum(contextFiles);
1777
+ const rawSkills = qcuTokenSum(skills);
1778
+ const rawCustomAgents = Math.max(qcuTokenSum(customAgents), extractedAgents.tokens);
1779
+ const rawSystemPromptTotal = qcuEstimateTokens(systemPrompt);
1780
+ const rawSystemPrompt = Math.max(0, rawSystemPromptTotal - rawContextFiles - rawSkills - rawCustomAgents);
1781
+ const categories = qcuScaleCategories([
1782
+ { color: "muted", icon: "◉", key: "system", label: "System prompt", rawTokens: rawSystemPrompt },
1783
+ { color: "warning", icon: "◉", key: "builtinTools", label: "Built-in tools", rawTokens: rawBuiltinToolTokens },
1784
+ { color: "error", icon: "◉", key: "extensionTools", label: "Extension tools", rawTokens: rawExtensionToolTokens },
1785
+ { color: "warning", icon: "◉", key: "mcpTools", label: "MCP tools", rawTokens: rawMcpToolTokens },
1786
+ { color: "success", icon: "◉", key: "agents", label: "Custom agents", rawTokens: rawCustomAgents },
1787
+ { color: "error", icon: "◉", key: "contextFiles", label: "Context / memory files", rawTokens: rawContextFiles },
1788
+ { color: "success", icon: "◉", key: "skills", label: "Skills", rawTokens: rawSkills },
1789
+ { color: "accent", icon: "◉", key: "messages", label: "Messages", rawTokens: rawMessageTokens },
1790
+ { color: "muted", icon: "◉", key: "compact", label: "Compact buffer", rawTokens: rawCompactSummaryTokens },
1791
+ ], tokens, safeContextWindow);
1792
+ const modelProvider = typeof model.provider === "string" ? model.provider : "unknown";
1793
+ const modelId = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : "unknown";
1794
+ const modelName = typeof model.name === "string" ? model.name : modelId;
1795
+ const thinking = typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined;
1796
+ return {
1797
+ builtinTools,
1798
+ categories,
1799
+ compactSummaries,
1800
+ contextFiles,
1801
+ customAgents,
1802
+ extensionTools,
1803
+ freeTokens: safeContextWindow ? Math.max(0, safeContextWindow - tokens) : undefined,
1804
+ mcpTools,
1805
+ messageStats: stats,
1806
+ model: {
1807
+ contextWindow: safeContextWindow,
1808
+ id: modelId,
1809
+ label: modelName,
1810
+ provider: modelProvider,
1811
+ thinking: typeof thinking === "string" && thinking !== "off" ? thinking : undefined,
1812
+ },
1813
+ note: promptOptions ? undefined : "Context-file and skill breakdowns become more precise after the next agent turn.",
1814
+ skills,
1815
+ usage: {
1816
+ contextWindow: safeContextWindow,
1817
+ percent: Number.isFinite(percent) ? percent : safeContextWindow ? (tokens / safeContextWindow) * 100 : undefined,
1818
+ tokens,
1819
+ },
1820
+ };
1821
+ }
1822
+
1823
+ function qcuLimitDetails(items: QolContextDetailItem[], limit: number): QolContextDetailItem[] {
1824
+ return items.slice(0, Math.max(0, limit));
1825
+ }
1826
+
1827
+ function qcuBranch(theme: Theme, branch: "├" | "└" | "│"): string {
1828
+ if (branch === "│") return theme.fg("muted", "│ ");
1829
+ return theme.fg("muted", `${branch}─ `);
1830
+ }
1831
+
1832
+ function qcuStem(theme: Theme, isLast: boolean): string {
1833
+ return isLast ? theme.fg("borderMuted", " ") : qcuBranch(theme, "│");
1834
+ }
1835
+
1836
+ function qcuRenderDetailSection(lines: string[], title: string, items: QolContextDetailItem[], theme: Theme, options: { empty?: string; limit?: number; showDescriptions?: boolean } = {}): void {
1837
+ if (items.length === 0) {
1838
+ if (options.empty) {
1839
+ lines.push("");
1840
+ lines.push(`${theme.fg("customMessageLabel", theme.bold(title))}`);
1841
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("dim", options.empty)}`);
1842
+ }
1843
+ return;
1844
+ }
1845
+ const visible = qcuLimitDetails(items, options.limit ?? 10);
1846
+ lines.push("");
1847
+ lines.push(`${theme.fg("customMessageLabel", theme.bold(title))}`);
1848
+ visible.forEach((item, index) => {
1849
+ const isLast = index === visible.length - 1 && visible.length === items.length;
1850
+ const branch = isLast ? "└" : "├";
1851
+ const group = item.group ? theme.fg("dim", ` ${item.group}`) : "";
1852
+ lines.push(`${qcuBranch(theme, branch)}${theme.fg("text", qcuOneLine(item.label, 72))}: ${theme.fg("accent", qcuFormatTokens(item.tokens))} tokens${group}`);
1853
+ if (options.showDescriptions && item.description) lines.push(`${qcuStem(theme, isLast)}${theme.fg("dim", qcuOneLine(item.description, 100))}`);
1854
+ });
1855
+ if (items.length > visible.length) lines.push(`${qcuBranch(theme, "└")}${theme.fg("dim", `… ${items.length - visible.length} more`)}`);
1856
+ }
1857
+
1858
+ function renderQolContextUsageMessage(message: any, _options: any, theme: Theme): { render(width: number): string[]; invalidate(): void } {
1859
+ const details = message?.details as QolContextUsageMessageDetails | undefined;
1860
+ return {
1861
+ invalidate() {},
1862
+ render(width: number): string[] {
1863
+ if (!details) return [theme.fg("warning", "Context usage details unavailable.")];
1864
+ const safeWidth = Math.max(48, width);
1865
+ const lines: string[] = [];
1866
+ lines.push(`${theme.fg("accent", "› /context")}`);
1867
+ lines.push(`└ ${theme.fg("customMessageLabel", theme.bold("Context Usage"))}`);
1868
+ const gridCols = safeWidth >= 112 ? 28 : safeWidth >= 88 ? 22 : 16;
1869
+ const gridRows = safeWidth >= 88 ? 7 : 6;
1870
+ const totalBlocks = gridCols * gridRows;
1871
+ const contextWindow = details.usage.contextWindow;
1872
+ const gridDenominator = contextWindow ?? details.usage.tokens;
1873
+ const blockCategories: QolContextCategory[] = [];
1874
+ for (const category of details.categories) {
1875
+ if (!gridDenominator || category.tokens <= 0) continue;
1876
+ let count = Math.round((category.tokens / gridDenominator) * totalBlocks);
1877
+ if (count === 0 && category.tokens > 0) count = 1;
1878
+ for (let index = 0; index < count && blockCategories.length < totalBlocks; index += 1) blockCategories.push(category);
1879
+ }
1880
+ const gridLines: string[] = [];
1881
+ for (let row = 0; row < gridRows; row += 1) {
1882
+ let line = "";
1883
+ for (let col = 0; col < gridCols; col += 1) {
1884
+ const category = blockCategories[row * gridCols + col];
1885
+ line += category ? theme.fg(category.color as any, category.icon) : theme.fg("borderMuted", "□");
1886
+ if (col !== gridCols - 1) line += " ";
1887
+ }
1888
+ gridLines.push(line);
1889
+ }
1890
+ const modelBits = [`${details.model.provider}/${details.model.id}`];
1891
+ if (details.model.thinking) modelBits.push(`thinking ${details.model.thinking}`);
1892
+ const summaryLines = [
1893
+ `${theme.fg("text", theme.bold(details.model.label))}${contextWindow ? theme.fg("muted", ` (${qcuFormatTokens(contextWindow)} context)`) : ""}`,
1894
+ theme.fg("muted", modelBits.join(" · ")),
1895
+ `${theme.fg("accent", qcuFormatTokens(details.usage.tokens))}${contextWindow ? `/${qcuFormatTokens(contextWindow)}` : ""} tokens${details.usage.percent != null ? ` (${details.usage.percent.toFixed(1)}%)` : ""}`,
1896
+ "",
1897
+ theme.fg("muted", theme.italic("Estimated usage by category")),
1898
+ ...details.categories.map((category) => `${theme.fg(category.color as any, category.icon)} ${theme.fg("text", `${category.label}:`)} ${qcuFormatTokens(category.tokens)} tokens ${theme.fg("muted", `(${qcuPercent(category.tokens, contextWindow)})`)}`),
1899
+ ];
1900
+ if (details.freeTokens != null) summaryLines.push(`${theme.fg("borderMuted", "□")} ${theme.fg("text", "Free space:")} ${qcuFormatTokens(details.freeTokens)} ${theme.fg("muted", `(${qcuPercent(details.freeTokens, contextWindow)})`)}`);
1901
+ lines.push("");
1902
+ if (safeWidth >= 82) {
1903
+ const leftWidth = visibleWidth(gridLines[0] ?? "");
1904
+ const maxRows = Math.max(gridLines.length, summaryLines.length);
1905
+ for (let index = 0; index < maxRows; index += 1) {
1906
+ lines.push(` ${qcuPadAnsi(gridLines[index] ?? "", leftWidth)} ${summaryLines[index] ?? ""}`.trimEnd());
1907
+ }
1908
+ } else {
1909
+ lines.push(...gridLines.map((line) => ` ${line}`));
1910
+ lines.push("");
1911
+ lines.push(...summaryLines.map((line) => ` ${line}`));
1912
+ }
1913
+ if (details.note) lines.push("", theme.fg("dim", details.note));
1914
+ const messageStats = details.messageStats;
1915
+ lines.push("");
1916
+ lines.push(`${theme.fg("customMessageLabel", theme.bold("Conversation"))}`);
1917
+ lines.push(`${qcuBranch(theme, "├")}context messages: ${theme.fg("accent", String(messageStats.contextMessages))} ${theme.fg("dim", `(${messageStats.branchEntries} branch entries)`)}`);
1918
+ lines.push(`${qcuBranch(theme, "├")}user: ${messageStats.user} · assistant: ${messageStats.assistant} · tool results: ${messageStats.toolResult}`);
1919
+ lines.push(`${qcuBranch(theme, "└")}bash: ${messageStats.bash} · custom: ${messageStats.custom} · compact summaries: ${messageStats.compact}`);
1920
+ qcuRenderDetailSection(lines, "MCP tools", details.mcpTools, theme, { empty: undefined, limit: 12, showDescriptions: false });
1921
+ qcuRenderDetailSection(lines, "Built-in tools", details.builtinTools, theme, { limit: details.mcpTools.length > 0 ? 8 : 12, showDescriptions: false });
1922
+ qcuRenderDetailSection(lines, "Extension tools", details.extensionTools, theme, { limit: 12, showDescriptions: false });
1923
+ qcuRenderDetailSection(lines, "Custom agents · /agents", details.customAgents, theme, { limit: 12, showDescriptions: false });
1924
+ qcuRenderDetailSection(lines, "Context / memory files", details.contextFiles, theme, { limit: 12, showDescriptions: false });
1925
+ qcuRenderDetailSection(lines, "Skills · /skills", details.skills, theme, { empty: "No skill commands discovered.", limit: 14, showDescriptions: false });
1926
+ qcuRenderDetailSection(lines, "Compact buffer", details.compactSummaries, theme, { limit: 6, showDescriptions: false });
1927
+ return lines.map((line) => truncateToWidth(line, safeWidth, ""));
1928
+ },
1929
+ };
1930
+ }
1931
+
1932
+ function textFromContent(content: unknown): string {
1933
+ if (typeof content === "string") return content;
1934
+ if (!Array.isArray(content)) return "";
1935
+ return content
1936
+ .map((part) => {
1937
+ if (typeof part === "string") return part;
1938
+ if (part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string") return (part as any).text;
1939
+ return "";
1940
+ })
1941
+ .filter(Boolean)
1942
+ .join("\n");
1943
+ }
1944
+
1945
+ function lastAssistantTextFromAgentEnd(event: any, ctx: ExtensionContext): string {
1946
+ const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
1947
+ for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
1948
+ const message = eventMessages[index];
1949
+ if (message?.role === "assistant") return textFromContent(message.content);
1950
+ }
1951
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
1952
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
1953
+ const entry = branch[index] as any;
1954
+ if (entry?.type === "message" && entry.message?.role === "assistant") return textFromContent(entry.message.content);
1955
+ }
1956
+ return "";
1957
+ }
1958
+
1959
+ function needsDirection(text: string): boolean {
1960
+ return /\?\s*$|\b(let me know|tell me|which (one|option)|choose|confirm|approve|should i|would you like|do you want|need your input|awaiting|next step)\b/i.test(text);
1961
+ }
1962
+
1963
+ function criticalInfo(text: string): string | undefined {
1964
+ const match = text.match(/\b(critical|urgent|warning|blocked|cannot proceed|security|vulnerab|secret|credential|rate limit|context (overflow|full)|manual action required)\b/i);
1965
+ if (!match) return undefined;
1966
+ const line = text.split(/\r?\n/).find((candidate) => candidate.toLowerCase().includes(match[0].toLowerCase())) ?? text;
1967
+ return line.trim();
1968
+ }
1969
+
1970
+ function taskStats(state: any): { completed: number; remaining: number; total: number } | undefined {
1971
+ const tasks = Array.isArray(state?.tasks) ? state.tasks : undefined;
1972
+ if (!tasks) return undefined;
1973
+ const total = tasks.length;
1974
+ const completed = tasks.filter((task: any) => task?.status === "completed").length;
1975
+ const remaining = tasks.filter((task: any) => task?.status === "pending" || task?.status === "in_progress").length;
1976
+ return { completed, remaining, total };
1977
+ }
1978
+
1979
+ function getQuestionService(): QuestionServiceLike | undefined {
1980
+ const service = (globalThis as unknown as Record<PropertyKey, unknown>)[QUESTION_SERVICE_SYMBOL];
1981
+ if (!service || typeof service !== "object") return undefined;
1982
+ const candidate = service as Partial<QuestionServiceLike>;
1983
+ if (typeof candidate.subscribe === "function" && typeof candidate.listPending === "function") return candidate as QuestionServiceLike;
1984
+ return undefined;
1985
+ }
1986
+
1987
+ function entryToHandoffMessage(entry: SessionEntry): AgentMessage | undefined {
1988
+ if (entry.type === "message") return entry.message;
1989
+ if (entry.type === "compaction") {
1990
+ return {
1991
+ role: "compactionSummary",
1992
+ summary: entry.summary,
1993
+ tokensBefore: entry.tokensBefore,
1994
+ timestamp: new Date(entry.timestamp).getTime(),
1995
+ };
1996
+ }
1997
+ return undefined;
1998
+ }
1999
+
2000
+ function getHandoffMessages(branch: SessionEntry[]): AgentMessage[] {
2001
+ let compactionIndex = -1;
2002
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
2003
+ if (branch[i]?.type === "compaction") {
2004
+ compactionIndex = i;
2005
+ break;
2006
+ }
2007
+ }
2008
+ if (compactionIndex < 0) return branch.map(entryToHandoffMessage).filter((message): message is AgentMessage => message !== undefined);
2009
+
2010
+ const compaction = branch[compactionIndex];
2011
+ const firstKeptIndex = compaction?.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
2012
+ const compactedBranch = [
2013
+ compaction,
2014
+ ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []),
2015
+ ...branch.slice(compactionIndex + 1),
2016
+ ];
2017
+ return compactedBranch.map(entryToHandoffMessage).filter((message): message is AgentMessage => message !== undefined);
2018
+ }
2019
+
2020
+ async function runHandoff(args: string, ctx: ExtensionCommandContext): Promise<void> {
2021
+ if (!ctx.hasUI) {
2022
+ ctx.ui.notify("handoff requires interactive mode", "error");
2023
+ return;
2024
+ }
2025
+
2026
+ if (!ctx.model) {
2027
+ ctx.ui.notify("No model selected", "error");
2028
+ return;
2029
+ }
2030
+
2031
+ const goal = args.trim();
2032
+ if (!goal) {
2033
+ ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
2034
+ return;
2035
+ }
2036
+
2037
+ const messages = getHandoffMessages(ctx.sessionManager.getBranch());
2038
+
2039
+ if (messages.length === 0) {
2040
+ ctx.ui.notify("No conversation to hand off", "error");
2041
+ return;
2042
+ }
2043
+
2044
+ const llmMessages = convertToLlm(messages);
2045
+ const conversationText = serializeConversation(llmMessages);
2046
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
2047
+
2048
+ const releaseModalLock = acquireVstackModalLock();
2049
+ let result: string | null = null;
2050
+ try {
2051
+ result = await ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
2052
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
2053
+ loader.onAbort = () => done(null);
2054
+
2055
+ const doGenerate = async () => {
2056
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
2057
+ if (!auth.ok || !auth.apiKey) {
2058
+ throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
2059
+ }
2060
+
2061
+ const userMessage: Message = {
2062
+ role: "user",
2063
+ content: [
2064
+ {
2065
+ type: "text",
2066
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
2067
+ },
2068
+ ],
2069
+ timestamp: Date.now(),
2070
+ };
2071
+
2072
+ const response = await complete(
2073
+ ctx.model!,
2074
+ { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
2075
+ { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
2076
+ );
2077
+
2078
+ if (response.stopReason === "aborted") return null;
2079
+ return response.content
2080
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
2081
+ .map((content) => content.text)
2082
+ .join("\n");
2083
+ };
2084
+
2085
+ doGenerate()
2086
+ .then(done)
2087
+ .catch((error) => {
2088
+ console.error("Handoff generation failed:", error);
2089
+ done(null);
2090
+ });
2091
+
2092
+ return loader;
2093
+ });
2094
+ } finally {
2095
+ releaseModalLock();
2096
+ }
2097
+
2098
+ if (result === null) {
2099
+ ctx.ui.notify("Cancelled", "info");
2100
+ return;
2101
+ }
2102
+
2103
+ const prompt = settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? await ctx.ui.editor("Edit handoff prompt", result) : result;
2104
+ if (prompt === undefined) {
2105
+ ctx.ui.notify("Cancelled", "info");
2106
+ return;
2107
+ }
2108
+
2109
+ const newSessionResult = await ctx.newSession({
2110
+ parentSession: currentSessionFile,
2111
+ withSession: async (replacementCtx: any) => {
2112
+ replacementCtx.ui.setEditorText(prompt);
2113
+ replacementCtx.ui.notify("Handoff ready. Submit when ready.", "info");
2114
+ },
2115
+ });
2116
+
2117
+ if (newSessionResult.cancelled) {
2118
+ ctx.ui.notify("New session cancelled", "info");
2119
+ }
2120
+ }
2121
+
2122
+ function statusMessage(ctx: ExtensionContext): string {
2123
+ const labels = attachmentLabels(currentEditorText(ctx), ctx.cwd);
2124
+ const searchShortcut = sessionSearchShortcut(ctx.cwd);
2125
+ return [
2126
+ "Pi QOL status",
2127
+ `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
2128
+ `Shift+Enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
2129
+ `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
2130
+ `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
2131
+ `Image chips: ${settingBoolean("showImageChips", true, ctx.cwd) ? "filled (placeholders and existing image paths)" : "off"}`,
2132
+ `Image placeholders/paths in draft: ${labels.length ? labels.join(", ") : "none"}`,
2133
+ `Rename command: ${settingBoolean("enableSessionNameCommand", true, ctx.cwd) ? "enabled (/rename)" : "disabled"}`,
2134
+ `Auto session rename: ${autoRenameEnabled(ctx.cwd) ? `enabled (${settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, ctx.cwd)})` : "disabled"}`,
2135
+ `Handoff command: ${settingBoolean("enableHandoffCommand", true, ctx.cwd) ? "enabled" : "disabled"}`,
2136
+ `Handoff prompt review: ${settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? "enabled" : "disabled"}`,
2137
+ `Session search: ${settingBoolean("sessionSearch.enabled", true, ctx.cwd) ? `enabled (/search${searchShortcut ? `, ${searchShortcut}` : ""})` : "disabled"}`,
2138
+ `Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
2139
+ `Idle compaction: ${settingBoolean("compaction.idleEnabled", false, ctx.cwd) ? `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle` : "disabled"}`,
2140
+ `Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
2141
+ `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"}`,
2142
+ `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"}`,
2143
+ `Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
2144
+ "If Shift+Enter still submits, configure your terminal/tmux to send a distinct Shift+Enter sequence or use the fallback key.",
2145
+ ].join("\n");
2146
+ }
2147
+
2148
+
2149
+ type QolSessionSortMode = "recent" | "relevance";
2150
+
2151
+ type QolSessionAction = "resume" | "copy" | "summarize" | "newSession" | "back";
2152
+
2153
+ const QOL_SESSION_ACTIONS: QolSessionAction[] = ["resume", "copy", "summarize", "newSession", "back"];
2154
+ const QOL_SESSION_ACTION_LABELS: Record<QolSessionAction, string> = {
2155
+ resume: "Resume Session From Here",
2156
+ copy: "Copy Prompt",
2157
+ summarize: "Inject Context",
2158
+ newSession: "New Session + Context",
2159
+ back: "Back",
2160
+ };
2161
+
2162
+ interface QolSessionSearchSession {
2163
+ allMessagesText: string;
2164
+ created: Date;
2165
+ cwd: string;
2166
+ firstMessage: string;
2167
+ id: string;
2168
+ messageCount: number;
2169
+ modified: Date;
2170
+ name?: string;
2171
+ parentSessionPath?: string;
2172
+ path: string;
2173
+ }
2174
+
2175
+ interface QolSessionSearchResult extends QolSessionSearchSession {
2176
+ rank: number;
2177
+ snippets: string[];
2178
+ }
2179
+
2180
+ interface QolParsedSessionQuery {
2181
+ mode: "tokens" | "regex";
2182
+ tokens: Array<{ kind: "fuzzy" | "phrase"; value: string }>;
2183
+ regex?: RegExp;
2184
+ error?: string;
2185
+ }
2186
+
2187
+ interface QolSessionSearchState {
2188
+ cursor: number;
2189
+ query: string;
2190
+ results: QolSessionSearchResult[];
2191
+ selected: number;
2192
+ total: number;
2193
+ }
2194
+
2195
+ interface QolSessionUserMessage {
2196
+ entryId?: string;
2197
+ index: number;
2198
+ parentId?: string | null;
2199
+ text: string;
2200
+ timestamp?: number;
2201
+ }
2202
+
2203
+ interface QolSessionMessagesState {
2204
+ messages: QolSessionUserMessage[];
2205
+ result: QolSessionSearchResult;
2206
+ selected: number;
2207
+ }
2208
+
2209
+ interface QolSessionActionState {
2210
+ actionIndex: number;
2211
+ message: QolSessionUserMessage;
2212
+ result: QolSessionSearchResult;
2213
+ }
2214
+
2215
+ interface QolSessionFocusState {
2216
+ cursor: number;
2217
+ message: QolSessionUserMessage;
2218
+ prompt: string;
2219
+ result: QolSessionSearchResult;
2220
+ type: "summarize" | "newSession";
2221
+ }
2222
+
2223
+ interface QolSessionSearchPendingMessage {
2224
+ content: string;
2225
+ details: Record<string, unknown>;
2226
+ }
2227
+
2228
+ interface QolSessionPaletteAction {
2229
+ type: "cancel" | "resume" | "copy" | "summarize" | "newSession";
2230
+ customPrompt?: string;
2231
+ message?: QolSessionUserMessage;
2232
+ result?: QolSessionSearchResult;
2233
+ }
2234
+
2235
+ let qolSessionSearchCache: QolSessionSearchSession[] = [];
2236
+ let qolSessionSearchLoadedAt = 0;
2237
+ let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
2238
+ const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
2239
+
2240
+ function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
2241
+ return (globalThis as unknown as Record<PropertyKey, unknown>)[SESSION_SEARCH_PENDING_SYMBOL] as QolSessionSearchPendingMessage | undefined;
2242
+ }
2243
+
2244
+ function setPendingSessionSearchMessage(message: QolSessionSearchPendingMessage | undefined): void {
2245
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
2246
+ if (message) host[SESSION_SEARCH_PENDING_SYMBOL] = message;
2247
+ else delete host[SESSION_SEARCH_PENDING_SYMBOL];
2248
+ }
2249
+
2250
+ function coerceDate(value: unknown): Date {
2251
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
2252
+ const date = new Date(typeof value === "string" || typeof value === "number" ? value : 0);
2253
+ return Number.isNaN(date.getTime()) ? new Date(0) : date;
2254
+ }
2255
+
2256
+ function sessionInfoToSearchSession(info: any): QolSessionSearchSession | undefined {
2257
+ if (!info || typeof info.path !== "string") return undefined;
2258
+ return {
2259
+ allMessagesText: typeof info.allMessagesText === "string" ? info.allMessagesText : "",
2260
+ created: coerceDate(info.created),
2261
+ cwd: typeof info.cwd === "string" ? info.cwd : "",
2262
+ firstMessage: typeof info.firstMessage === "string" ? info.firstMessage : "(no messages)",
2263
+ id: typeof info.id === "string" ? info.id : basename(info.path),
2264
+ messageCount: Number.isFinite(Number(info.messageCount)) ? Number(info.messageCount) : 0,
2265
+ modified: coerceDate(info.modified),
2266
+ name: typeof info.name === "string" && info.name.trim() ? info.name.trim() : undefined,
2267
+ parentSessionPath: typeof info.parentSessionPath === "string" ? info.parentSessionPath : undefined,
2268
+ path: info.path,
2269
+ };
2270
+ }
2271
+
2272
+ function resolveSettingsRelativePath(value: string, settingsPath: string): string {
2273
+ const expanded = expandHome(value.trim());
2274
+ return isAbsolute(expanded) ? expanded : resolve(dirname(settingsPath), expanded);
2275
+ }
2276
+
2277
+ function sessionSearchShortcut(cwd?: string): string | undefined {
2278
+ // Legacy escape hatch from the original Ctrl+F setting. If users disabled it,
2279
+ // keep shortcuts disabled even though the default shortcut is now conflict-free.
2280
+ if (!settingBoolean("sessionSearch.ctrlFShortcut", true, cwd)) return undefined;
2281
+ const shortcut = settingStringAllowEmpty("sessionSearch.shortcutKey", DEFAULT_SESSION_SEARCH_SHORTCUT, cwd).trim().toLowerCase();
2282
+ if (!shortcut || shortcut === "none" || shortcut === "off" || shortcut === "false") return undefined;
2283
+ return shortcut;
2284
+ }
2285
+
2286
+ function configuredSessionDir(cwd: string): string | undefined {
2287
+ const envDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
2288
+ if (envDir) return resolveSettingsRelativePath(envDir, join(resolve(cwd), ".pi", "settings.json"));
2289
+ let configured: string | undefined;
2290
+ for (const settingsPath of piSettingsPaths(cwd)) {
2291
+ if (!existsSync(settingsPath)) continue;
2292
+ try {
2293
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
2294
+ if (typeof parsed?.sessionDir === "string" && parsed.sessionDir.trim()) {
2295
+ configured = resolveSettingsRelativePath(parsed.sessionDir, settingsPath);
2296
+ }
2297
+ } catch {
2298
+ // Ignore malformed optional settings.
2299
+ }
2300
+ }
2301
+ return configured;
2302
+ }
2303
+
2304
+ async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
2305
+ const customSessionDir = configuredSessionDir(ctx.cwd);
2306
+ const infos = customSessionDir
2307
+ ? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
2308
+ : await SessionManager.listAll(onProgress);
2309
+ return infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined);
2310
+ }
2311
+
2312
+ async function refreshQolSessionSearchCache(ctx: ExtensionContext, options?: { force?: boolean; quiet?: boolean }): Promise<QolSessionSearchSession[]> {
2313
+ const ttlMs = Math.max(0, settingNumber("sessionSearch.cacheTtlSeconds", DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS, ctx.cwd) * 1000);
2314
+ const fresh = qolSessionSearchCache.length > 0 && (ttlMs === 0 || Date.now() - qolSessionSearchLoadedAt < ttlMs);
2315
+ if (!options?.force && fresh) return qolSessionSearchCache;
2316
+ if (qolSessionSearchLoading) return qolSessionSearchLoading;
2317
+
2318
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, "Loading sessions...");
2319
+ qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, (loaded, total) => {
2320
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
2321
+ }).then((sessions) => {
2322
+ qolSessionSearchCache = sessions;
2323
+ qolSessionSearchLoadedAt = Date.now();
2324
+ qolSessionUserMessagesCache.clear();
2325
+ return sessions;
2326
+ }).finally(() => {
2327
+ qolSessionSearchLoading = undefined;
2328
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
2329
+ });
2330
+ return qolSessionSearchLoading;
2331
+ }
2332
+
2333
+ function formatSessionSearchDate(date: Date): string {
2334
+ const now = Date.now();
2335
+ const diffMs = Math.max(0, now - date.getTime());
2336
+ const diffMins = Math.floor(diffMs / 60_000);
2337
+ const diffHours = Math.floor(diffMs / 3_600_000);
2338
+ const diffDays = Math.floor(diffMs / 86_400_000);
2339
+ if (diffMins < 1) return "now";
2340
+ if (diffMins < 60) return `${diffMins}m ago`;
2341
+ if (diffHours < 24) return `${diffHours}h ago`;
2342
+ if (diffDays < 7) return `${diffDays}d ago`;
2343
+ return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
2344
+ }
2345
+
2346
+ const ANSI_ESCAPE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
2347
+ const ANSI_GREEN_FG = "\x1b[32m";
2348
+ const ANSI_RED_FG = "\x1b[31m";
2349
+ const ANSI_YELLOW_FG = "\x1b[33m";
2350
+ const ANSI_FG_RESET = "\x1b[39m";
2351
+
2352
+ function ansiGreen(text: string): string {
2353
+ return `${ANSI_GREEN_FG}${text}${ANSI_FG_RESET}`;
2354
+ }
2355
+
2356
+ function ansiRed(text: string): string {
2357
+ return `${ANSI_RED_FG}${text}${ANSI_FG_RESET}`;
2358
+ }
2359
+
2360
+ function ansiYellow(text: string): string {
2361
+ return `${ANSI_YELLOW_FG}${text}${ANSI_FG_RESET}`;
2362
+ }
2363
+
2364
+ interface PendingQueueThemePatch {
2365
+ originalFg: unknown;
2366
+ cwd?: string;
2367
+ }
2368
+
2369
+ function isPendingQueuePreviewText(text: string): boolean {
2370
+ const plain = stripAnsi(text);
2371
+ return plain.startsWith("Steering: ") || plain.startsWith("Follow-up: ");
2372
+ }
2373
+
2374
+ function isPendingQueueHintText(text: string): boolean {
2375
+ const plain = stripAnsi(text);
2376
+ return plain.startsWith("↳ ") && plain.includes("queued messages");
2377
+ }
2378
+
2379
+ function pendingQueueLine(text: string): string {
2380
+ return ansiGreen(`| ${text}`);
2381
+ }
2382
+
2383
+ function isQueuedMessageStatusText(text: string): boolean {
2384
+ const plain = stripAnsi(text);
2385
+ return /^Restored \d+ queued messages? to editor$/.test(plain) || plain === "No queued messages to restore";
2386
+ }
2387
+
2388
+ function installStatusTextAlignmentPatch(): void {
2389
+ const proto = Text.prototype as unknown as Record<PropertyKey, any>;
2390
+ if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL]) return;
2391
+ const originalRender = proto.render;
2392
+ if (typeof originalRender !== "function") return;
2393
+ proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = true;
2394
+ proto.render = function patchedQolStatusTextRender(this: any, width: number): string[] {
2395
+ const text = typeof this?.text === "string" ? this.text : "";
2396
+ if (!isQueuedMessageStatusText(text)) return originalRender.call(this, width);
2397
+ const originalPaddingX = this.paddingX;
2398
+ try {
2399
+ this.paddingX = 0;
2400
+ this.invalidate?.();
2401
+ return originalRender.call(this, width);
2402
+ } finally {
2403
+ this.paddingX = originalPaddingX;
2404
+ this.invalidate?.();
2405
+ }
2406
+ };
2407
+ }
2408
+
2409
+ function installPendingQueueThemePatch(ctx: ExtensionContext): void {
2410
+ if (!ctx.hasUI) return;
2411
+ const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
2412
+ const existing = proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] as PendingQueueThemePatch | undefined;
2413
+ if (existing) {
2414
+ existing.cwd = ctx.cwd;
2415
+ return;
2416
+ }
2417
+ const originalFg = proto.fg;
2418
+ if (typeof originalFg !== "function") return;
2419
+ const patch: PendingQueueThemePatch = { originalFg, cwd: ctx.cwd };
2420
+ proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] = patch;
2421
+ proto.fg = function patchedQolFg(this: Theme, token: string, text: string): string {
2422
+ if (token === "dim" && typeof text === "string" && settingBoolean("pendingQueue.asciiGreen", true, patch.cwd)) {
2423
+ if (isPendingQueuePreviewText(text) || isPendingQueueHintText(text)) return pendingQueueLine(text);
2424
+ }
2425
+ return (patch.originalFg as (this: Theme, token: string, text: string) => string).call(this, token, text);
2426
+ };
2427
+ }
2428
+
2429
+ function restorePendingQueueThemePatch(_ctx: ExtensionContext): void {
2430
+ const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
2431
+ const patch = proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] as PendingQueueThemePatch | undefined;
2432
+ if (!patch) return;
2433
+ proto.fg = patch.originalFg;
2434
+ delete proto[PENDING_QUEUE_THEME_PATCH_SYMBOL];
2435
+ }
2436
+
2437
+ function oneLine(text: string): string {
2438
+ return text
2439
+ .replace(ANSI_ESCAPE_RE, "")
2440
+ .replace(/[\r\n\t]+/g, " ")
2441
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
2442
+ .replace(/\s+/g, " ")
2443
+ .trim();
2444
+ }
2445
+
2446
+ type AutoRenameFallbackMode = "none" | "truncate" | "words";
2447
+
2448
+ interface AutoRenameAuth {
2449
+ apiKey?: string;
2450
+ headers?: Record<string, string>;
2451
+ label: string;
2452
+ model: any;
2453
+ source: string;
2454
+ }
2455
+
2456
+ function autoRenameEnabled(cwd?: string): boolean {
2457
+ return settingBoolean("sessionAutoRename.enabled", true, cwd);
2458
+ }
2459
+
2460
+ function autoRenameCtxAlive(ctx: ExtensionContext): boolean {
2461
+ // Auto-rename runs from a deferred timer / async path, so the captured
2462
+ // extension context can be invalidated mid-flight by newSession / fork /
2463
+ // switchSession / reload. Touching ctx.hasUI on a stale ctx throws and
2464
+ // kills the host pi process, which has surfaced as bg subagents 'failing'
2465
+ // at exit code 1 even after their work completed cleanly. Treat any
2466
+ // access error as 'no UI available' and silently skip the notification.
2467
+ try {
2468
+ return ctx.hasUI;
2469
+ } catch {
2470
+ return false;
2471
+ }
2472
+ }
2473
+
2474
+ function autoRenameDebug(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
2475
+ if (!autoRenameCtxAlive(ctx)) return;
2476
+ if (settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
2477
+ }
2478
+
2479
+ function autoRenameNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info", force = false): void {
2480
+ if (!autoRenameCtxAlive(ctx)) return;
2481
+ if (force || settingBoolean("sessionAutoRename.notify", false, ctx.cwd) || settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
2482
+ }
2483
+
2484
+ function firstUserMessageText(branch: SessionEntry[]): string | undefined {
2485
+ for (const entry of branch) {
2486
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
2487
+ const text = textFromContent(entry.message.content).trim();
2488
+ if (text) return text;
2489
+ }
2490
+ return undefined;
2491
+ }
2492
+
2493
+ function conversationTranscriptText(branch: SessionEntry[], maxChars: number): string | undefined {
2494
+ const lines: string[] = [];
2495
+ for (const entry of branch) {
2496
+ if (entry.type !== "message") continue;
2497
+ const role = entry.message?.role;
2498
+ if (role !== "user" && role !== "assistant") continue;
2499
+ const text = textFromContent(entry.message.content).trim();
2500
+ if (!text) continue;
2501
+ lines.push(`${role === "user" ? "User" : "Assistant"}: ${text}`);
2502
+ }
2503
+ const transcript = lines.join("\n\n").trim();
2504
+ return transcript ? truncateMiddle(transcript, maxChars) : undefined;
2505
+ }
2506
+
2507
+ function truncateMiddle(text: string, maxChars: number): string {
2508
+ const max = Math.max(200, Math.floor(maxChars));
2509
+ if (text.length <= max) return text;
2510
+ const marker = "\n[...truncated...]\n";
2511
+ const budget = max - marker.length;
2512
+ if (budget <= 0) return text.slice(0, max);
2513
+ const headBudget = Math.ceil(budget * 0.6);
2514
+ const tailBudget = budget - headBudget;
2515
+ let head = text.slice(0, headBudget);
2516
+ const headSpace = head.lastIndexOf(" ");
2517
+ if (headSpace > headBudget * 0.6) head = head.slice(0, headSpace);
2518
+ let tail = text.slice(text.length - tailBudget);
2519
+ const tailSpace = tail.indexOf(" ");
2520
+ if (tailSpace >= 0 && tailSpace < tailBudget * 0.4) tail = tail.slice(tailSpace + 1);
2521
+ return `${head}${marker}${tail}`;
2522
+ }
2523
+
2524
+ function clampAutoRenameName(name: string, maxChars: number): string {
2525
+ const max = Math.max(20, Math.floor(maxChars));
2526
+ let cleaned = oneLine(name).replace(/[.!?:;,]+$/g, "").trim();
2527
+ if (cleaned.length <= max) return cleaned;
2528
+ const truncated = cleaned.slice(0, max).trimEnd();
2529
+ const lastSpace = truncated.lastIndexOf(" ");
2530
+ cleaned = lastSpace > max * 0.45 ? truncated.slice(0, lastSpace) : truncated;
2531
+ return cleaned.replace(/[,;:\s]+$/g, "").trim();
2532
+ }
2533
+
2534
+ function stripAutoRenameThinkTags(text: string): string {
2535
+ return text
2536
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
2537
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
2538
+ .replace(/<think>[\s\S]*/gi, "")
2539
+ .replace(/<thinking>[\s\S]*/gi, "")
2540
+ .trim();
2541
+ }
2542
+
2543
+ function normalizeAutoRenameCandidate(line: string): string {
2544
+ return line
2545
+ .replace(/<[^>]+>/g, " ")
2546
+ .replace(/^\s*(?:final\s+)?(?:title|name|session\s+name)\s*[:\-]\s*/i, "")
2547
+ .replace(/^[\s\-*>#•]+/, "")
2548
+ .replace(/^\d+[.)]\s*/, "")
2549
+ .replace(/\*\*/g, "")
2550
+ .replace(/`/g, "")
2551
+ .replace(/^["'“”‘’]+|["'“”‘’]+$/g, "")
2552
+ .replace(/\s+/g, " ")
2553
+ .trim();
2554
+ }
2555
+
2556
+ function looksLikeAutoRenameReasoning(line: string): boolean {
2557
+ if (!line) return true;
2558
+ if (/\b(here'?s|i would|best title|candidate|option|reasoning|analysis|thinking)\b/i.test(line)) return true;
2559
+ if (/[{}<>|]/.test(line)) return true;
2560
+ const words = line.split(/\s+/).filter(Boolean);
2561
+ return words.length === 0 || words.length > 12;
2562
+ }
2563
+
2564
+ function autoRenameResponseText(response: any): string {
2565
+ const blocks = Array.isArray(response?.content) ? response.content : [];
2566
+ const text = blocks
2567
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
2568
+ .map((block: any) => block.text)
2569
+ .join("\n")
2570
+ .trim();
2571
+ if (text) return text;
2572
+ return blocks
2573
+ .filter((block: any) => block?.type === "thinking" && typeof block.thinking === "string")
2574
+ .map((block: any) => block.thinking)
2575
+ .join("\n")
2576
+ .trim();
2577
+ }
2578
+
2579
+ function sanitizeAutoRenameName(raw: string, maxChars: number): string | undefined {
2580
+ const stripped = stripAutoRenameThinkTags(raw);
2581
+ const lines = stripped
2582
+ .split(/\r?\n/)
2583
+ .map(normalizeAutoRenameCandidate)
2584
+ .filter(Boolean);
2585
+ let name = "";
2586
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2587
+ if (!looksLikeAutoRenameReasoning(lines[index]!)) {
2588
+ name = lines[index]!;
2589
+ break;
2590
+ }
2591
+ }
2592
+ if (!name && lines.length > 0) name = [...lines].sort((a, b) => a.length - b.length)[0]!;
2593
+ name = clampAutoRenameName(name, maxChars);
2594
+ return name && !looksLikeAutoRenameReasoning(name) ? name : undefined;
2595
+ }
2596
+
2597
+ function titleCaseWord(word: string): string {
2598
+ return word ? `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}` : word;
2599
+ }
2600
+
2601
+ function autoRenameFallbackMode(cwd?: string): AutoRenameFallbackMode {
2602
+ const configured = settingString("sessionAutoRename.fallback", "words", cwd).toLowerCase();
2603
+ return configured === "none" || configured === "truncate" ? configured : "words";
2604
+ }
2605
+
2606
+ function deterministicAutoRenameName(query: string, cwd?: string): string | undefined {
2607
+ const mode = autoRenameFallbackMode(cwd);
2608
+ if (mode === "none") return undefined;
2609
+ const maxChars = settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd);
2610
+ const cleaned = oneLine(query)
2611
+ .replace(/[`"'“”‘’]/g, "")
2612
+ .replace(/[^\w\s./-]/g, " ")
2613
+ .replace(/\s+/g, " ")
2614
+ .trim();
2615
+ if (!cleaned) return undefined;
2616
+ if (mode === "truncate") return clampAutoRenameName(cleaned, Math.min(maxChars, 50));
2617
+ const words = cleaned.split(/\s+/).filter(Boolean).slice(0, 6).map(titleCaseWord);
2618
+ return clampAutoRenameName(words.join(" "), maxChars);
2619
+ }
2620
+
2621
+ function autoRenamePrompt(query: string, cwd?: string): string {
2622
+ const maxChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", DEFAULT_AUTO_RENAME_INPUT_CHARS, cwd)));
2623
+ const message = truncateMiddle(query, maxChars);
2624
+ const configured = settingStringAllowEmpty("sessionAutoRename.prompt", "", cwd);
2625
+ const template = configured || DEFAULT_AUTO_RENAME_PROMPT;
2626
+ if (template.includes("{{message}}")) return template.split("{{message}}").join(message);
2627
+ return `${template.trim()}\n\nFirst user message:\n${message}`.trim();
2628
+ }
2629
+
2630
+ function disabledModelSetting(value: string): boolean {
2631
+ const normalized = value.trim().toLowerCase();
2632
+ return !normalized || normalized === "none" || normalized === "off" || normalized === "disabled";
2633
+ }
2634
+
2635
+ function autoRenameModelSettings(cwd?: string): string[] {
2636
+ const primary = settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, cwd);
2637
+ const fallback = settingStringAllowEmpty("sessionAutoRename.fallbackModel", DEFAULT_AUTO_RENAME_FALLBACK_MODEL, cwd);
2638
+ const candidates = [primary];
2639
+ if (primary === DEFAULT_AUTO_RENAME_MODEL) candidates.push("openai-codex/gpt-5.3-codex-spark");
2640
+ candidates.push(fallback);
2641
+ return candidates
2642
+ .map((value) => value.trim())
2643
+ .filter((value) => !disabledModelSetting(value))
2644
+ .filter((value, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === value.toLowerCase()) === index);
2645
+ }
2646
+
2647
+ function headerRecord(headers: unknown): Record<string, string> | undefined {
2648
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return undefined;
2649
+ const entries = Object.entries(headers as Record<string, unknown>)
2650
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].length > 0);
2651
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
2652
+ }
2653
+
2654
+ function modelCost(model: any): number {
2655
+ const cost = model?.cost;
2656
+ return Number(cost?.input ?? 0) + Number(cost?.output ?? 0);
2657
+ }
2658
+
2659
+ async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<AutoRenameAuth | undefined> {
2660
+ const registry = ctx.modelRegistry as any;
2661
+ const rawModels = typeof registry.getAvailable === "function" ? registry.getAvailable() : typeof registry.getAll === "function" ? registry.getAll() : [];
2662
+ const models = Array.isArray(rawModels) ? rawModels.filter((model) => model?.input?.includes?.("text") ?? true) : [];
2663
+ models.sort((a, b) => modelCost(a) - modelCost(b) || modelLabel(a).localeCompare(modelLabel(b)));
2664
+ for (const model of models) {
2665
+ try {
2666
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2667
+ const headers = headerRecord(auth.headers);
2668
+ if (auth.ok && (auth.apiKey || headers)) return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: "cheapest" };
2669
+ } catch {
2670
+ // Try the next available model.
2671
+ }
2672
+ }
2673
+ return undefined;
2674
+ }
2675
+
2676
+ async function resolveAutoRenameModel(ctx: ExtensionContext, configured: string): Promise<AutoRenameAuth | undefined> {
2677
+ if (configured.trim().toLowerCase() === "cheapest") return cheapestAvailableAutoRenameModel(ctx);
2678
+ const model = resolveConfiguredModel(ctx, configured);
2679
+ if (!model) {
2680
+ autoRenameDebug(ctx, `model not found: ${configured}`, "warning");
2681
+ return undefined;
2682
+ }
2683
+ try {
2684
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2685
+ const headers = headerRecord(auth.headers);
2686
+ if (!auth.ok) {
2687
+ autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
2688
+ return undefined;
2689
+ }
2690
+ if (!auth.apiKey && !headers) {
2691
+ autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
2692
+ return undefined;
2693
+ }
2694
+ return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
2695
+ } catch (error) {
2696
+ autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
2697
+ return undefined;
2698
+ }
2699
+ }
2700
+
2701
+ async function generateAutoRenameName(query: string, ctx: ExtensionContext, fullConversation = false): Promise<{ name?: string; source: string }> {
2702
+ const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, ctx.cwd)));
2703
+ const maxTokens = Math.max(16, Math.floor(settingNumber("sessionAutoRename.maxTokens", DEFAULT_AUTO_RENAME_MAX_TOKENS, ctx.cwd)));
2704
+ const prompt = fullConversation
2705
+ ? `${AUTO_RENAME_SYSTEM_PROMPT}\n\nName this session using the conversation transcript below. Return only the title.\n\n${query}`
2706
+ : autoRenamePrompt(query, ctx.cwd);
2707
+ const message: Message = {
2708
+ content: [{ text: prompt, type: "text" }],
2709
+ role: "user",
2710
+ timestamp: Date.now(),
2711
+ };
2712
+
2713
+ for (const configured of autoRenameModelSettings(ctx.cwd)) {
2714
+ const resolved = await resolveAutoRenameModel(ctx, configured);
2715
+ if (!resolved) continue;
2716
+ try {
2717
+ const controller = new AbortController();
2718
+ const timeoutMs = Math.max(1000, Math.floor(settingNumber("sessionAutoRename.timeoutMs", DEFAULT_AUTO_RENAME_TIMEOUT_MS, ctx.cwd)));
2719
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
2720
+ timeout.unref?.();
2721
+ try {
2722
+ const response = await complete(
2723
+ resolved.model,
2724
+ { messages: [message], systemPrompt: AUTO_RENAME_SYSTEM_PROMPT },
2725
+ { apiKey: resolved.apiKey, headers: resolved.headers, maxTokens, signal: controller.signal },
2726
+ );
2727
+ if (response.stopReason === "error") {
2728
+ autoRenameDebug(ctx, `${resolved.label} failed: ${response.errorMessage ?? "unknown error"}`, "warning");
2729
+ continue;
2730
+ }
2731
+ const name = sanitizeAutoRenameName(autoRenameResponseText(response), maxNameChars);
2732
+ if (name) return { name, source: resolved.label };
2733
+ autoRenameDebug(ctx, `${resolved.label} returned no usable title`, "warning");
2734
+ } finally {
2735
+ clearTimeout(timeout);
2736
+ }
2737
+ } catch (error) {
2738
+ autoRenameDebug(ctx, `${resolved.label} failed: ${stringifyError(error)}`, "warning");
2739
+ }
2740
+ }
2741
+
2742
+ const deterministic = deterministicAutoRenameName(query, ctx.cwd);
2743
+ return deterministic ? { name: deterministic, source: `fallback:${autoRenameFallbackMode(ctx.cwd)}` } : { source: "none" };
2744
+ }
2745
+
2746
+ function withAutoRenamePrefix(name: string, cwd?: string): string {
2747
+ const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd)));
2748
+ const prefix = clampAutoRenameName(settingStringAllowEmpty("sessionAutoRename.prefix", "", cwd), 40);
2749
+ return clampAutoRenameName(prefix ? `${prefix}: ${name}` : name, maxNameChars);
2750
+ }
2751
+
2752
+ function sessionDisplayName(session: QolSessionSearchSession): string {
2753
+ if (session.name) return oneLine(session.name) || "session";
2754
+ if (session.cwd) {
2755
+ const parts = oneLine(session.cwd).split(/[\\/]+/).filter(Boolean);
2756
+ if (parts.length >= 2) return parts.slice(-2).join("/");
2757
+ if (parts.length === 1) return parts[0]!;
2758
+ }
2759
+ return oneLine(basename(session.path) || "session") || "session";
2760
+ }
2761
+
2762
+ function sessionResumeTitle(session: QolSessionSearchSession): string {
2763
+ // Match /resume's primary label: explicit session name, otherwise first user prompt.
2764
+ if (session.name) return oneLine(session.name) || sessionDisplayName(session);
2765
+ if (session.firstMessage && session.firstMessage !== "(no messages)") return oneLine(session.firstMessage) || sessionDisplayName(session);
2766
+ return sessionDisplayName(session);
2767
+ }
2768
+
2769
+ function shortPathForUi(path: string): string {
2770
+ const cleaned = oneLine(path);
2771
+ const home = homedir();
2772
+ if (cleaned === home) return "~";
2773
+ if (cleaned.startsWith(`${home}/`)) return `~${cleaned.slice(home.length)}`;
2774
+ return cleaned;
2775
+ }
2776
+
2777
+ function messageContentText(content: unknown): string {
2778
+ if (typeof content === "string") return content;
2779
+ if (!Array.isArray(content)) return "";
2780
+ return content.map((part: any) => {
2781
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
2782
+ if (part?.type === "image") return "[image]";
2783
+ return "";
2784
+ }).filter(Boolean).join(" ");
2785
+ }
2786
+
2787
+ function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
2788
+ const cached = qolSessionUserMessagesCache.get(sessionPath);
2789
+ if (cached) return cached;
2790
+ const messages: QolSessionUserMessage[] = [];
2791
+ try {
2792
+ const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
2793
+ for (const line of lines) {
2794
+ if (!line.trim()) continue;
2795
+ let entry: any;
2796
+ try { entry = JSON.parse(line); } catch { continue; }
2797
+ const message = entry?.type === "message" ? entry.message : undefined;
2798
+ if (!message || message.role !== "user") continue;
2799
+ const text = oneLine(messageContentText(message.content));
2800
+ if (!text) continue;
2801
+ messages.push({
2802
+ entryId: typeof entry.id === "string" ? entry.id : undefined,
2803
+ index: messages.length + 1,
2804
+ parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
2805
+ text,
2806
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : undefined,
2807
+ });
2808
+ }
2809
+ } catch {
2810
+ // Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
2811
+ }
2812
+ qolSessionUserMessagesCache.set(sessionPath, messages);
2813
+ return messages;
2814
+ }
2815
+
2816
+ function userMessagesForResult(result: QolSessionSearchResult): QolSessionUserMessage[] {
2817
+ const messages = sessionUserMessages(result.path);
2818
+ if (messages.length > 0) return messages;
2819
+ return [{ index: 1, text: oneLine(result.firstMessage || "No user messages") }];
2820
+ }
2821
+
2822
+ function sessionUserPromptCount(session: QolSessionSearchSession): number {
2823
+ const count = sessionUserMessages(session.path).length;
2824
+ if (count > 0) return count;
2825
+ return session.firstMessage && session.firstMessage !== "(no messages)" ? 1 : 0;
2826
+ }
2827
+
2828
+ function promptCountLabel(count: number): string {
2829
+ return `${count} prompt${count === 1 ? "" : "s"}`;
2830
+ }
2831
+
2832
+ function lastUserMessageSnippet(session: QolSessionSearchSession): string {
2833
+ const messages = sessionUserMessages(session.path);
2834
+ return messages[messages.length - 1]?.text || oneLine(session.firstMessage || "No user messages");
2835
+ }
2836
+
2837
+ function sessionSearchText(session: QolSessionSearchSession): string {
2838
+ return [session.id, session.name ?? "", session.cwd, session.path, session.firstMessage, session.allMessagesText].join("\n");
2839
+ }
2840
+
2841
+ function normalizeSearchText(text: string): string {
2842
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
2843
+ }
2844
+
2845
+ function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
2846
+ const trimmed = query.trim();
2847
+ if (!trimmed) return { mode: "tokens", tokens: [] };
2848
+ if (trimmed.startsWith("re:")) {
2849
+ const source = trimmed.slice(3).trim();
2850
+ if (!source) return { error: "Empty regex", mode: "regex", tokens: [] };
2851
+ try {
2852
+ return { mode: "regex", regex: new RegExp(source, "i"), tokens: [] };
2853
+ } catch (error) {
2854
+ return { error: stringifyError(error), mode: "regex", tokens: [] };
2855
+ }
2856
+ }
2857
+
2858
+ const tokens: QolParsedSessionQuery["tokens"] = [];
2859
+ let buffer = "";
2860
+ let inQuote = false;
2861
+ let unclosed = false;
2862
+ const flush = (kind: "fuzzy" | "phrase") => {
2863
+ const value = buffer.trim();
2864
+ buffer = "";
2865
+ if (value) tokens.push({ kind, value });
2866
+ };
2867
+
2868
+ for (let i = 0; i < trimmed.length; i++) {
2869
+ const char = trimmed[i]!;
2870
+ if (char === '"') {
2871
+ if (inQuote) {
2872
+ flush("phrase");
2873
+ inQuote = false;
2874
+ } else {
2875
+ flush("fuzzy");
2876
+ inQuote = true;
2877
+ }
2878
+ continue;
2879
+ }
2880
+ if (!inQuote && /\s/.test(char)) {
2881
+ flush("fuzzy");
2882
+ continue;
2883
+ }
2884
+ buffer += char;
2885
+ }
2886
+ if (inQuote) unclosed = true;
2887
+ if (unclosed) {
2888
+ return {
2889
+ mode: "tokens",
2890
+ tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })),
2891
+ };
2892
+ }
2893
+ flush(inQuote ? "phrase" : "fuzzy");
2894
+ return { mode: "tokens", tokens };
2895
+ }
2896
+
2897
+ function simpleFuzzyScore(needle: string, haystack: string): number | undefined {
2898
+ const query = needle.toLowerCase();
2899
+ const text = haystack.toLowerCase();
2900
+ if (!query) return 0;
2901
+ const direct = text.indexOf(query);
2902
+ if (direct >= 0) return direct * 0.1;
2903
+ let pos = 0;
2904
+ let score = 0;
2905
+ for (const char of query) {
2906
+ const found = text.indexOf(char, pos);
2907
+ if (found < 0) return undefined;
2908
+ score += found - pos + 1;
2909
+ pos = found + 1;
2910
+ }
2911
+ return score + 1000;
2912
+ }
2913
+
2914
+ function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
2915
+ const text = sessionSearchText(session);
2916
+ if (parsed.mode === "regex") {
2917
+ if (!parsed.regex) return { matches: false, score: 0 };
2918
+ const index = text.search(parsed.regex);
2919
+ return index < 0 ? { matches: false, score: 0 } : { matches: true, score: index * 0.1 };
2920
+ }
2921
+ if (parsed.tokens.length === 0) return { matches: true, score: 0 };
2922
+ let score = 0;
2923
+ let normalized: string | undefined;
2924
+ for (const token of parsed.tokens) {
2925
+ if (token.kind === "phrase") {
2926
+ normalized ??= normalizeSearchText(text);
2927
+ const phrase = normalizeSearchText(token.value);
2928
+ const index = normalized.indexOf(phrase);
2929
+ if (index < 0) return { matches: false, score: 0 };
2930
+ score += index * 0.1;
2931
+ continue;
2932
+ }
2933
+ const tokenScore = simpleFuzzyScore(token.value, text);
2934
+ if (tokenScore === undefined) return { matches: false, score: 0 };
2935
+ score += tokenScore;
2936
+ }
2937
+ return { matches: true, score };
2938
+ }
2939
+
2940
+ function escapeForRegex(input: string): string {
2941
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2942
+ }
2943
+
2944
+ function sessionSnippetSource(session: QolSessionSearchSession): string {
2945
+ return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
2946
+ }
2947
+
2948
+ function snippetAround(text: string, start: number, length: number, width: number): string {
2949
+ const safeStart = Math.max(0, start - Math.floor(width / 3));
2950
+ const safeEnd = Math.min(text.length, start + length + Math.floor((width * 2) / 3));
2951
+ const prefix = safeStart > 0 ? "…" : "";
2952
+ const suffix = safeEnd < text.length ? "…" : "";
2953
+ return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
2954
+ }
2955
+
2956
+ function buildSessionSnippets(session: QolSessionSearchSession, parsed: QolParsedSessionQuery, limit: number): string[] {
2957
+ const source = sessionSnippetSource(session);
2958
+ if (!source) return [];
2959
+ if (parsed.mode === "regex" && parsed.regex) {
2960
+ parsed.regex.lastIndex = 0;
2961
+ const match = parsed.regex.exec(source);
2962
+ return match ? [snippetAround(source, match.index, match[0].length, 220)] : [];
2963
+ }
2964
+ if (parsed.tokens.length === 0) return [source.slice(0, 220)];
2965
+ const snippets: string[] = [];
2966
+ const lower = source.toLowerCase();
2967
+ const seen = new Set<string>();
2968
+ for (const token of parsed.tokens) {
2969
+ const value = normalizeSearchText(token.value);
2970
+ if (!value) continue;
2971
+ const index = lower.indexOf(value.toLowerCase());
2972
+ if (index < 0) continue;
2973
+ const snippet = snippetAround(source, index, value.length, 220);
2974
+ const key = snippet.toLowerCase();
2975
+ if (!seen.has(key)) {
2976
+ seen.add(key);
2977
+ snippets.push(snippet);
2978
+ }
2979
+ if (snippets.length >= limit) break;
2980
+ }
2981
+ return snippets.length > 0 ? snippets : [source.slice(0, 220)];
2982
+ }
2983
+
2984
+ function searchQolSessions(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchResult[] {
2985
+ const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
2986
+ const snippetLimit = Math.max(1, Math.floor(settingNumber("sessionSearch.previewSnippets", DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS, cwd)));
2987
+ const parsed = parseSessionSearchQuery(query);
2988
+ if (parsed.error) return [];
2989
+ if (!query.trim()) {
2990
+ return sessions
2991
+ .slice()
2992
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime())
2993
+ .slice(0, limit)
2994
+ .map((session) => ({ ...session, rank: 0, snippets: buildSessionSnippets(session, { mode: "tokens", tokens: [] }, 1) }));
2995
+ }
2996
+ const results: QolSessionSearchResult[] = [];
2997
+ for (const session of sessions) {
2998
+ const match = matchSessionSearch(session, parsed);
2999
+ if (!match.matches) continue;
3000
+ results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
3001
+ }
3002
+ const sortMode = settingString("sessionSearch.sortMode", "relevance", cwd) === "recent" ? "recent" : "relevance" as QolSessionSortMode;
3003
+ results.sort((a, b) => sortMode === "recent" ? b.modified.getTime() - a.modified.getTime() : a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
3004
+ return results.slice(0, limit);
3005
+ }
3006
+
3007
+ function styleSessionSnippet(snippet: string, query: string, theme: Theme): string {
3008
+ let styled = snippet;
3009
+ const parsed = parseSessionSearchQuery(query);
3010
+ if (parsed.mode === "tokens") {
3011
+ for (const token of parsed.tokens) {
3012
+ const value = token.value.trim();
3013
+ if (!value || value.length > 80) continue;
3014
+ try {
3015
+ styled = styled.replace(new RegExp(escapeForRegex(value), "gi"), (match) => ansiRed(match));
3016
+ } catch {
3017
+ // Ignore highlighting failures; search result remains readable.
3018
+ }
3019
+ }
3020
+ }
3021
+ return styled;
3022
+ }
3023
+
3024
+ function boxParts(width: number, theme: Theme) {
3025
+ const safeWidth = Math.max(24, width);
3026
+ const paddingX = 2;
3027
+ const frameInner = Math.max(10, safeWidth - 2);
3028
+ const inner = Math.max(1, frameInner - paddingX * 2);
3029
+ const border = (s: string) => theme.fg("borderAccent", s);
3030
+ const fixed = (content = "", rowWidth = inner) => {
3031
+ // A single accidental newline in a session name/prompt can tear the box apart.
3032
+ // Preserve ANSI styling, but collapse hard line breaks before measuring.
3033
+ const safeContent = content.replace(/[\r\n\t]+/g, " ");
3034
+ const clipped = truncateToWidth(safeContent, rowWidth, "");
3035
+ return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
3036
+ };
3037
+ const row = (content = "", selected = false) => {
3038
+ const body = fixed(content);
3039
+ return `${border("┃")}${" ".repeat(paddingX)}${selected ? theme.bg("selectedBg", body) : body}${" ".repeat(paddingX)}${border("┃")}`;
3040
+ };
3041
+ const filledRow = (content = "", bg: "selectedBg" | "toolPendingBg" = "toolPendingBg") => {
3042
+ const body = fixed(content);
3043
+ return `${border("┃")}${" ".repeat(paddingX)}${theme.bg(bg, body)}${" ".repeat(paddingX)}${border("┃")}`;
3044
+ };
3045
+ const selectedRow = (content = "") => row(content, true);
3046
+ const empty = () => `${border("┃")}${" ".repeat(frameInner)}${border("┃")}`;
3047
+ const divider = () => row(theme.fg("borderMuted", "━".repeat(inner)));
3048
+ const top = (label = "", right = "") => {
3049
+ if (!label) return border(`┏${"━".repeat(frameInner)}┓`);
3050
+ const rightPlain = right ? ` ${right} ` : "";
3051
+ const titleBudget = Math.max(1, frameInner - visibleWidth(rightPlain) - 1);
3052
+ const titlePlain = ` ${truncateToWidth(label, Math.max(1, titleBudget - 2), "…")} `;
3053
+ const fill = Math.max(1, frameInner - visibleWidth(titlePlain) - visibleWidth(rightPlain));
3054
+ const rightText = right ? theme.fg("dim", rightPlain) : "";
3055
+ return `${border("┏")}${ansiGreen(titlePlain)}${border("━".repeat(fill))}${rightText}${border("┓")}`;
3056
+ };
3057
+ const bottom = () => border(`┗${"━".repeat(frameInner)}┛`);
3058
+ return { bottom, divider, empty, filledRow, inner, row, selectedRow, top };
3059
+ }
3060
+
3061
+ function isPrintableInput(data: string): boolean {
3062
+ return data.length >= 1 && data.charCodeAt(0) >= 32 && !data.startsWith("\x1b") && data !== "\x7f";
3063
+ }
3064
+
3065
+ class QolSessionSearchComponent {
3066
+ private screen: "search" | "messages" | "actions" | "focus" = "search";
3067
+ private searchState: QolSessionSearchState;
3068
+ private messagesState: QolSessionMessagesState | undefined;
3069
+ private actionState: QolSessionActionState | undefined;
3070
+ private focusState: QolSessionFocusState | undefined;
3071
+
3072
+ constructor(
3073
+ private readonly done: (action: QolSessionPaletteAction) => void,
3074
+ private readonly tui: { requestRender(): void },
3075
+ private readonly theme: Theme,
3076
+ private readonly sessions: QolSessionSearchSession[],
3077
+ private readonly cwd: string,
3078
+ initialQuery = "",
3079
+ ) {
3080
+ const query = initialQuery.trim();
3081
+ this.searchState = {
3082
+ cursor: query.length,
3083
+ query,
3084
+ results: searchQolSessions(sessions, query, cwd),
3085
+ selected: 0,
3086
+ total: sessions.length,
3087
+ };
3088
+ }
3089
+
3090
+ invalidate(): void {}
3091
+
3092
+ render(width: number): string[] {
3093
+ const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
3094
+ const renderWidth = Math.min(Math.max(48, width), configured);
3095
+ if (this.screen === "messages" && this.messagesState) return this.renderMessages(renderWidth, this.messagesState);
3096
+ if (this.screen === "actions" && this.actionState) return this.renderActions(renderWidth, this.actionState);
3097
+ if (this.screen === "focus" && this.focusState) return this.renderFocus(renderWidth, this.focusState);
3098
+ return this.renderSearch(renderWidth);
3099
+ }
3100
+
3101
+ handleInput(data: string): void {
3102
+ if (this.screen === "messages") this.handleMessagesInput(data);
3103
+ else if (this.screen === "actions") this.handleActionInput(data);
3104
+ else if (this.screen === "focus") this.handleFocusInput(data);
3105
+ else this.handleSearchInput(data);
3106
+ this.tui.requestRender();
3107
+ }
3108
+
3109
+ private clampSelection(): void {
3110
+ this.searchState.selected = Math.max(0, Math.min(this.searchState.selected, Math.max(0, this.searchState.results.length - 1)));
3111
+ }
3112
+
3113
+ private updateQuery(query: string, cursor: number): void {
3114
+ this.searchState.query = query;
3115
+ this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
3116
+ this.searchState.results = searchQolSessions(this.sessions, query, this.cwd);
3117
+ this.searchState.selected = 0;
3118
+ this.clampSelection();
3119
+ }
3120
+
3121
+ private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
3122
+ const trimmed = query.trim().toLowerCase();
3123
+ if (trimmed) {
3124
+ const firstToken = trimmed.replace(/^re:/, "").replace(/["']/g, "").split(/\s+/).find(Boolean);
3125
+ if (firstToken) {
3126
+ const found = messages.findIndex((message) => message.text.toLowerCase().includes(firstToken));
3127
+ if (found >= 0) return found;
3128
+ }
3129
+ }
3130
+ return Math.max(0, messages.length - 1);
3131
+ }
3132
+
3133
+ private openMessages(result: QolSessionSearchResult): void {
3134
+ const messages = userMessagesForResult(result);
3135
+ this.messagesState = {
3136
+ messages,
3137
+ result,
3138
+ selected: this.selectedMessageIndex(messages, this.searchState.query),
3139
+ };
3140
+ this.screen = "messages";
3141
+ }
3142
+
3143
+ private selectedFocusText(message: QolSessionUserMessage): string {
3144
+ return `Focus on prompt #${message.index}: ${message.text}`;
3145
+ }
3146
+
3147
+ private handleSearchInput(data: string): void {
3148
+ const state = this.searchState;
3149
+ if (matchesKey(data, "escape")) {
3150
+ this.done({ type: "cancel" });
3151
+ return;
3152
+ }
3153
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3154
+ const result = state.results[state.selected];
3155
+ if (!result) return;
3156
+ this.openMessages(result);
3157
+ return;
3158
+ }
3159
+ if (matchesKey(data, "up")) {
3160
+ state.selected = Math.max(0, state.selected - 1);
3161
+ return;
3162
+ }
3163
+ if (matchesKey(data, "down")) {
3164
+ state.selected = Math.min(state.results.length - 1, state.selected + 1);
3165
+ return;
3166
+ }
3167
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3168
+ state.selected = Math.max(0, state.selected - 10);
3169
+ return;
3170
+ }
3171
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3172
+ state.selected = Math.min(state.results.length - 1, state.selected + 10);
3173
+ return;
3174
+ }
3175
+ if (matchesKey(data, "left")) {
3176
+ state.cursor = Math.max(0, state.cursor - 1);
3177
+ return;
3178
+ }
3179
+ if (matchesKey(data, "right")) {
3180
+ state.cursor = Math.min(state.query.length, state.cursor + 1);
3181
+ return;
3182
+ }
3183
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3184
+ state.cursor = 0;
3185
+ return;
3186
+ }
3187
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3188
+ state.cursor = state.query.length;
3189
+ return;
3190
+ }
3191
+ if (matchesKey(data, "ctrl+u")) {
3192
+ this.updateQuery("", 0);
3193
+ return;
3194
+ }
3195
+ if (matchesKey(data, "ctrl+w") || matchesKey(data, "alt+backspace")) {
3196
+ const before = state.query.slice(0, state.cursor);
3197
+ const after = state.query.slice(state.cursor);
3198
+ let i = before.length;
3199
+ while (i > 0 && /\s/.test(before[i - 1]!)) i -= 1;
3200
+ while (i > 0 && !/\s/.test(before[i - 1]!)) i -= 1;
3201
+ this.updateQuery(`${before.slice(0, i)}${after}`, i);
3202
+ return;
3203
+ }
3204
+ if (matchesKey(data, "backspace")) {
3205
+ if (state.cursor > 0) this.updateQuery(`${state.query.slice(0, state.cursor - 1)}${state.query.slice(state.cursor)}`, state.cursor - 1);
3206
+ return;
3207
+ }
3208
+ if (matchesKey(data, "delete")) {
3209
+ if (state.cursor < state.query.length) this.updateQuery(`${state.query.slice(0, state.cursor)}${state.query.slice(state.cursor + 1)}`, state.cursor);
3210
+ return;
3211
+ }
3212
+ if (isPrintableInput(data)) this.updateQuery(`${state.query.slice(0, state.cursor)}${data}${state.query.slice(state.cursor)}`, state.cursor + data.length);
3213
+ }
3214
+
3215
+ private handleMessagesInput(data: string): void {
3216
+ const state = this.messagesState;
3217
+ if (!state) return;
3218
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3219
+ this.screen = "search";
3220
+ return;
3221
+ }
3222
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3223
+ const message = state.messages[state.selected];
3224
+ if (!message) return;
3225
+ this.actionState = { actionIndex: 0, message, result: state.result };
3226
+ this.screen = "actions";
3227
+ return;
3228
+ }
3229
+ if (data.toLowerCase() === "r") {
3230
+ this.done({ result: state.result, type: "resume" });
3231
+ return;
3232
+ }
3233
+ if (matchesKey(data, "up")) {
3234
+ state.selected = Math.max(0, state.selected - 1);
3235
+ return;
3236
+ }
3237
+ if (matchesKey(data, "down")) {
3238
+ state.selected = Math.min(state.messages.length - 1, state.selected + 1);
3239
+ return;
3240
+ }
3241
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3242
+ state.selected = Math.max(0, state.selected - 10);
3243
+ return;
3244
+ }
3245
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3246
+ state.selected = Math.min(state.messages.length - 1, state.selected + 10);
3247
+ return;
3248
+ }
3249
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3250
+ state.selected = 0;
3251
+ return;
3252
+ }
3253
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3254
+ state.selected = Math.max(0, state.messages.length - 1);
3255
+ }
3256
+ }
3257
+
3258
+ private handleActionInput(data: string): void {
3259
+ const state = this.actionState;
3260
+ if (!state) return;
3261
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
3262
+ this.screen = "messages";
3263
+ return;
3264
+ }
3265
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
3266
+ state.actionIndex = (state.actionIndex + 1) % QOL_SESSION_ACTIONS.length;
3267
+ return;
3268
+ }
3269
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
3270
+ state.actionIndex = (state.actionIndex - 1 + QOL_SESSION_ACTIONS.length) % QOL_SESSION_ACTIONS.length;
3271
+ return;
3272
+ }
3273
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3274
+ const action = QOL_SESSION_ACTIONS[state.actionIndex];
3275
+ if (action === "back") {
3276
+ this.screen = "messages";
3277
+ return;
3278
+ }
3279
+ if (action === "resume" || action === "copy") {
3280
+ this.done({ message: state.message, result: state.result, type: action });
3281
+ return;
3282
+ }
3283
+ this.focusState = { cursor: 0, message: state.message, prompt: "", result: state.result, type: action };
3284
+ this.screen = "focus";
3285
+ }
3286
+ }
3287
+
3288
+ private handleFocusInput(data: string): void {
3289
+ const state = this.focusState;
3290
+ if (!state) return;
3291
+ if (matchesKey(data, "escape")) {
3292
+ this.screen = "actions";
3293
+ return;
3294
+ }
3295
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
3296
+ this.done({ customPrompt: state.prompt.trim() || this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
3297
+ return;
3298
+ }
3299
+ if (matchesKey(data, "left")) {
3300
+ state.cursor = Math.max(0, state.cursor - 1);
3301
+ return;
3302
+ }
3303
+ if (matchesKey(data, "right")) {
3304
+ state.cursor = Math.min(state.prompt.length, state.cursor + 1);
3305
+ return;
3306
+ }
3307
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
3308
+ state.cursor = 0;
3309
+ return;
3310
+ }
3311
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
3312
+ state.cursor = state.prompt.length;
3313
+ return;
3314
+ }
3315
+ if (matchesKey(data, "ctrl+u")) {
3316
+ state.prompt = "";
3317
+ state.cursor = 0;
3318
+ return;
3319
+ }
3320
+ if (matchesKey(data, "ctrl+w") || matchesKey(data, "alt+backspace")) {
3321
+ const before = state.prompt.slice(0, state.cursor);
3322
+ const after = state.prompt.slice(state.cursor);
3323
+ let i = before.length;
3324
+ while (i > 0 && /\s/.test(before[i - 1]!)) i -= 1;
3325
+ while (i > 0 && !/\s/.test(before[i - 1]!)) i -= 1;
3326
+ state.prompt = `${before.slice(0, i)}${after}`;
3327
+ state.cursor = i;
3328
+ return;
3329
+ }
3330
+ if (matchesKey(data, "backspace")) {
3331
+ if (state.cursor > 0) {
3332
+ state.prompt = `${state.prompt.slice(0, state.cursor - 1)}${state.prompt.slice(state.cursor)}`;
3333
+ state.cursor -= 1;
3334
+ }
3335
+ return;
3336
+ }
3337
+ if (matchesKey(data, "delete")) {
3338
+ if (state.cursor < state.prompt.length) state.prompt = `${state.prompt.slice(0, state.cursor)}${state.prompt.slice(state.cursor + 1)}`;
3339
+ return;
3340
+ }
3341
+ if (isPrintableInput(data)) {
3342
+ state.prompt = `${state.prompt.slice(0, state.cursor)}${data}${state.prompt.slice(state.cursor)}`;
3343
+ state.cursor += data.length;
3344
+ }
3345
+ }
3346
+
3347
+ private renderSearch(width: number): string[] {
3348
+ const { bottom, divider, empty, filledRow, inner, row, selectedRow, top } = boxParts(width, this.theme);
3349
+ const state = this.searchState;
3350
+ const lines: string[] = [top("Session Search", `${state.results.length}/${state.total} shown`), empty()];
3351
+ const accent = (s: string) => this.theme.fg("accent", s);
3352
+ const dim = (s: string) => this.theme.fg("dim", s);
3353
+ const muted = (s: string) => this.theme.fg("muted", s);
3354
+ const pair = (left: string, right: string) => {
3355
+ const leftWidth = Math.max(1, inner - visibleWidth(right) - 1);
3356
+ const clippedLeft = truncateToWidth(left, leftWidth, "…");
3357
+ const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3358
+ return `${clippedLeft}${" ".repeat(gap)}${right}`;
3359
+ };
3360
+ const cursorChar = state.query[state.cursor] ?? " ";
3361
+ const queryDisplay = `${state.query.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.query.slice(state.cursor + (state.cursor < state.query.length ? 1 : 0))}`;
3362
+ lines.push(filledRow(` > ${queryDisplay}`));
3363
+ lines.push(row(dim(`${state.total} sessions · re:<pattern> regex · "phrase" exact`)));
3364
+ lines.push(empty(), divider(), empty());
3365
+ if (state.results.length === 0) {
3366
+ lines.push(row(muted(state.query.trim() ? "No sessions match your search" : "No sessions found")), empty());
3367
+ } else {
3368
+ const maxVisible = Math.max(3, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
3369
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3370
+ const end = Math.min(start + maxVisible, state.results.length);
3371
+ const rowBudget = Math.max(10, inner);
3372
+ for (let i = start; i < end; i++) {
3373
+ const result = state.results[i]!;
3374
+ const selected = i === state.selected;
3375
+ const title = sessionResumeTitle(result);
3376
+ const rightPlain = `${promptCountLabel(sessionUserPromptCount(result))} · ${formatSessionSearchDate(result.modified)}`;
3377
+ const right = selected ? this.theme.fg("text", rightPlain) : dim(rightPlain);
3378
+ const titleBudget = Math.max(12, rowBudget - visibleWidth(right) - 1);
3379
+ const titleText = truncateToWidth(title, titleBudget, "…");
3380
+ const left = selected ? this.theme.bold(accent(titleText)) : this.theme.bold(titleText);
3381
+ const titleRow = pair(left, right);
3382
+ lines.push(selected ? selectedRow(titleRow) : row(titleRow));
3383
+
3384
+ const folder = shortPathForUi(result.cwd || dirname(result.path));
3385
+ const project = sessionDisplayName(result);
3386
+ lines.push(row(muted(truncateToWidth(`${project} · ${folder}`, inner, "…"))));
3387
+
3388
+ const snippet = state.query.trim() ? (result.snippets[0] || lastUserMessageSnippet(result)) : lastUserMessageSnippet(result);
3389
+ const label = state.query.trim() ? "match" : "last";
3390
+ const snippetPrefix = `${dim(`${label}:`)} `;
3391
+ lines.push(row(`${snippetPrefix}${truncateToWidth(styleSessionSnippet(snippet, state.query, this.theme), inner - visibleWidth(snippetPrefix), "…")}`));
3392
+ if (i < end - 1) lines.push(row(this.theme.fg("borderMuted", "─".repeat(Math.max(8, inner)))));
3393
+ }
3394
+ if (state.results.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent sessions"}`)));
3395
+ }
3396
+ lines.push(divider(), empty());
3397
+ lines.push(row(`${ansiYellow("↑↓")} ${dim("sessions")} ${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompts")} ${ansiYellow("ctrl+u")} ${dim("clear")} ${ansiYellow("esc")} ${dim("close")}`));
3398
+ lines.push(bottom());
3399
+ return lines;
3400
+ }
3401
+
3402
+ private renderMessages(width: number, state: QolSessionMessagesState): string[] {
3403
+ const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
3404
+ const accent = (s: string) => this.theme.fg("accent", s);
3405
+ const dim = (s: string) => this.theme.fg("dim", s);
3406
+ const muted = (s: string) => this.theme.fg("muted", s);
3407
+ const result = state.result;
3408
+ const lines: string[] = [top("Session Prompts", `${state.selected + 1}/${state.messages.length}`), empty()];
3409
+ const pair = (left: string, right: string) => {
3410
+ const leftWidth = Math.max(1, inner - visibleWidth(right) - 1);
3411
+ const clippedLeft = truncateToWidth(left, leftWidth, "…");
3412
+ const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
3413
+ return `${clippedLeft}${" ".repeat(gap)}${right}`;
3414
+ };
3415
+ const right = dim(`${promptCountLabel(state.messages.length)} · ${formatSessionSearchDate(result.modified)}`);
3416
+ const title = truncateToWidth(sessionResumeTitle(result), Math.max(12, inner - visibleWidth(right) - 1), "…");
3417
+ lines.push(row(pair(this.theme.bold(accent(title)), right)));
3418
+ lines.push(row(muted(truncateToWidth(shortPathForUi(result.cwd || result.path), inner, "…"))));
3419
+ lines.push(row(dim(`${state.messages.length} user prompt${state.messages.length === 1 ? "" : "s"} · enter opens prompt actions · r resumes latest session state`)));
3420
+ lines.push(empty(), divider(), empty());
3421
+
3422
+ const maxVisible = Math.max(6, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
3423
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
3424
+ const end = Math.min(start + maxVisible, state.messages.length);
3425
+ for (let i = start; i < end; i++) {
3426
+ const message = state.messages[i]!;
3427
+ const selected = i === state.selected;
3428
+ const numberPlain = `#${message.index}`;
3429
+ const number = selected ? this.theme.fg("text", numberPlain) : dim(numberPlain);
3430
+ const textWidth = Math.max(12, inner - visibleWidth(number) - 1);
3431
+ const text = truncateToWidth(message.text, textWidth, "…");
3432
+ const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
3433
+ lines.push(selected ? selectedRow(messageRow) : row(messageRow));
3434
+ }
3435
+ if (state.messages.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.messages.length} user prompts`)));
3436
+ lines.push(divider(), empty());
3437
+ lines.push(row(`${ansiYellow("↑↓")} ${dim("prompts")} ${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompt actions")} ${ansiYellow("r")} ${dim("resume session")} ${ansiYellow("esc")} ${dim("sessions")}`));
3438
+ lines.push(bottom());
3439
+ return lines;
3440
+ }
3441
+
3442
+ private renderActions(width: number, state: QolSessionActionState): string[] {
3443
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
3444
+ const accent = (s: string) => this.theme.fg("accent", s);
3445
+ const dim = (s: string) => this.theme.fg("dim", s);
3446
+ const muted = (s: string) => this.theme.fg("muted", s);
3447
+ const lines: string[] = [top("Prompt Actions"), empty()];
3448
+ const title = truncateToWidth(sessionResumeTitle(state.result), Math.max(12, inner - visibleWidth(`#${state.message.index}`) - 2), "…");
3449
+ lines.push(row(`${this.theme.bold(accent(title))} ${dim(`#${state.message.index}`)}`));
3450
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
3451
+ lines.push(empty(), divider(), empty());
3452
+ lines.push(row(dim("Selected prompt")));
3453
+ const wrapped = wrapVisible(state.message.text, inner, 4);
3454
+ for (const line of wrapped) lines.push(row(line));
3455
+ lines.push(empty());
3456
+ lines.push(row(dim("Resume Session From Here restores this prompt in the selected session.")));
3457
+ lines.push(row(dim("Inject Context adds a summary here; New Session + Context imports it into a new session.")));
3458
+ lines.push(empty(), divider(), empty());
3459
+ const actions = QOL_SESSION_ACTIONS.map((action, index) => {
3460
+ const label = QOL_SESSION_ACTION_LABELS[action];
3461
+ return index === state.actionIndex ? this.theme.bold(accent(`[${label}]`)) : dim(`[${label}]`);
3462
+ });
3463
+ for (const line of wrapVisible(actions.join(" "), inner, 2)) lines.push(row(line));
3464
+ lines.push(empty());
3465
+ lines.push(row(`${ansiYellow("tab")}/${ansiYellow("←→")} ${dim("cycle")} ${ansiYellow("enter")} ${dim("choose")} ${ansiYellow("esc")} ${dim("prompts")}`));
3466
+ lines.push(bottom());
3467
+ return lines;
3468
+ }
3469
+
3470
+ private renderFocus(width: number, state: QolSessionFocusState): string[] {
3471
+ const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
3472
+ const accent = (s: string) => this.theme.fg("accent", s);
3473
+ const dim = (s: string) => this.theme.fg("dim", s);
3474
+ const muted = (s: string) => this.theme.fg("muted", s);
3475
+ const action = state.type === "newSession" ? "New Session + Context" : "Inject Context";
3476
+ const lines: string[] = [top("Context Focus", action), empty()];
3477
+ lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`-> ${action}`)}`));
3478
+ lines.push(row(muted(truncateToWidth(`Default focus: #${state.message.index} ${state.message.text}`, inner, "…"))));
3479
+ lines.push(empty(), divider(), empty());
3480
+ const prefix = "> ";
3481
+ const textWidth = Math.max(10, inner - visibleWidth(prefix));
3482
+ if (!state.prompt) {
3483
+ lines.push(filledRow(`${prefix}${this.theme.inverse(" ")}${muted(" optional focus override; Enter uses the selected prompt")}`));
3484
+ } else {
3485
+ const cursorChar = state.prompt[state.cursor] ?? " ";
3486
+ const text = `${state.prompt.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.prompt.slice(state.cursor + (state.cursor < state.prompt.length ? 1 : 0))}`;
3487
+ const wrapped = wrapVisible(text, textWidth, 5);
3488
+ for (let i = 0; i < wrapped.length; i++) lines.push(filledRow(`${i === 0 ? prefix : " ".repeat(visibleWidth(prefix))}${wrapped[i]}`));
3489
+ }
3490
+ lines.push(empty(), divider(), empty());
3491
+ lines.push(row(`${ansiYellow("enter")} ${dim(action === "Inject Context" ? "inject context" : "create session with context")} ${ansiYellow("esc")} ${dim("actions")}`));
3492
+ lines.push(bottom());
3493
+ return lines;
3494
+ }
3495
+ }
3496
+
3497
+ function wrapVisible(text: string, width: number, maxLines: number): string[] {
3498
+ const wrapped = wrapTextWithAnsi(text, width);
3499
+ if (wrapped.length <= maxLines) return wrapped;
3500
+ const head = wrapped.slice(0, Math.max(0, maxLines - 1));
3501
+ const tail = wrapped.slice(Math.max(0, maxLines - 1)).join(" ");
3502
+ return [...head, truncateToWidth(tail, width, "…")];
3503
+ }
3504
+
3505
+ class QolSessionSearchLoadingComponent {
3506
+ private readonly controller = new AbortController();
3507
+ private readonly startedAt = Date.now();
3508
+ private frame = 0;
3509
+ private timer: ReturnType<typeof setInterval> | undefined;
3510
+
3511
+ constructor(
3512
+ private readonly tui: { requestRender(): void },
3513
+ private readonly theme: Theme,
3514
+ private readonly title: string,
3515
+ private readonly message: string,
3516
+ private readonly done: (value: null) => void,
3517
+ ) {
3518
+ this.timer = setInterval(() => {
3519
+ this.frame += 1;
3520
+ this.tui.requestRender();
3521
+ }, 180);
3522
+ this.timer.unref?.();
3523
+ }
3524
+
3525
+ get signal(): AbortSignal {
3526
+ return this.controller.signal;
3527
+ }
3528
+
3529
+ dispose(): void {
3530
+ if (this.timer) clearInterval(this.timer);
3531
+ this.timer = undefined;
3532
+ }
3533
+
3534
+ invalidate(): void {}
3535
+
3536
+ handleInput(data: string): void {
3537
+ if (!matchesKey(data, "escape") && !matchesKey(data, "ctrl+c")) return;
3538
+ if (!this.controller.signal.aborted) this.controller.abort();
3539
+ this.dispose();
3540
+ this.done(null);
3541
+ }
3542
+
3543
+ render(width: number): string[] {
3544
+ const { bottom, divider, empty, row, top } = boxParts(width, this.theme);
3545
+ const dim = (s: string) => this.theme.fg("dim", s);
3546
+ const muted = (s: string) => this.theme.fg("muted", s);
3547
+ const accent = (s: string) => this.theme.fg("accent", s);
3548
+ const frames = ["|", "/", "-", "\\"];
3549
+ const elapsed = `${Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000))}s`;
3550
+ const indicator = accent(frames[this.frame % frames.length]!);
3551
+ return [
3552
+ top(this.title, elapsed),
3553
+ empty(),
3554
+ row(`${indicator} ${muted(this.message)}`),
3555
+ row(dim("Long sessions may take a few seconds to summarize.")),
3556
+ empty(),
3557
+ divider(),
3558
+ empty(),
3559
+ row(`${ansiYellow("esc")} ${dim("cancel")} ${ansiYellow("ctrl+c")} ${dim("cancel")}`),
3560
+ bottom(),
3561
+ ];
3562
+ }
3563
+ }
3564
+
3565
+ function trimSessionSummaryInput(text: string, cwd: string): string {
3566
+ const maxChars = Math.max(20_000, Math.floor(settingNumber("sessionSearch.summaryInputMaxChars", DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS, cwd)));
3567
+ if (text.length <= maxChars) return text;
3568
+ const headChars = Math.floor(maxChars * 0.35);
3569
+ const tailChars = maxChars - headChars;
3570
+ const omitted = text.length - maxChars;
3571
+ return `${text.slice(0, headChars)}\n\n[... ${omitted.toLocaleString()} character(s) omitted from the middle of this imported session ...]\n\n${text.slice(-tailChars)}`;
3572
+ }
3573
+
3574
+ function messagesForSessionSummary(sessionPath: string): AgentMessage[] {
3575
+ try {
3576
+ const manager = SessionManager.open(sessionPath);
3577
+ const context = manager.buildSessionContext();
3578
+ if (Array.isArray(context.messages) && context.messages.length > 0) return context.messages as AgentMessage[];
3579
+ } catch {
3580
+ // Fall back to a direct JSONL parse below.
3581
+ }
3582
+ try {
3583
+ const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
3584
+ const messages: AgentMessage[] = [];
3585
+ for (const line of lines) {
3586
+ if (!line.trim()) continue;
3587
+ let entry: any;
3588
+ try { entry = JSON.parse(line); } catch { continue; }
3589
+ if (entry?.type === "message" && entry.message) messages.push(entry.message);
3590
+ }
3591
+ return messages;
3592
+ } catch {
3593
+ return [];
3594
+ }
3595
+ }
3596
+
3597
+ async function buildSessionSearchContextMessage(ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string, signal?: AbortSignal): Promise<QolSessionSearchPendingMessage> {
3598
+ const messages = messagesForSessionSummary(result.path);
3599
+ const fallbackText = result.allMessagesText || result.firstMessage;
3600
+ const conversationText = messages.length > 0 ? serializeConversation(convertToLlm(messages)) : fallbackText;
3601
+ if (!conversationText.trim()) throw new Error("Selected session has no text content to summarize");
3602
+ const focus = customPrompt?.trim();
3603
+ const summary = await generateQolSummary(ctx, {
3604
+ conversationText: trimSessionSummaryInput(conversationText, ctx.cwd),
3605
+ maxTokens: Math.max(256, Math.floor(settingNumber("sessionSearch.summaryMaxTokens", DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS, ctx.cwd))),
3606
+ model: settingString("sessionSearch.summaryModel", "current", ctx.cwd),
3607
+ customInstructions: [
3608
+ `Source session file: ${result.path}`,
3609
+ `Source project/cwd: ${result.cwd || sessionDisplayName(result)}`,
3610
+ focus ? `User focus: ${focus}` : "Focus on facts needed to continue or reference this previous session.",
3611
+ ].join("\n"),
3612
+ purpose: "session-search",
3613
+ signal: signal ?? ctx.signal,
3614
+ });
3615
+ const title = sessionDisplayName(result);
3616
+ const content = [
3617
+ `## Session Search Context: ${title}`,
3618
+ `**Date:** ${result.modified.toISOString()} | **File:** ${result.path}`,
3619
+ focus ? `**Focus:** ${focus}` : undefined,
3620
+ "",
3621
+ summary.summary,
3622
+ ].filter((line): line is string => line !== undefined).join("\n");
3623
+ return {
3624
+ content,
3625
+ details: {
3626
+ file: result.path,
3627
+ focus,
3628
+ model: summary.model,
3629
+ project: result.cwd || title,
3630
+ source: "pi-qol-session-search",
3631
+ via: summary.via,
3632
+ },
3633
+ };
3634
+ }
3635
+
3636
+ async function buildSessionSearchContextMessageWithLoader(ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt: string | undefined, actionLabel: string): Promise<QolSessionSearchPendingMessage> {
3637
+ const title = sessionDisplayName(result);
3638
+ if (!ctx.hasUI) return buildSessionSearchContextMessage(ctx, result, customPrompt);
3639
+ ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Summarizing ${title}...`);
3640
+ const releaseModalLock = acquireVstackModalLock();
3641
+ let failure: unknown;
3642
+ try {
3643
+ const message = await ctx.ui.custom<QolSessionSearchPendingMessage | null>((tui: any, theme: any, _kb: any, done: (value: QolSessionSearchPendingMessage | null) => void) => {
3644
+ let closed = false;
3645
+ const loader = new QolSessionSearchLoadingComponent(tui, theme, actionLabel, "Summarizing session context...", (value) => {
3646
+ if (closed) return;
3647
+ closed = true;
3648
+ done(value);
3649
+ });
3650
+ const finish = (value: QolSessionSearchPendingMessage | null) => {
3651
+ if (closed) return;
3652
+ closed = true;
3653
+ loader.dispose();
3654
+ done(value);
3655
+ };
3656
+ buildSessionSearchContextMessage(ctx, result, customPrompt, loader.signal)
3657
+ .then(finish)
3658
+ .catch((error) => {
3659
+ if (!loader.signal?.aborted) failure = error;
3660
+ finish(null);
3661
+ });
3662
+ return loader;
3663
+ }, {
3664
+ overlay: true,
3665
+ overlayOptions: {
3666
+ anchor: "center",
3667
+ width: Math.max(56, Math.min(92, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, ctx.cwd) * 0.75))),
3668
+ maxHeight: "70%",
3669
+ },
3670
+ });
3671
+ if (failure) throw failure;
3672
+ if (!message) throw new Error("Session context summary cancelled");
3673
+ return message;
3674
+ } finally {
3675
+ releaseModalLock();
3676
+ ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3677
+ }
3678
+ }
3679
+
3680
+ async function injectSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3681
+ const title = sessionDisplayName(result);
3682
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "Inject Context");
3683
+ pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { deliverAs: "followUp", triggerTurn: false });
3684
+ ctx.ui.notify(`Session context injected: ${title}`, "info");
3685
+ }
3686
+
3687
+ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<ExtensionCommandContext>) {
3688
+ return ctx as ExtensionContext & Partial<ExtensionCommandContext>;
3689
+ }
3690
+
3691
+ async function createNewSessionWithSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, result: QolSessionSearchResult, customPrompt?: string): Promise<void> {
3692
+ const title = sessionDisplayName(result);
3693
+ const message = await buildSessionSearchContextMessageWithLoader(ctx, result, customPrompt, "New Session + Context");
3694
+
3695
+ const commandCtx = asCommandContext(ctx);
3696
+ if (typeof commandCtx.newSession === "function") {
3697
+ const parentSession = ctx.sessionManager.getSessionFile?.();
3698
+ let replacementStarted = false;
3699
+ try {
3700
+ const switchResult = await commandCtx.newSession({
3701
+ parentSession,
3702
+ withSession: async (replacementCtx: any) => {
3703
+ replacementStarted = true;
3704
+ try {
3705
+ if (typeof replacementCtx.sendMessage === "function") {
3706
+ await replacementCtx.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: message.content, details: message.details, display: true }, { triggerTurn: false });
3707
+ }
3708
+ replacementCtx.ui.notify(`New session has context from ${title}`, "info");
3709
+ } catch (error) {
3710
+ replacementCtx.ui.notify(`New session context import failed: ${stringifyError(error)}`, "error");
3711
+ }
3712
+ },
3713
+ });
3714
+ if (switchResult.cancelled) ctx.ui.notify("New session cancelled", "info");
3715
+ } catch (error) {
3716
+ if (!replacementStarted) throw error;
3717
+ }
3718
+ return;
3719
+ }
3720
+ setPendingSessionSearchMessage(message);
3721
+ ctx.ui.setEditorText("/new");
3722
+ ctx.ui.notify(`${title} — press Enter to create a new session with this context`, "info");
3723
+ }
3724
+
3725
+ async function consumePendingSessionSearchContext(pi: ExtensionAPI, ctx: ExtensionContext, reason: unknown): Promise<void> {
3726
+ if (reason !== "new") return;
3727
+ const pending = getPendingSessionSearchMessage();
3728
+ if (!pending) return;
3729
+ setPendingSessionSearchMessage(undefined);
3730
+ pi.sendMessage({ customType: SESSION_SEARCH_CONTEXT_TYPE, content: pending.content, details: pending.details, display: true }, { triggerTurn: false });
3731
+ if (ctx.hasUI) ctx.ui.notify("Imported session-search context.", "info");
3732
+ }
3733
+
3734
+ async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionContext, initialQuery = ""): Promise<void> {
3735
+ if (!ctx.hasUI) {
3736
+ ctx.ui.notify("Session search requires interactive UI", "warning");
3737
+ return;
3738
+ }
3739
+ const sessions = await refreshQolSessionSearchCache(ctx);
3740
+ const releaseModalLock = acquireVstackModalLock();
3741
+ let action: QolSessionPaletteAction | undefined;
3742
+ try {
3743
+ action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) =>
3744
+ new QolSessionSearchComponent(done, tui, theme, sessions, ctx.cwd, initialQuery), {
3745
+ overlay: true,
3746
+ overlayOptions: {
3747
+ anchor: "center",
3748
+ width: Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, ctx.cwd))),
3749
+ maxHeight: "90%",
3750
+ },
3751
+ });
3752
+ } finally {
3753
+ releaseModalLock();
3754
+ }
3755
+ if (!action || action.type === "cancel" || !action.result) return;
3756
+ if (action.type === "resume") {
3757
+ const commandCtx = asCommandContext(ctx);
3758
+ if (typeof commandCtx.switchSession === "function") {
3759
+ const targetTitle = sessionDisplayName(action.result);
3760
+ const selectedMessage = action.message;
3761
+ let replacementStarted = false;
3762
+ try {
3763
+ const result = await commandCtx.switchSession(action.result.path, {
3764
+ withSession: async (replacementCtx: any) => {
3765
+ replacementStarted = true;
3766
+ if (selectedMessage?.entryId) {
3767
+ const manager = replacementCtx.sessionManager as any;
3768
+ if (selectedMessage.parentId && typeof manager?.branch === "function") manager.branch(selectedMessage.parentId);
3769
+ else if (selectedMessage.parentId === null && typeof manager?.resetLeaf === "function") manager.resetLeaf();
3770
+ replacementCtx.ui.setEditorText(selectedMessage.text);
3771
+ replacementCtx.ui.notify(`Resumed ${targetTitle} from prompt #${selectedMessage.index}. Submit to branch from here.`, "info");
3772
+ return;
3773
+ }
3774
+ replacementCtx.ui.notify(`Resumed session: ${targetTitle}`, "info");
3775
+ },
3776
+ });
3777
+ if (result.cancelled) ctx.ui.notify("Resume cancelled", "info");
3778
+ } catch (error) {
3779
+ if (!replacementStarted) ctx.ui.notify(`Resume failed: ${stringifyError(error)}`, "error");
3780
+ }
3781
+ return;
3782
+ }
3783
+ ctx.ui.setEditorText(`/resume ${quoteSessionSearchArg(action.result.path)}`);
3784
+ ctx.ui.notify(`${sessionDisplayName(action.result)} — press Enter to resume`, "info");
3785
+ return;
3786
+ }
3787
+ if (action.type === "copy") {
3788
+ const text = action.message?.text || action.result.firstMessage;
3789
+ ctx.ui.setEditorText(text);
3790
+ ctx.ui.notify("Copied selected prompt into the editor", "info");
3791
+ return;
3792
+ }
3793
+ if (action.type === "summarize") {
3794
+ try {
3795
+ await injectSessionSearchContext(pi, ctx, action.result, action.customPrompt);
3796
+ } catch (error) {
3797
+ const message = stringifyError(error);
3798
+ ctx.ui.notify(/cancelled/i.test(message) ? "Inject context cancelled" : `Inject context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3799
+ ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3800
+ }
3801
+ return;
3802
+ }
3803
+ if (action.type === "newSession") {
3804
+ try {
3805
+ await createNewSessionWithSearchContext(pi, ctx, action.result, action.customPrompt);
3806
+ } catch (error) {
3807
+ const message = stringifyError(error);
3808
+ ctx.ui.notify(/cancelled/i.test(message) ? "New Session + Context cancelled" : `New Session + Context failed: ${message}`, /cancelled/i.test(message) ? "info" : "error");
3809
+ ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
3810
+ }
3811
+ }
3812
+ }
3813
+
3814
+ function quoteSessionSearchArg(value: string): string {
3815
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3816
+ }
3817
+
3818
+ function renderSessionSearchContextMessage(message: any, options: any, theme: Theme): Text {
3819
+ const raw = typeof message.content === "string"
3820
+ ? message.content
3821
+ : Array.isArray(message.content)
3822
+ ? message.content.map((part: any) => part?.type === "text" ? part.text ?? "" : "").join("")
3823
+ : "";
3824
+ const title = raw.match(/^## Session Search Context:\s*(.+)$/m)?.[1]?.trim() || "session";
3825
+ const date = raw.match(/\*\*Date:\*\*\s*([^|\n]+)/)?.[1]?.trim() || "";
3826
+ const header = `${theme.fg("customMessageLabel", theme.bold("Session context: "))}${theme.fg("accent", title)}${date ? theme.fg("muted", ` (${date})`) : ""}`;
3827
+ if (!options?.expanded) return new Text(`${header}${theme.fg("dim", " ctrl+o expand")}`, 0, 0);
3828
+ const body = raw.slice(raw.indexOf("\n") + 1).trim();
3829
+ return new Text(body ? `${header}\n\n${body}` : header, 0, 0);
3830
+ }
3831
+
3832
+ export default function qol(pi: ExtensionAPI): void {
3833
+ const guard = pi as unknown as Record<PropertyKey, unknown>;
3834
+ if (guard[INSTALL_SYMBOL]) return;
3835
+ guard[INSTALL_SYMBOL] = true;
3836
+ if (!settingBoolean("enabled", true)) return;
3837
+
3838
+ installThinkingTimerPatch();
3839
+ installStatusTextAlignmentPatch();
3840
+ const thinkingTimerStore: ThinkingTimerStore = {
3841
+ enabled: false,
3842
+ starts: new Map(),
3843
+ durations: new Map(),
3844
+ labels: new Map(),
3845
+ };
3846
+ (globalThis as unknown as Record<PropertyKey, unknown>)[THINKING_TIMER_STORE_SYMBOL] = thinkingTimerStore;
3847
+
3848
+ let idleCompactionTimer: ReturnType<typeof setTimeout> | undefined;
3849
+ let questionSubscribeTimer: ReturnType<typeof setInterval> | undefined;
3850
+ let sessionSearchWarmupTimer: ReturnType<typeof setTimeout> | undefined;
3851
+ let thinkingTimerTicker: ReturnType<typeof setInterval> | undefined;
3852
+ let questionUnsubscribe: (() => void) | undefined;
3853
+ let lastTaskStats: { completed: number; remaining: number; total: number } | undefined;
3854
+ let autoRenameAttempted = false;
3855
+ let autoRenameInProgress = false;
3856
+ let autoRenameGeneration = 0;
3857
+ let latestSystemPromptOptions: unknown;
3858
+
3859
+ const resetAutoRename = () => {
3860
+ autoRenameAttempted = false;
3861
+ autoRenameInProgress = false;
3862
+ autoRenameGeneration += 1;
3863
+ };
3864
+
3865
+ const attemptAutoRename = async (ctx: ExtensionContext, options: { force?: boolean; fullConversation?: boolean; notify?: boolean } = {}) => {
3866
+ const force = options.force === true;
3867
+ if (!force && !autoRenameEnabled(ctx.cwd)) return;
3868
+ if (autoRenameInProgress) return;
3869
+ if (!force && (autoRenameAttempted || pi.getSessionName())) return;
3870
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
3871
+ const maxInputChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", DEFAULT_AUTO_RENAME_INPUT_CHARS, ctx.cwd)));
3872
+ const sourceText = options.fullConversation ? conversationTranscriptText(branch, maxInputChars) : firstUserMessageText(branch);
3873
+ if (!sourceText) {
3874
+ if (force) autoRenameNotify(ctx, "No user message found to name this session.", "warning", true);
3875
+ return;
3876
+ }
3877
+ const generation = autoRenameGeneration;
3878
+ if (!force) autoRenameAttempted = true;
3879
+ autoRenameInProgress = true;
3880
+ try {
3881
+ const result = await generateAutoRenameName(sourceText, ctx, options.fullConversation === true);
3882
+ if (generation !== autoRenameGeneration) return;
3883
+ if (!result.name) {
3884
+ autoRenameNotify(ctx, "No session name generated.", "warning", options.notify === true || force);
3885
+ return;
3886
+ }
3887
+ if (!force && pi.getSessionName()) return;
3888
+ const name = withAutoRenamePrefix(result.name, ctx.cwd);
3889
+ if (!name) return;
3890
+ pi.setSessionName(name);
3891
+ autoRenameNotify(ctx, `Session named: ${name} (${result.source})`, "info", options.notify === true || force);
3892
+ } finally {
3893
+ if (generation === autoRenameGeneration) autoRenameInProgress = false;
3894
+ }
3895
+ };
3896
+
3897
+ const stopThinkingTimerTicker = () => {
3898
+ if (thinkingTimerTicker) clearInterval(thinkingTimerTicker);
3899
+ thinkingTimerTicker = undefined;
3900
+ };
3901
+
3902
+ const tickThinkingTimer = () => {
3903
+ if (!thinkingTimerStore.enabled || thinkingTimerStore.starts.size === 0) {
3904
+ stopThinkingTimerTicker();
3905
+ return;
3906
+ }
3907
+ for (const [key, start] of thinkingTimerStore.starts.entries()) {
3908
+ const label = thinkingTimerStore.labels.get(key);
3909
+ if (label) label.setText(thinkingTimerLabel(thinkingTimerStore.theme, Date.now() - start, thinkingTimerStore.cwd));
3910
+ }
3911
+ };
3912
+
3913
+ const startThinkingTimerTicker = () => {
3914
+ if (thinkingTimerTicker) return;
3915
+ thinkingTimerTicker = setInterval(tickThinkingTimer, 100);
3916
+ thinkingTimerTicker.unref?.();
3917
+ };
3918
+
3919
+ const resetThinkingTimer = (ctx?: ExtensionContext) => {
3920
+ stopThinkingTimerTicker();
3921
+ thinkingTimerStore.starts.clear();
3922
+ thinkingTimerStore.durations.clear();
3923
+ thinkingTimerStore.labels.clear();
3924
+ thinkingTimerStore.cwd = ctx?.cwd;
3925
+ thinkingTimerStore.theme = ctx?.ui.theme;
3926
+ thinkingTimerStore.enabled = !!ctx?.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx?.cwd);
3927
+ };
3928
+
3929
+ const updateThinkingTimerEnabled = (ctx: ExtensionContext): boolean => {
3930
+ thinkingTimerStore.cwd = ctx.cwd;
3931
+ thinkingTimerStore.theme = ctx.ui.theme;
3932
+ const enabled = ctx.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx.cwd);
3933
+ if (thinkingTimerStore.enabled && !enabled) resetThinkingTimer(ctx);
3934
+ thinkingTimerStore.enabled = enabled;
3935
+ return enabled;
3936
+ };
3937
+
3938
+ const finalizeThinkingBlock = (key: string, endTimeMs = Date.now()) => {
3939
+ const start = thinkingTimerStore.starts.get(key);
3940
+ if (start === undefined) return;
3941
+ const duration = Math.max(0, endTimeMs - start);
3942
+ thinkingTimerStore.starts.delete(key);
3943
+ thinkingTimerStore.durations.set(key, duration);
3944
+ const label = thinkingTimerStore.labels.get(key);
3945
+ if (label) label.setText(thinkingTimerLabel(thinkingTimerStore.theme, duration, thinkingTimerStore.cwd));
3946
+ if (thinkingTimerStore.starts.size === 0) stopThinkingTimerTicker();
3947
+ };
3948
+
3949
+ const clearIdleCompactionTimer = () => {
3950
+ if (idleCompactionTimer) clearTimeout(idleCompactionTimer);
3951
+ idleCompactionTimer = undefined;
3952
+ };
3953
+
3954
+ const scheduleIdleCompaction = (ctx: ExtensionContext) => {
3955
+ clearIdleCompactionTimer();
3956
+ if (!settingBoolean("compaction.idleEnabled", false, ctx.cwd)) return;
3957
+ const reason = compactionTriggerReason(ctx);
3958
+ if (!reason) return;
3959
+ const delayMs = Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd))) * 1000;
3960
+ idleCompactionTimer = setTimeout(() => {
3961
+ idleCompactionTimer = undefined;
3962
+ if (!ctx.isIdle?.()) return;
3963
+ const latestReason = compactionTriggerReason(ctx);
3964
+ if (!latestReason) return;
3965
+ compactionNotify(ctx, `QOL idle compaction starting: ${latestReason}`, "info");
3966
+ ctx.compact?.({
3967
+ customInstructions: `QOL idle compaction triggered after inactivity because ${latestReason}. Preserve current task state, decisions, files, blockers, and next steps.`,
3968
+ onComplete: () => compactionNotify(ctx, "QOL idle compaction completed.", "info"),
3969
+ onError: (error: Error) => compactionNotify(ctx, `QOL idle compaction failed: ${stringifyError(error)}`, "error"),
3970
+ });
3971
+ }, delayMs);
3972
+ idleCompactionTimer.unref?.();
3973
+ };
3974
+
3975
+ const clearQuestionSubscribeTimer = () => {
3976
+ if (questionSubscribeTimer) clearInterval(questionSubscribeTimer);
3977
+ questionSubscribeTimer = undefined;
3978
+ };
3979
+
3980
+ const subscribeToQuestions = (ctx: ExtensionContext): boolean => {
3981
+ if (questionUnsubscribe) return true;
3982
+ const service = getQuestionService();
3983
+ if (!service) return false;
3984
+ questionUnsubscribe = service.subscribe((event: any) => {
3985
+ if (event?.action !== "opened") return;
3986
+ notifyQuestionOpened(ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
3987
+ });
3988
+ return true;
3989
+ };
3990
+
3991
+ const startQuestionSubscription = (ctx: ExtensionContext) => {
3992
+ if (subscribeToQuestions(ctx) || questionSubscribeTimer) return;
3993
+ let attempts = 0;
3994
+ questionSubscribeTimer = setInterval(() => {
3995
+ attempts += 1;
3996
+ if (subscribeToQuestions(ctx) || attempts >= 40) clearQuestionSubscribeTimer();
3997
+ }, 250);
3998
+ questionSubscribeTimer.unref?.();
3999
+ };
4000
+
4001
+ let pendingTaskCompleteNotification: string | undefined;
4002
+ let activeTui: TUI | undefined;
4003
+ let gitState: GitState | undefined;
4004
+ let refreshInFlight: Promise<void> | undefined;
4005
+ let sessionTitleTimer: ReturnType<typeof setInterval> | undefined;
4006
+ let tmuxPaneTitleTarget: string | undefined;
4007
+ let tmuxOriginalPaneTitle: string | undefined;
4008
+ let tmuxOriginalPaneBorderStatus: string | undefined;
4009
+ let tmuxOriginalPaneBorderFormat: string | undefined;
4010
+ let tmuxChangedPaneBorderStatus = false;
4011
+ let tmuxChangedPaneBorderFormat = false;
4012
+ let tmuxLastPaneTitle: string | undefined;
4013
+ let lastSessionTitle: string | undefined;
4014
+
4015
+ const requestRender = () => activeTui?.requestRender();
4016
+ const refreshStatusline = (ctx: ExtensionContext) => {
4017
+ if (refreshInFlight) return refreshInFlight;
4018
+ refreshInFlight = refreshGitState(pi, ctx)
4019
+ .then((next) => {
4020
+ gitState = next;
4021
+ requestRender();
4022
+ })
4023
+ .finally(() => {
4024
+ refreshInFlight = undefined;
4025
+ });
4026
+ return refreshInFlight;
4027
+ };
4028
+
4029
+ const syncSessionTitle = (ctx: ExtensionContext) => {
4030
+ const sessionTitle = normalizedSessionName(pi);
4031
+ if (sessionTitle !== lastSessionTitle) {
4032
+ lastSessionTitle = sessionTitle;
4033
+ requestRender();
4034
+ }
4035
+ ctx.ui.setStatus(SESSION_MANAGER_STATUS_KEY, undefined);
4036
+ const target = tmuxPaneTitleTarget;
4037
+ if (!target) return;
4038
+ const nextTitle = sessionTitle ? formatTmuxSessionTitle(sessionTitle) : tmuxOriginalPaneTitle;
4039
+ if (nextTitle === undefined || nextTitle === tmuxLastPaneTitle) return;
4040
+ tmuxLastPaneTitle = nextTitle;
4041
+ setTmuxPaneTitle(target, nextTitle);
4042
+ };
4043
+
4044
+ const installSessionTitle = (ctx: ExtensionContext) => {
4045
+ if (!settingBoolean("showSessionNameTitle", true, ctx.cwd)) return;
4046
+ const tmuxTarget = tmuxPaneTarget();
4047
+ if (tmuxTarget) {
4048
+ tmuxPaneTitleTarget = tmuxTarget;
4049
+ readTmuxPaneTitle(tmuxTarget, (title) => {
4050
+ if (tmuxPaneTitleTarget !== tmuxTarget) return;
4051
+ tmuxOriginalPaneTitle = title;
4052
+ syncSessionTitle(ctx);
4053
+ });
4054
+ readTmuxWindowOption(tmuxTarget, "pane-border-status", (value) => {
4055
+ if (tmuxPaneTitleTarget !== tmuxTarget) return;
4056
+ tmuxOriginalPaneBorderStatus = value;
4057
+ if (!value || value === "off") {
4058
+ tmuxChangedPaneBorderStatus = true;
4059
+ setTmuxWindowOption(tmuxTarget, "pane-border-status", "top");
4060
+ }
4061
+ });
4062
+ readTmuxWindowOption(tmuxTarget, "pane-border-format", (value) => {
4063
+ if (tmuxPaneTitleTarget !== tmuxTarget) return;
4064
+ tmuxOriginalPaneBorderFormat = value;
4065
+ if (value !== TMUX_SESSION_TITLE_BORDER_FORMAT) {
4066
+ tmuxChangedPaneBorderFormat = true;
4067
+ setTmuxWindowOption(tmuxTarget, "pane-border-format", TMUX_SESSION_TITLE_BORDER_FORMAT);
4068
+ }
4069
+ });
4070
+ return;
4071
+ }
4072
+ ctx.ui.setHeader((tui, theme) => {
4073
+ activeTui = tui;
4074
+ return {
4075
+ invalidate() {},
4076
+ render(width: number): string[] {
4077
+ return sessionNameHeader(width, pi, theme);
4078
+ },
4079
+ };
4080
+ });
4081
+ };
4082
+
4083
+ const installSessionTitleSync = (ctx: ExtensionContext) => {
4084
+ syncSessionTitle(ctx);
4085
+ sessionTitleTimer = setInterval(() => syncSessionTitle(ctx), SESSION_TITLE_SYNC_INTERVAL_MS);
4086
+ sessionTitleTimer.unref?.();
4087
+ };
4088
+
4089
+ const resetStatuslineUi = (ctx: ExtensionContext) => {
4090
+ if (sessionTitleTimer) clearInterval(sessionTitleTimer);
4091
+ sessionTitleTimer = undefined;
4092
+ if (tmuxPaneTitleTarget && tmuxOriginalPaneTitle !== undefined) setTmuxPaneTitle(tmuxPaneTitleTarget, tmuxOriginalPaneTitle);
4093
+ if (tmuxPaneTitleTarget && tmuxChangedPaneBorderStatus && tmuxOriginalPaneBorderStatus !== undefined) setTmuxWindowOption(tmuxPaneTitleTarget, "pane-border-status", tmuxOriginalPaneBorderStatus);
4094
+ if (tmuxPaneTitleTarget && tmuxChangedPaneBorderFormat && tmuxOriginalPaneBorderFormat !== undefined) setTmuxWindowOption(tmuxPaneTitleTarget, "pane-border-format", tmuxOriginalPaneBorderFormat);
4095
+ tmuxPaneTitleTarget = undefined;
4096
+ tmuxOriginalPaneTitle = undefined;
4097
+ tmuxOriginalPaneBorderStatus = undefined;
4098
+ tmuxOriginalPaneBorderFormat = undefined;
4099
+ tmuxChangedPaneBorderStatus = false;
4100
+ tmuxChangedPaneBorderFormat = false;
4101
+ tmuxLastPaneTitle = undefined;
4102
+ lastSessionTitle = undefined;
4103
+ ctx.ui.setStatus(SESSION_MANAGER_STATUS_KEY, undefined);
4104
+ ctx.ui.setWidget("statusline", undefined);
4105
+ ctx.ui.setHeader(undefined);
4106
+ ctx.ui.setFooter(undefined);
4107
+ activeTui = undefined;
4108
+ };
4109
+
4110
+ const maybeNotifyTaskCompletion = (_ctx: ExtensionContext, state: any) => {
4111
+ const stats = taskStats(state);
4112
+ if (!stats) return;
4113
+ const previous = lastTaskStats;
4114
+ lastTaskStats = stats;
4115
+ if (stats.total === 0 || stats.remaining !== 0 || stats.completed !== stats.total) {
4116
+ pendingTaskCompleteNotification = undefined;
4117
+ return;
4118
+ }
4119
+ if (previous && previous.total === stats.total && previous.remaining === 0 && previous.completed === stats.completed) return;
4120
+ if (previous && previous.remaining <= 0) return;
4121
+ pendingTaskCompleteNotification = `Task list complete: ${stats.completed}/${stats.total} done.`;
4122
+ };
4123
+
4124
+ let currentCtx: ExtensionContext | undefined;
4125
+ const notificationService: QolNotificationService = {
4126
+ notifyQuestionOpened(ctx, event) {
4127
+ notifyQuestionOpened(ctx ?? currentCtx, event, "question");
4128
+ return true;
4129
+ },
4130
+ };
4131
+ (globalThis as unknown as Record<PropertyKey, unknown>)[QOL_NOTIFICATION_SERVICE_SYMBOL] = notificationService;
4132
+
4133
+ pi.events.on(QUESTION_OPENED_EVENT, (data: unknown) => {
4134
+ if (!data || typeof data !== "object") return;
4135
+ const event = data as QuestionOpenedEventLike;
4136
+ notifyQuestionOpened(currentCtx, event, "question");
4137
+ });
4138
+
4139
+ pi.on("session_start", (event, ctx) => {
4140
+ currentCtx = ctx;
4141
+ latestSystemPromptOptions = undefined;
4142
+ resetAutoRename();
4143
+ resetThinkingTimer(ctx);
4144
+ void consumePendingSessionSearchContext(pi, ctx, event.reason);
4145
+ installAutocompleteHintStyling(ctx);
4146
+ installPendingQueueThemePatch(ctx);
4147
+ if (ctx.hasUI) {
4148
+ ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
4149
+ gitState = makeFallbackGitState(ctx.cwd);
4150
+ void refreshStatusline(ctx);
4151
+ installSessionTitle(ctx);
4152
+ installSessionTitleSync(ctx);
4153
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
4154
+ activeTui = tui;
4155
+ return settingBoolean("compactPrompt", true, ctx.cwd)
4156
+ ? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
4157
+ : new QolEditor(tui, theme, keybindings, ctx);
4158
+ });
4159
+ const statusWidgetTimer = setTimeout(() => {
4160
+ ctx.ui.setWidget("statusline", (tui, theme) => {
4161
+ activeTui = tui;
4162
+ return {
4163
+ invalidate() {},
4164
+ render(width: number): string[] {
4165
+ return [renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme)];
4166
+ },
4167
+ };
4168
+ });
4169
+ }, 0);
4170
+ statusWidgetTimer.unref?.();
4171
+ if (settingBoolean("replaceFooter", true, ctx.cwd)) {
4172
+ ctx.ui.setFooter((tui, _theme, footerData) => {
4173
+ activeTui = tui;
4174
+ const unsubscribe = footerData.onBranchChange(() => {
4175
+ void refreshStatusline(ctx);
4176
+ requestRender();
4177
+ });
4178
+ return { dispose: unsubscribe, invalidate() {}, render: () => [] };
4179
+ });
4180
+ }
4181
+ }
4182
+ startQuestionSubscription(ctx);
4183
+ void attemptAutoRename(ctx);
4184
+ if (settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
4185
+ if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
4186
+ sessionSearchWarmupTimer = setTimeout(() => {
4187
+ sessionSearchWarmupTimer = undefined;
4188
+ void refreshQolSessionSearchCache(ctx, { quiet: true }).catch(() => undefined);
4189
+ }, 500);
4190
+ sessionSearchWarmupTimer.unref?.();
4191
+ }
4192
+ });
4193
+
4194
+ pi.on("before_agent_start", (event: any) => {
4195
+ latestSystemPromptOptions = event?.systemPromptOptions;
4196
+ });
4197
+
4198
+ pi.on("session_shutdown", (_event, ctx) => {
4199
+ resetAutoRename();
4200
+ clearIdleCompactionTimer();
4201
+ clearQuestionSubscribeTimer();
4202
+ if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
4203
+ sessionSearchWarmupTimer = undefined;
4204
+ resetThinkingTimer(undefined);
4205
+ clearTmuxWindowMark();
4206
+ questionUnsubscribe?.();
4207
+ questionUnsubscribe = undefined;
4208
+ currentCtx = undefined;
4209
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
4210
+ if (host[QOL_NOTIFICATION_SERVICE_SYMBOL] === notificationService) delete host[QOL_NOTIFICATION_SERVICE_SYMBOL];
4211
+ ctx.ui.setStatus(STATUS_KEY, undefined);
4212
+ ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
4213
+ restorePendingQueueThemePatch(ctx);
4214
+ resetStatuslineUi(ctx);
4215
+ ctx.ui.setEditorComponent(undefined);
4216
+ });
4217
+
4218
+ pi.on("model_select", (_event, ctx) => {
4219
+ if (!ctx.hasUI) return;
4220
+ void refreshStatusline(ctx);
4221
+ requestRender();
4222
+ });
4223
+ pi.on("thinking_level_select", (_event, ctx) => {
4224
+ if (ctx.hasUI) requestRender();
4225
+ });
4226
+ pi.on("agent_start", (_event, ctx) => {
4227
+ clearIdleCompactionTimer();
4228
+ clearTmuxWindowMark();
4229
+ if (ctx.hasUI) {
4230
+ void refreshStatusline(ctx);
4231
+ requestRender();
4232
+ }
4233
+ });
4234
+ pi.on("message_update", (event, ctx) => {
4235
+ if (ctx.hasUI) requestRender();
4236
+ if (!updateThinkingTimerEnabled(ctx)) return;
4237
+ const streamEvent = event.assistantMessageEvent as any;
4238
+ if (!streamEvent || typeof streamEvent.type !== "string") return;
4239
+ if (streamEvent.type === "thinking_start" || streamEvent.type === "thinking_delta") {
4240
+ const partial = streamEvent.partial;
4241
+ if (!partial || typeof partial.timestamp !== "number" || typeof streamEvent.contentIndex !== "number") return;
4242
+ const key = thinkingTimerKey(partial.timestamp, streamEvent.contentIndex);
4243
+ if (!thinkingTimerStore.starts.has(key) && !thinkingTimerStore.durations.has(key)) thinkingTimerStore.starts.set(key, Date.now());
4244
+ startThinkingTimerTicker();
4245
+ tickThinkingTimer();
4246
+ return;
4247
+ }
4248
+ if (streamEvent.type === "thinking_end") {
4249
+ const partial = streamEvent.partial;
4250
+ if (!partial || typeof partial.timestamp !== "number" || typeof streamEvent.contentIndex !== "number") return;
4251
+ finalizeThinkingBlock(thinkingTimerKey(partial.timestamp, streamEvent.contentIndex));
4252
+ }
4253
+ });
4254
+ pi.on("message_end", (event, ctx) => {
4255
+ if (!updateThinkingTimerEnabled(ctx)) return;
4256
+ const message = event.message as any;
4257
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
4258
+ for (let i = 0; i < message.content.length; i++) {
4259
+ if (message.content[i]?.type !== "thinking") continue;
4260
+ finalizeThinkingBlock(thinkingTimerKey(message.timestamp, i));
4261
+ }
4262
+ });
4263
+ pi.on("agent_end", (event, ctx) => {
4264
+ if (ctx.hasUI) {
4265
+ void refreshStatusline(ctx);
4266
+ requestRender();
4267
+ }
4268
+ scheduleIdleCompaction(ctx);
4269
+ void attemptAutoRename(ctx);
4270
+ const text = lastAssistantTextFromAgentEnd(event, ctx);
4271
+ const critical = criticalInfo(text);
4272
+ if (critical) {
4273
+ pendingTaskCompleteNotification = undefined;
4274
+ sendQolNotification(ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
4275
+ return;
4276
+ }
4277
+ if (pendingTaskCompleteNotification) {
4278
+ const body = pendingTaskCompleteNotification;
4279
+ pendingTaskCompleteNotification = undefined;
4280
+ sendQolNotification(ctx, "task-complete", body, "info", "task-complete");
4281
+ return;
4282
+ }
4283
+ if (ctx.hasPendingMessages?.()) return;
4284
+ if (needsDirection(text)) {
4285
+ sendQolNotification(ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
4286
+ return;
4287
+ }
4288
+ sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
4289
+ });
4290
+ pi.on("session_compact", (_event, ctx) => {
4291
+ if (!ctx.hasUI) return;
4292
+ void refreshStatusline(ctx);
4293
+ requestRender();
4294
+ });
4295
+ pi.on("session_before_compact", (event, ctx) => handleQolCompaction(event, ctx));
4296
+ pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
4297
+ pi.on("tool_call", async (event: any, ctx) => {
4298
+ if (event?.toolName === "question") {
4299
+ notifyQuestionOpened(ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
4300
+ return undefined;
4301
+ }
4302
+ if (!settingBoolean("permissionGate.enabled", false, ctx.cwd)) return undefined;
4303
+ if (event?.toolName !== "bash") return undefined;
4304
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
4305
+ if (!command) return undefined;
4306
+ const matched = permissionGateMatch(command, ctx.cwd);
4307
+ if (!matched) return undefined;
4308
+ if (!ctx.hasUI) return { block: true, reason: `Command matched permission gate (${matched}) and no UI is available for confirmation` };
4309
+ const choice = await ctx.ui.select(permissionGatePrompt(matched, command, ctx.cwd), ["Allow once", "Block"]);
4310
+ if (choice !== "Allow once") return { block: true, reason: `Blocked by QOL permission gate (${matched})` };
4311
+ return undefined;
4312
+ });
4313
+ pi.on("tool_result", (event: any, ctx) => {
4314
+ if (event?.toolName === "tasks_write") maybeNotifyTaskCompletion(ctx, event.details?.state);
4315
+ });
4316
+
4317
+ pi.on("input", async (event) => {
4318
+ clearTmuxWindowMark();
4319
+ if (event.source === "extension") return { action: "continue" };
4320
+ const text = event.text ?? "";
4321
+ const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
4322
+ if (paths.length === 0) return { action: "continue" };
4323
+ const images = paths.map(imageContentForPath).filter(Boolean);
4324
+ if (images.length === 0) return { action: "continue" };
4325
+ return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
4326
+ });
4327
+
4328
+ if (settingBoolean("enableSessionNameCommand", true)) {
4329
+ pi.registerCommand("rename", {
4330
+ description: "Current session friendly-name editor.",
4331
+ handler: async (args, ctx) => {
4332
+ const name = args.trim();
4333
+ if (name) {
4334
+ pi.setSessionName(name);
4335
+ ctx.ui.notify(`Session named: ${name}`, "info");
4336
+ return;
4337
+ }
4338
+
4339
+ const current = pi.getSessionName();
4340
+ ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
4341
+ },
4342
+ });
4343
+ }
4344
+
4345
+ if (settingBoolean("enableHandoffCommand", true)) {
4346
+ pi.registerCommand("handoff", {
4347
+ description: "Focused context handoff to a new session.",
4348
+ handler: async (args, ctx) => runHandoff(args, ctx),
4349
+ });
4350
+ }
4351
+
4352
+ if (settingBoolean("enableContextCommand", true)) {
4353
+ pi.registerMessageRenderer(CONTEXT_USAGE_MESSAGE_TYPE, renderQolContextUsageMessage);
4354
+ pi.registerCommand("context", {
4355
+ description: "Show context-window usage and estimated category breakdowns inline.",
4356
+ handler: async (_args, ctx) => {
4357
+ const details = buildQolContextUsageDetails(pi, ctx, latestSystemPromptOptions);
4358
+ if (!details) {
4359
+ ctx.ui.notify("Context usage info is not available yet.", "warning");
4360
+ return;
4361
+ }
4362
+ pi.sendMessage({ customType: CONTEXT_USAGE_MESSAGE_TYPE, content: "Context usage snapshot", details, display: true }, { deliverAs: "followUp", triggerTurn: false });
4363
+ },
4364
+ });
4365
+ }
4366
+
4367
+ if (settingBoolean("sessionSearch.enabled", true)) {
4368
+ pi.registerMessageRenderer(SESSION_SEARCH_CONTEXT_TYPE, renderSessionSearchContextMessage);
4369
+
4370
+ const handleSearchCommand = async (args: string, ctx: ExtensionCommandContext) => {
4371
+ const trimmed = args.trim();
4372
+ if (trimmed === "refresh") {
4373
+ try {
4374
+ const sessions = await refreshQolSessionSearchCache(ctx, { force: true });
4375
+ ctx.ui.notify(`Session search refreshed: ${sessions.length} session(s)`, "info");
4376
+ } catch (error) {
4377
+ ctx.ui.notify(`Session search refresh failed: ${stringifyError(error)}`, "error");
4378
+ }
4379
+ return;
4380
+ }
4381
+ await openQolSessionSearch(pi, ctx, trimmed);
4382
+ };
4383
+
4384
+ pi.registerCommand("search", {
4385
+ description: "Previous-session search and context import.",
4386
+ getArgumentCompletions: getSessionSearchArgumentCompletions,
4387
+ handler: handleSearchCommand,
4388
+ });
4389
+ pi.registerCommand("search:refresh", {
4390
+ description: "Refresh the session search index",
4391
+ handler: async (_args, ctx) => handleSearchCommand("refresh", ctx),
4392
+ });
4393
+ const shortcut = sessionSearchShortcut();
4394
+ if (shortcut) {
4395
+ pi.registerShortcut(shortcut, {
4396
+ description: "Search previous sessions",
4397
+ handler: async (ctx) => openQolSessionSearch(pi, ctx as ExtensionContext),
4398
+ });
4399
+ }
4400
+ }
4401
+
4402
+ const tryOpenExtensionManagerSettings = async (ctx: ExtensionCommandContext): Promise<boolean> => {
4403
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
4404
+ const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
4405
+ if (typeof openQuickSettings !== "function") return false;
4406
+ try {
4407
+ await (openQuickSettings as (ctx: ExtensionCommandContext, hint?: string) => Promise<void>)(ctx, "pi-qol");
4408
+ return true;
4409
+ } catch {
4410
+ return false;
4411
+ }
4412
+ };
4413
+
4414
+ const dispatchQol = async (sub: string, rest: string, ctx: ExtensionCommandContext) => {
4415
+ const restLower = rest.toLowerCase();
4416
+ if (sub === "status") {
4417
+ if (await tryOpenExtensionManagerSettings(ctx)) return;
4418
+ ctx.ui.notify(statusMessage(ctx), "info");
4419
+ return;
4420
+ }
4421
+ if (sub === "rename") {
4422
+ if (!restLower) {
4423
+ await attemptAutoRename(ctx, { force: true, notify: true });
4424
+ return;
4425
+ }
4426
+ if (restLower === "full") {
4427
+ await attemptAutoRename(ctx, { force: true, fullConversation: true, notify: true });
4428
+ return;
4429
+ }
4430
+ ctx.ui.notify("Unknown /qol rename mode. Try /qol:rename or /qol:rename:full.", "warning");
4431
+ return;
4432
+ }
4433
+ if (sub === "notify-test") {
4434
+ sendQolNotification(ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
4435
+ ctx.ui.notify("Sent QOL notification test.", "info");
4436
+ return;
4437
+ }
4438
+ ctx.ui.notify("Unknown /qol action. Try /qol, /qol:rename, /qol:rename:full, or /qol notify-test.", "warning");
4439
+ };
4440
+
4441
+ pi.registerCommand("qol", {
4442
+ description: "QOL helpers and settings.",
4443
+ getArgumentCompletions: getQolArgumentCompletions,
4444
+ handler: async (args, ctx) => {
4445
+ const trimmed = args.trim();
4446
+ const firstSpace = trimmed.search(/\s/);
4447
+ const sub = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase() || "status";
4448
+ const rest = firstSpace < 0 ? "" : trimmed.slice(firstSpace + 1).trim();
4449
+ await dispatchQol(sub, rest, ctx);
4450
+ },
4451
+ });
4452
+ pi.registerCommand("qol:rename", {
4453
+ description: "Generate a session name from the first user message",
4454
+ handler: async (_args, ctx) => dispatchQol("rename", "", ctx),
4455
+ });
4456
+ pi.registerCommand("qol:rename:full", {
4457
+ description: "Generate a session name from the full conversation",
4458
+ handler: async (_args, ctx) => dispatchQol("rename", "full", ctx),
4459
+ });
4460
+ }