@vanillagreen/pi-qol 1.1.1 → 1.3.0

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