@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.
@@ -0,0 +1,107 @@
1
+ export const INSTALL_SYMBOL = Symbol.for("vstack.pi-qol.installed");
2
+ export const CONFIG_ID = "@vanillagreen/pi-qol";
3
+
4
+ export const STATUS_KEY = "qol-attachments";
5
+ export const SESSION_SEARCH_STATUS_KEY = "qol-session-search";
6
+ export const SESSION_SEARCH_CONTEXT_TYPE = "qol-session-context";
7
+ export const CONTEXT_USAGE_MESSAGE_TYPE = "qol-context-usage";
8
+ export const SESSION_MANAGER_STATUS_KEY = "session-manager";
9
+
10
+ export const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".heic", ".heif"]);
11
+ export const IMAGE_PATH_PATTERN = /(^|[\s(\[{<"'`])(@?(?:~|\.\.?|\/)[^\s)\]}>"'`]+?\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))(?=$|[\s)\]}>"'`,.;:!?])/gi;
12
+
13
+ export const QUESTION_SERVICE_SYMBOL = Symbol.for("vstack.pi-questions.service");
14
+ export const QOL_NOTIFICATION_SERVICE_SYMBOL = Symbol.for("vstack.pi-qol.notification-service");
15
+ export const VSTACK_MODAL_LOCK_SYMBOL = Symbol.for("vstack.pi.modal-lock");
16
+ export const CAVEMAN_BRIDGE_SYMBOL = Symbol.for("vstack.pi.caveman");
17
+ export const PI_AGENTS_STATUSLINE_SYMBOL = Symbol.for("vstack.pi-agents-tmux.statusline");
18
+
19
+ export const CAVEMAN_ICON_ACTIVE = "\uee9a";
20
+ export const CAVEMAN_ICON_INACTIVE = "\u{f19e0}";
21
+
22
+ export const THINKING_TIMER_STORE_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.store");
23
+ export const THINKING_TIMER_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.patch");
24
+ export const SESSION_SEARCH_PENDING_SYMBOL = Symbol.for("vstack.pi-qol.session-search.pending-context");
25
+ export const PENDING_QUEUE_THEME_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.pending-queue.theme-patch");
26
+ export const STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.status-text-alignment-patch");
27
+
28
+ export const QUESTION_OPENED_EVENT = "vstack:pi-questions:opened";
29
+ export const QUESTION_NOTIFY_DEDUP_MS = 2000;
30
+
31
+ export const DEFAULT_NOTIFICATION_TITLE = "Pi";
32
+ export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
33
+ export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
34
+ export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
35
+
36
+ export const DEFAULT_COMPACTION_MODEL = "google/gemini-2.5-flash";
37
+ export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
38
+ export const DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS = 200000;
39
+ export const DEFAULT_IDLE_COMPACTION_SECONDS = 300;
40
+
41
+ export const DEFAULT_PERMISSION_GATE_COMMANDS = "rm -Rf";
42
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_LINES = 12;
43
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
44
+ export const DEFAULT_PERMISSION_GATE_PREVIEW_LINE_WIDTH = 120;
45
+
46
+ export const DEFAULT_SESSION_SEARCH_LIMIT = 40;
47
+ export const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
48
+ export const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
49
+ export const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
50
+ export const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
51
+ export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
52
+ export const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
53
+
54
+ export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
55
+ export const DEFAULT_AUTO_RENAME_FALLBACK_MODEL = "current";
56
+ export const DEFAULT_AUTO_RENAME_INPUT_CHARS = 2000;
57
+ export const DEFAULT_AUTO_RENAME_NAME_CHARS = 80;
58
+ export const DEFAULT_AUTO_RENAME_MAX_TOKENS = 96;
59
+ export const DEFAULT_AUTO_RENAME_TIMEOUT_MS = 12_000;
60
+ export const DEFAULT_INPUT_BOTTOM_PADDING_LINES = 0;
61
+
62
+ export const SESSION_TITLE_SYNC_INTERVAL_MS = 1000;
63
+
64
+ // tmux trims plain leading/trailing spaces from pane-border-format output, so
65
+ // use NBSP padding. Terminals render it as a space, but tmux keeps it visible.
66
+ export const TMUX_SESSION_TITLE_PAD = "\u00a0";
67
+ export const TMUX_SESSION_TITLE_BORDER_FORMAT = `${TMUX_SESSION_TITLE_PAD}#{pane_title}${TMUX_SESSION_TITLE_PAD}`;
68
+
69
+ export const THINKING_LABEL_DEFAULT = "\ue28c ";
70
+
71
+ export 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.";
72
+
73
+ export 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.";
74
+
75
+ export const DEFAULT_AUTO_RENAME_PROMPT = `Generate a short, descriptive title for this Pi coding-agent session based on the first user message.
76
+
77
+ Rules:
78
+ - Use 2-6 words
79
+ - Use Title Case
80
+ - Be specific about the user's task or topic
81
+ - Do not mention Pi unless Pi itself is the task
82
+ - Return only the title
83
+
84
+ First user message:
85
+ {{message}}`;
86
+
87
+ export 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:
88
+
89
+ 1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
90
+ 2. Lists any relevant files that were discussed or modified
91
+ 3. Clearly states the next task based on the user's goal
92
+ 4. Is self-contained - the new thread should be able to proceed without the old conversation
93
+
94
+ 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.
95
+
96
+ Example output format:
97
+ ## Context
98
+ We've been working on X. Key decisions:
99
+ - Decision 1
100
+ - Decision 2
101
+
102
+ Files involved:
103
+ - path/to/file1.ts
104
+ - path/to/file2.ts
105
+
106
+ ## Task
107
+ [Clear description of what to do next based on user's goal]`;
@@ -0,0 +1,492 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+
6
+ type QolContextColor = "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted" | "text";
7
+
8
+ interface QolContextDetailItem {
9
+ label: string;
10
+ tokens: number;
11
+ description?: string;
12
+ group?: string;
13
+ }
14
+
15
+ interface QolContextCategory {
16
+ key: string;
17
+ label: string;
18
+ rawTokens: number;
19
+ tokens: number;
20
+ color: QolContextColor;
21
+ icon: string;
22
+ }
23
+
24
+ export interface QolContextUsageMessageDetails {
25
+ usage: {
26
+ contextWindow?: number;
27
+ percent?: number;
28
+ tokens: number;
29
+ };
30
+ model: {
31
+ contextWindow?: number;
32
+ id: string;
33
+ label: string;
34
+ provider: string;
35
+ thinking?: string;
36
+ };
37
+ categories: QolContextCategory[];
38
+ freeTokens?: number;
39
+ contextFiles: QolContextDetailItem[];
40
+ skills: QolContextDetailItem[];
41
+ customAgents: QolContextDetailItem[];
42
+ builtinTools: QolContextDetailItem[];
43
+ extensionTools: QolContextDetailItem[];
44
+ mcpTools: QolContextDetailItem[];
45
+ messageStats: {
46
+ assistant: number;
47
+ bash: number;
48
+ branchEntries: number;
49
+ compact: number;
50
+ contextMessages: number;
51
+ custom: number;
52
+ toolResult: number;
53
+ user: number;
54
+ };
55
+ compactSummaries: QolContextDetailItem[];
56
+ note?: string;
57
+ }
58
+
59
+ function qcuSafeString(value: unknown): string {
60
+ if (typeof value === "string") return value;
61
+ if (value == null) return "";
62
+ const seen = new WeakSet<object>();
63
+ try {
64
+ return JSON.stringify(value, (_key, current) => {
65
+ if (typeof current === "function") return undefined;
66
+ if (typeof current === "object" && current !== null) {
67
+ if (seen.has(current)) return "[Circular]";
68
+ seen.add(current);
69
+ }
70
+ return current;
71
+ }) ?? "";
72
+ } catch {
73
+ return String(value);
74
+ }
75
+ }
76
+
77
+ function qcuEstimateTokens(value: unknown): number {
78
+ const text = qcuSafeString(value);
79
+ if (!text) return 0;
80
+ return Math.max(1, Math.ceil(text.length / 4));
81
+ }
82
+
83
+ function qcuFormatTokens(value: number | undefined): string {
84
+ const n = Math.max(0, Math.round(Number(value) || 0));
85
+ const trim = (input: number) => input.toFixed(input >= 10 ? 0 : 1).replace(/\.0$/, "");
86
+ if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`;
87
+ if (n >= 1_000) return `${trim(n / 1_000)}K`;
88
+ return n.toLocaleString();
89
+ }
90
+
91
+ function qcuPercent(tokens: number, contextWindow?: number): string {
92
+ if (!contextWindow || contextWindow <= 0) return "--";
93
+ return `${((tokens / contextWindow) * 100).toFixed(1)}%`;
94
+ }
95
+
96
+ function qcuPadAnsi(text: string, width: number): string {
97
+ const clipped = truncateToWidth(text, Math.max(0, width), "");
98
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
99
+ }
100
+
101
+ function qcuOneLine(text: string, maxWidth: number): string {
102
+ return truncateToWidth(text.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim(), maxWidth, "…");
103
+ }
104
+
105
+ function qcuDetailLabel(item: unknown, fallback: string): string {
106
+ if (typeof item === "string") return qcuOneLine(item, 90) || fallback;
107
+ if (!item || typeof item !== "object") return fallback;
108
+ const obj = item as Record<string, unknown>;
109
+ const raw = obj.name ?? obj.title ?? obj.label ?? obj.id ?? obj.path ?? obj.file ?? obj.filePath ?? obj.sourcePath ?? obj.description ?? fallback;
110
+ const label = typeof raw === "string" ? raw : fallback;
111
+ return qcuOneLine(label, 90) || fallback;
112
+ }
113
+
114
+ function qcuDetailText(item: unknown): string {
115
+ if (typeof item === "string") return item;
116
+ if (!item || typeof item !== "object") return qcuSafeString(item);
117
+ const obj = item as Record<string, unknown>;
118
+ const direct = obj.content ?? obj.text ?? obj.markdown ?? obj.body ?? obj.instructions ?? obj.description ?? obj.prompt;
119
+ if (typeof direct === "string") return direct;
120
+ const pathValue = obj.path ?? obj.file ?? obj.filePath ?? obj.sourcePath;
121
+ if (typeof pathValue === "string" && isAbsolute(pathValue) && existsSync(pathValue)) {
122
+ try {
123
+ return readFileSync(pathValue, "utf8");
124
+ } catch {
125
+ // Fall through to a safe structural estimate.
126
+ }
127
+ }
128
+ return qcuSafeString(item);
129
+ }
130
+
131
+ function qcuDetailsFromItems(items: unknown[], fallbackGroup?: string): QolContextDetailItem[] {
132
+ return items
133
+ .map((item, index) => {
134
+ const obj = item && typeof item === "object" ? item as Record<string, unknown> : undefined;
135
+ const sourceInfo = obj?.sourceInfo && typeof obj.sourceInfo === "object" ? obj.sourceInfo as Record<string, unknown> : undefined;
136
+ const group = typeof obj?.scope === "string" ? obj.scope : typeof sourceInfo?.scope === "string" ? sourceInfo.scope : typeof sourceInfo?.source === "string" ? sourceInfo.source : fallbackGroup;
137
+ const description = typeof obj?.description === "string" ? obj.description : undefined;
138
+ return {
139
+ description: description ? qcuOneLine(description, 120) : undefined,
140
+ group,
141
+ label: qcuDetailLabel(item, `item-${index + 1}`),
142
+ tokens: qcuEstimateTokens(qcuDetailText(item)),
143
+ };
144
+ })
145
+ .filter((item) => item.tokens > 0 || item.label.length > 0);
146
+ }
147
+
148
+ function qcuPromptOptionArray(options: unknown, keys: string[]): unknown[] {
149
+ if (!options || typeof options !== "object") return [];
150
+ const obj = options as Record<string, unknown>;
151
+ for (const key of keys) {
152
+ const value = obj[key];
153
+ if (Array.isArray(value)) return value;
154
+ }
155
+ return [];
156
+ }
157
+
158
+ function qcuUniqDetails(items: QolContextDetailItem[]): QolContextDetailItem[] {
159
+ const byLabel = new Map<string, QolContextDetailItem>();
160
+ for (const item of items) {
161
+ const key = `${item.group ?? ""}:${item.label}`;
162
+ const existing = byLabel.get(key);
163
+ if (!existing || item.tokens > existing.tokens) byLabel.set(key, item);
164
+ }
165
+ return Array.from(byLabel.values()).sort((a, b) => b.tokens - a.tokens || a.label.localeCompare(b.label));
166
+ }
167
+
168
+ function qcuTokenSum(items: QolContextDetailItem[]): number {
169
+ return items.reduce((sum, item) => sum + Math.max(0, item.tokens), 0);
170
+ }
171
+
172
+ function qcuMessageTokens(message: any): number {
173
+ if (!message || typeof message !== "object") return 0;
174
+ if (message.role === "bashExecution" && message.excludeFromContext === true) return 0;
175
+ if (message.role === "compactionSummary") return qcuEstimateTokens(message.summary ?? "");
176
+ if (message.role === "branchSummary") return qcuEstimateTokens(message.summary ?? "");
177
+ if (message.role === "custom") return qcuEstimateTokens(message.content ?? "");
178
+ const content = message.content;
179
+ if (typeof content === "string") return qcuEstimateTokens(content);
180
+ if (!Array.isArray(content)) return qcuEstimateTokens(content);
181
+ return content.reduce((sum: number, part: any) => {
182
+ if (part?.type === "text") return sum + qcuEstimateTokens(part.text ?? "");
183
+ if (part?.type === "thinking") return sum + qcuEstimateTokens(part.thinking ?? "");
184
+ if (part?.type === "toolCall") return sum + qcuEstimateTokens(part);
185
+ if (part?.type === "image") return sum + 85;
186
+ return sum + qcuEstimateTokens(part);
187
+ }, 0);
188
+ }
189
+
190
+ function qcuBuildMessageStats(ctx: ExtensionCommandContext): { rawMessageTokens: number; rawCompactTokens: number; stats: QolContextUsageMessageDetails["messageStats"]; compactSummaries: QolContextDetailItem[] } {
191
+ const sm = ctx.sessionManager as any;
192
+ const branch = Array.isArray(sm.getBranch?.()) ? sm.getBranch() : [];
193
+ const built = typeof sm.buildSessionContext === "function" ? sm.buildSessionContext() : undefined;
194
+ const messages = Array.isArray(built?.messages) ? built.messages : branch.filter((entry: any) => entry?.type === "message").map((entry: any) => entry.message);
195
+ const stats = { assistant: 0, bash: 0, branchEntries: branch.length, compact: 0, contextMessages: messages.length, custom: 0, toolResult: 0, user: 0 };
196
+ let rawMessageTokens = 0;
197
+ let rawCompactTokens = 0;
198
+ for (const message of messages) {
199
+ const tokens = qcuMessageTokens(message);
200
+ switch (message?.role) {
201
+ case "user": stats.user += 1; rawMessageTokens += tokens; break;
202
+ case "assistant": stats.assistant += 1; rawMessageTokens += tokens; break;
203
+ case "toolResult": stats.toolResult += 1; rawMessageTokens += tokens; break;
204
+ case "bashExecution": stats.bash += 1; rawMessageTokens += tokens; break;
205
+ case "custom": stats.custom += 1; rawMessageTokens += tokens; break;
206
+ case "compactionSummary":
207
+ case "branchSummary":
208
+ stats.compact += 1;
209
+ rawCompactTokens += tokens;
210
+ break;
211
+ default:
212
+ rawMessageTokens += tokens;
213
+ }
214
+ }
215
+ const compactSummaries = branch
216
+ .filter((entry: any) => entry?.type === "compaction" || entry?.type === "branch_summary")
217
+ .map((entry: any) => ({
218
+ group: entry.type === "compaction" ? "compaction" : "branch",
219
+ label: entry.type === "compaction" ? `Compaction${entry.tokensBefore ? ` (${qcuFormatTokens(Number(entry.tokensBefore))} before)` : ""}` : "Branch summary",
220
+ tokens: qcuEstimateTokens(entry.summary ?? ""),
221
+ }))
222
+ .reverse();
223
+ return { compactSummaries, rawCompactTokens, rawMessageTokens, stats };
224
+ }
225
+
226
+ function qcuToolDetails(pi: ExtensionAPI): {
227
+ builtinTools: QolContextDetailItem[];
228
+ extensionTools: QolContextDetailItem[];
229
+ mcpTools: QolContextDetailItem[];
230
+ rawBuiltinToolTokens: number;
231
+ rawExtensionToolTokens: number;
232
+ rawMcpToolTokens: number;
233
+ } {
234
+ const api = pi as any;
235
+ const activeNames = new Set<string>(Array.isArray(api.getActiveTools?.()) ? api.getActiveTools() : []);
236
+ const allTools = Array.isArray(api.getAllTools?.()) ? api.getAllTools() : [];
237
+ const activeTools = allTools.filter((tool: any) => typeof tool?.name === "string" && (activeNames.size === 0 || activeNames.has(tool.name)));
238
+ const details = activeTools.map((tool: any) => {
239
+ const sourceInfo = tool.sourceInfo && typeof tool.sourceInfo === "object" ? tool.sourceInfo : undefined;
240
+ const source = typeof sourceInfo?.source === "string" ? sourceInfo.source : undefined;
241
+ return {
242
+ description: typeof tool.description === "string" ? qcuOneLine(tool.description, 120) : undefined,
243
+ group: source,
244
+ label: tool.name,
245
+ tokens: qcuEstimateTokens({ description: tool.description, name: tool.name, parameters: tool.parameters, promptGuidelines: tool.promptGuidelines, promptSnippet: tool.promptSnippet }),
246
+ };
247
+ });
248
+ const isMcpTool = (tool: QolContextDetailItem) => /^mcp_{1,2}/i.test(tool.label) || /mcp/i.test(tool.group ?? "");
249
+ const mcpTools = details.filter(isMcpTool);
250
+ const builtinTools = details.filter((tool) => !isMcpTool(tool) && (tool.group === "builtin" || tool.group === "sdk"));
251
+ const extensionTools = details.filter((tool) => !isMcpTool(tool) && tool.group !== "builtin" && tool.group !== "sdk");
252
+ return {
253
+ builtinTools: qcuUniqDetails(builtinTools),
254
+ extensionTools: qcuUniqDetails(extensionTools),
255
+ mcpTools: qcuUniqDetails(mcpTools),
256
+ rawBuiltinToolTokens: qcuTokenSum(builtinTools),
257
+ rawExtensionToolTokens: qcuTokenSum(extensionTools),
258
+ rawMcpToolTokens: qcuTokenSum(mcpTools),
259
+ };
260
+ }
261
+
262
+ function qcuSkillDetails(pi: ExtensionAPI, promptOptions: unknown): QolContextDetailItem[] {
263
+ const fromOptions = qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["skills", "loadedSkills", "availableSkills"]), "loaded");
264
+ const commands = Array.isArray((pi as any).getCommands?.()) ? (pi as any).getCommands() : [];
265
+ const fromCommands = commands
266
+ .filter((command: any) => command?.source === "skill")
267
+ .map((command: any) => {
268
+ const sourceInfo = command.sourceInfo && typeof command.sourceInfo === "object" ? command.sourceInfo : undefined;
269
+ return {
270
+ description: typeof command.description === "string" ? qcuOneLine(command.description, 120) : undefined,
271
+ group: typeof sourceInfo?.scope === "string" ? sourceInfo.scope : typeof sourceInfo?.source === "string" ? sourceInfo.source : "skill",
272
+ label: String(command.name ?? "skill").replace(/^skill:/, ""),
273
+ tokens: qcuEstimateTokens(`${command.name ?? ""}\n${command.description ?? ""}`),
274
+ };
275
+ });
276
+ return qcuUniqDetails([...fromOptions, ...fromCommands]);
277
+ }
278
+
279
+ function qcuScaleCategories(rawCategories: Array<Omit<QolContextCategory, "tokens">>, totalTokens: number, contextWindow?: number): QolContextCategory[] {
280
+ const rawTotal = rawCategories.reduce((sum, category) => sum + Math.max(0, category.rawTokens), 0);
281
+ if (rawTotal <= 0) {
282
+ return [{ color: "accent", icon: "◉", key: "messages", label: "Messages", rawTokens: totalTokens, tokens: totalTokens }];
283
+ }
284
+ const ratio = totalTokens / rawTotal;
285
+ const categories = rawCategories
286
+ .map((category) => ({ ...category, tokens: Math.max(0, Math.round(category.rawTokens * ratio)) }))
287
+ .filter((category) => category.tokens > 0 || category.rawTokens > 0);
288
+ const scaledTotal = categories.reduce((sum, category) => sum + category.tokens, 0);
289
+ const delta = totalTokens - scaledTotal;
290
+ const messages = categories.find((category) => category.key === "messages") ?? categories[categories.length - 1];
291
+ if (messages && delta !== 0) messages.tokens = Math.max(0, messages.tokens + delta);
292
+ const used = categories.reduce((sum, category) => sum + category.tokens, 0);
293
+ if (contextWindow && used < totalTokens) {
294
+ const other = totalTokens - used;
295
+ if (other > 0) categories.push({ color: "dim", icon: "◉", key: "other", label: "Other", rawTokens: other, tokens: other });
296
+ }
297
+ return categories;
298
+ }
299
+
300
+ function qcuExtractProjectAgents(systemPrompt: string): { agents: QolContextDetailItem[]; tokens: number } {
301
+ const marker = systemPrompt.includes("## Project Agents") ? "## Project Agents" : "## Project Subagents";
302
+ const start = systemPrompt.indexOf(marker);
303
+ if (start < 0) return { agents: [], tokens: 0 };
304
+ const rest = systemPrompt.slice(start);
305
+ const next = rest.slice(marker.length).search(/\n##\s+/);
306
+ const section = next >= 0 ? rest.slice(0, marker.length + next) : rest;
307
+ const agents = section
308
+ .split(/\r?\n/)
309
+ .filter((line) => line.startsWith("- "))
310
+ .map((line) => {
311
+ const match = line.match(/^-\s*([^:]+):\s*(.*?)(?:\s*\(([^)]*)\))?$/);
312
+ return {
313
+ description: match?.[2] ? qcuOneLine(match[2], 120) : undefined,
314
+ group: match?.[3]?.includes("project") ? "project" : match?.[3]?.includes("user") ? "user" : "agent",
315
+ label: qcuOneLine(match?.[1] ?? line.replace(/^[-\s]+/, ""), 90),
316
+ tokens: qcuEstimateTokens(line),
317
+ };
318
+ });
319
+ return { agents: qcuUniqDetails(agents), tokens: qcuEstimateTokens(section) };
320
+ }
321
+
322
+ export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionCommandContext, promptOptions: unknown): QolContextUsageMessageDetails | undefined {
323
+ const usage = ctx.getContextUsage?.() as { contextWindow?: unknown; percent?: unknown; tokens?: unknown } | undefined;
324
+ const tokens = Number(usage?.tokens);
325
+ if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
326
+ const model = (ctx as any).model ?? {};
327
+ const contextWindow = Number(usage?.contextWindow ?? model.contextWindow);
328
+ const safeContextWindow = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined;
329
+ const percent = Number(usage?.percent);
330
+ const systemPrompt = ctx.getSystemPrompt?.() ?? "";
331
+ const contextFiles = qcuUniqDetails(qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["contextFiles", "agentsFiles", "memoryFiles"]), "context"));
332
+ const skills = qcuSkillDetails(pi, promptOptions);
333
+ const extractedAgents = qcuExtractProjectAgents(systemPrompt);
334
+ const customAgents = qcuUniqDetails([...qcuDetailsFromItems(qcuPromptOptionArray(promptOptions, ["customAgents", "agents", "agentSnippets"]), "agent"), ...extractedAgents.agents]);
335
+ const { builtinTools, extensionTools, mcpTools, rawBuiltinToolTokens, rawExtensionToolTokens, rawMcpToolTokens } = qcuToolDetails(pi);
336
+ const { compactSummaries, rawCompactTokens, rawMessageTokens, stats } = qcuBuildMessageStats(ctx);
337
+ const rawCompactSummaryTokens = rawCompactTokens > 0 ? rawCompactTokens : qcuTokenSum(compactSummaries);
338
+ const rawContextFiles = qcuTokenSum(contextFiles);
339
+ const rawSkills = qcuTokenSum(skills);
340
+ const rawCustomAgents = Math.max(qcuTokenSum(customAgents), extractedAgents.tokens);
341
+ const rawSystemPromptTotal = qcuEstimateTokens(systemPrompt);
342
+ const rawSystemPrompt = Math.max(0, rawSystemPromptTotal - rawContextFiles - rawSkills - rawCustomAgents);
343
+ const categories = qcuScaleCategories([
344
+ { color: "muted", icon: "◉", key: "system", label: "System prompt", rawTokens: rawSystemPrompt },
345
+ { color: "warning", icon: "◉", key: "builtinTools", label: "Built-in tools", rawTokens: rawBuiltinToolTokens },
346
+ { color: "error", icon: "◉", key: "extensionTools", label: "Extension tools", rawTokens: rawExtensionToolTokens },
347
+ { color: "warning", icon: "◉", key: "mcpTools", label: "MCP tools", rawTokens: rawMcpToolTokens },
348
+ { color: "success", icon: "◉", key: "agents", label: "Custom agents", rawTokens: rawCustomAgents },
349
+ { color: "error", icon: "◉", key: "contextFiles", label: "Context / memory files", rawTokens: rawContextFiles },
350
+ { color: "success", icon: "◉", key: "skills", label: "Skills", rawTokens: rawSkills },
351
+ { color: "accent", icon: "◉", key: "messages", label: "Messages", rawTokens: rawMessageTokens },
352
+ { color: "muted", icon: "◉", key: "compact", label: "Compact buffer", rawTokens: rawCompactSummaryTokens },
353
+ ], tokens, safeContextWindow);
354
+ const modelProvider = typeof model.provider === "string" ? model.provider : "unknown";
355
+ const modelId = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : "unknown";
356
+ const modelName = typeof model.name === "string" ? model.name : modelId;
357
+ const thinking = typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined;
358
+ return {
359
+ builtinTools,
360
+ categories,
361
+ compactSummaries,
362
+ contextFiles,
363
+ customAgents,
364
+ extensionTools,
365
+ freeTokens: safeContextWindow ? Math.max(0, safeContextWindow - tokens) : undefined,
366
+ mcpTools,
367
+ messageStats: stats,
368
+ model: {
369
+ contextWindow: safeContextWindow,
370
+ id: modelId,
371
+ label: modelName,
372
+ provider: modelProvider,
373
+ thinking: typeof thinking === "string" && thinking !== "off" ? thinking : undefined,
374
+ },
375
+ note: promptOptions ? undefined : "Context-file and skill breakdowns become more precise after the next agent turn.",
376
+ skills,
377
+ usage: {
378
+ contextWindow: safeContextWindow,
379
+ percent: Number.isFinite(percent) ? percent : safeContextWindow ? (tokens / safeContextWindow) * 100 : undefined,
380
+ tokens,
381
+ },
382
+ };
383
+ }
384
+
385
+ function qcuLimitDetails(items: QolContextDetailItem[], limit: number): QolContextDetailItem[] {
386
+ return items.slice(0, Math.max(0, limit));
387
+ }
388
+
389
+ function qcuBranch(theme: Theme, branch: "├" | "└" | "│"): string {
390
+ if (branch === "│") return theme.fg("muted", "│ ");
391
+ return theme.fg("muted", `${branch}─ `);
392
+ }
393
+
394
+ function qcuStem(theme: Theme, isLast: boolean): string {
395
+ return isLast ? theme.fg("borderMuted", " ") : qcuBranch(theme, "│");
396
+ }
397
+
398
+ function qcuRenderDetailSection(lines: string[], title: string, items: QolContextDetailItem[], theme: Theme, options: { empty?: string; limit?: number; showDescriptions?: boolean } = {}): void {
399
+ if (items.length === 0) {
400
+ if (options.empty) {
401
+ lines.push("");
402
+ lines.push(`${theme.fg("customMessageLabel", theme.bold(title))}`);
403
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("dim", options.empty)}`);
404
+ }
405
+ return;
406
+ }
407
+ const visible = qcuLimitDetails(items, options.limit ?? 10);
408
+ lines.push("");
409
+ lines.push(`${theme.fg("customMessageLabel", theme.bold(title))}`);
410
+ visible.forEach((item, index) => {
411
+ const isLast = index === visible.length - 1 && visible.length === items.length;
412
+ const branch = isLast ? "└" : "├";
413
+ const group = item.group ? theme.fg("dim", ` ${item.group}`) : "";
414
+ lines.push(`${qcuBranch(theme, branch)}${theme.fg("text", qcuOneLine(item.label, 72))}: ${theme.fg("accent", qcuFormatTokens(item.tokens))} tokens${group}`);
415
+ if (options.showDescriptions && item.description) lines.push(`${qcuStem(theme, isLast)}${theme.fg("dim", qcuOneLine(item.description, 100))}`);
416
+ });
417
+ if (items.length > visible.length) lines.push(`${qcuBranch(theme, "└")}${theme.fg("dim", `… ${items.length - visible.length} more`)}`);
418
+ }
419
+
420
+ export function renderQolContextUsageMessage(message: any, _options: any, theme: Theme): { render(width: number): string[]; invalidate(): void } {
421
+ const details = message?.details as QolContextUsageMessageDetails | undefined;
422
+ return {
423
+ invalidate() {},
424
+ render(width: number): string[] {
425
+ if (!details) return [theme.fg("warning", "Context usage details unavailable.")];
426
+ const safeWidth = Math.max(48, width);
427
+ const lines: string[] = [];
428
+ lines.push(`${theme.fg("accent", "› /context")}`);
429
+ lines.push(`└ ${theme.fg("customMessageLabel", theme.bold("Context Usage"))}`);
430
+ const gridCols = safeWidth >= 112 ? 28 : safeWidth >= 88 ? 22 : 16;
431
+ const gridRows = safeWidth >= 88 ? 7 : 6;
432
+ const totalBlocks = gridCols * gridRows;
433
+ const contextWindow = details.usage.contextWindow;
434
+ const gridDenominator = contextWindow ?? details.usage.tokens;
435
+ const blockCategories: QolContextCategory[] = [];
436
+ for (const category of details.categories) {
437
+ if (!gridDenominator || category.tokens <= 0) continue;
438
+ let count = Math.round((category.tokens / gridDenominator) * totalBlocks);
439
+ if (count === 0 && category.tokens > 0) count = 1;
440
+ for (let index = 0; index < count && blockCategories.length < totalBlocks; index += 1) blockCategories.push(category);
441
+ }
442
+ const gridLines: string[] = [];
443
+ for (let row = 0; row < gridRows; row += 1) {
444
+ let line = "";
445
+ for (let col = 0; col < gridCols; col += 1) {
446
+ const category = blockCategories[row * gridCols + col];
447
+ line += category ? theme.fg(category.color as any, category.icon) : theme.fg("borderMuted", "□");
448
+ if (col !== gridCols - 1) line += " ";
449
+ }
450
+ gridLines.push(line);
451
+ }
452
+ const modelBits = [`${details.model.provider}/${details.model.id}`];
453
+ if (details.model.thinking) modelBits.push(`thinking ${details.model.thinking}`);
454
+ const summaryLines = [
455
+ `${theme.fg("text", theme.bold(details.model.label))}${contextWindow ? theme.fg("muted", ` (${qcuFormatTokens(contextWindow)} context)`) : ""}`,
456
+ theme.fg("muted", modelBits.join(" · ")),
457
+ `${theme.fg("accent", qcuFormatTokens(details.usage.tokens))}${contextWindow ? `/${qcuFormatTokens(contextWindow)}` : ""} tokens${details.usage.percent != null ? ` (${details.usage.percent.toFixed(1)}%)` : ""}`,
458
+ "",
459
+ theme.fg("muted", theme.italic("Estimated usage by category")),
460
+ ...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)})`)}`),
461
+ ];
462
+ if (details.freeTokens != null) summaryLines.push(`${theme.fg("borderMuted", "□")} ${theme.fg("text", "Free space:")} ${qcuFormatTokens(details.freeTokens)} ${theme.fg("muted", `(${qcuPercent(details.freeTokens, contextWindow)})`)}`);
463
+ lines.push("");
464
+ if (safeWidth >= 82) {
465
+ const leftWidth = visibleWidth(gridLines[0] ?? "");
466
+ const maxRows = Math.max(gridLines.length, summaryLines.length);
467
+ for (let index = 0; index < maxRows; index += 1) {
468
+ lines.push(` ${qcuPadAnsi(gridLines[index] ?? "", leftWidth)} ${summaryLines[index] ?? ""}`.trimEnd());
469
+ }
470
+ } else {
471
+ lines.push(...gridLines.map((line) => ` ${line}`));
472
+ lines.push("");
473
+ lines.push(...summaryLines.map((line) => ` ${line}`));
474
+ }
475
+ if (details.note) lines.push("", theme.fg("dim", details.note));
476
+ const messageStats = details.messageStats;
477
+ lines.push("");
478
+ lines.push(`${theme.fg("customMessageLabel", theme.bold("Conversation"))}`);
479
+ lines.push(`${qcuBranch(theme, "├")}context messages: ${theme.fg("accent", String(messageStats.contextMessages))} ${theme.fg("dim", `(${messageStats.branchEntries} branch entries)`)}`);
480
+ lines.push(`${qcuBranch(theme, "├")}user: ${messageStats.user} · assistant: ${messageStats.assistant} · tool results: ${messageStats.toolResult}`);
481
+ lines.push(`${qcuBranch(theme, "└")}bash: ${messageStats.bash} · custom: ${messageStats.custom} · compact summaries: ${messageStats.compact}`);
482
+ qcuRenderDetailSection(lines, "MCP tools", details.mcpTools, theme, { empty: undefined, limit: 12, showDescriptions: false });
483
+ qcuRenderDetailSection(lines, "Built-in tools", details.builtinTools, theme, { limit: details.mcpTools.length > 0 ? 8 : 12, showDescriptions: false });
484
+ qcuRenderDetailSection(lines, "Extension tools", details.extensionTools, theme, { limit: 12, showDescriptions: false });
485
+ qcuRenderDetailSection(lines, "Custom agents · /agents", details.customAgents, theme, { limit: 12, showDescriptions: false });
486
+ qcuRenderDetailSection(lines, "Context / memory files", details.contextFiles, theme, { limit: 12, showDescriptions: false });
487
+ qcuRenderDetailSection(lines, "Skills · /skills", details.skills, theme, { empty: "No skill commands discovered.", limit: 14, showDescriptions: false });
488
+ qcuRenderDetailSection(lines, "Compact buffer", details.compactSummaries, theme, { limit: 6, showDescriptions: false });
489
+ return lines.map((line) => truncateToWidth(line, safeWidth, ""));
490
+ },
491
+ };
492
+ }