@vanillagreen/pi-qol 1.2.0 → 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,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
+ }
@@ -0,0 +1,183 @@
1
+ import { CustomEditor, Theme, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type AutocompleteSuggestions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+ import { stripAnsi } from "./ansi.js";
4
+ import { readCavemanBridge } from "./bridges.js";
5
+ import { STATUS_KEY } from "./constants.js";
6
+ import { statusText, styleImageChips } from "./images.js";
7
+ import { newlineFallbackKey, settingBoolean } from "./settings.js";
8
+
9
+ function styleAutocompleteHintItem(item: AutocompleteItem, theme: Theme): AutocompleteItem {
10
+ const label = stripAnsi(item.label || item.value);
11
+ const styled: AutocompleteItem = { ...item, label: theme.fg("accent", label) };
12
+ if (typeof item.description === "string" && item.description.length > 0) {
13
+ styled.description = theme.fg("text", stripAnsi(item.description));
14
+ }
15
+ return styled;
16
+ }
17
+
18
+ function styleSlashAutocompleteHints(suggestions: AutocompleteSuggestions | null, theme: Theme): AutocompleteSuggestions | null {
19
+ if (!suggestions || !suggestions.prefix.startsWith("/")) return suggestions;
20
+ return { ...suggestions, items: suggestions.items.map((item) => styleAutocompleteHintItem(item, theme)) };
21
+ }
22
+
23
+ export function installAutocompleteHintStyling(ctx: ExtensionContext): void {
24
+ if (!ctx.hasUI) return;
25
+ ctx.ui.addAutocompleteProvider((current) => ({
26
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
27
+ return styleSlashAutocompleteHints(await current.getSuggestions(lines, cursorLine, cursorCol, options), ctx.ui.theme);
28
+ },
29
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
30
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
31
+ },
32
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
33
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
34
+ },
35
+ }));
36
+ }
37
+
38
+ const QOL_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
39
+ { value: "status", label: "status", description: "Show QOL status and current settings" },
40
+ { value: "rename", label: "rename", description: "Generate a session name from the first user message" },
41
+ { value: "notify-test", label: "notify-test", description: "Send a test QOL notification" },
42
+ ];
43
+
44
+ const QOL_RENAME_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
45
+ { value: "rename full", label: "rename full", description: "Generate a session name from the full conversation" },
46
+ ];
47
+
48
+ export function getQolArgumentCompletions(prefix: string): AutocompleteItem[] | null {
49
+ const query = prefix.trimStart().toLowerCase();
50
+ const items = query.startsWith("rename ") ? QOL_RENAME_ARGUMENT_COMPLETIONS : QOL_ARGUMENT_COMPLETIONS;
51
+ const filtered = items.filter((item) => item.value.toLowerCase().startsWith(query));
52
+ return filtered.length > 0 ? filtered : null;
53
+ }
54
+
55
+ const SESSION_SEARCH_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
56
+ { value: "refresh", label: "refresh", description: "Refresh the session search index" },
57
+ ];
58
+
59
+ export function getSessionSearchArgumentCompletions(prefix: string): AutocompleteItem[] | null {
60
+ const query = prefix.trimStart().toLowerCase();
61
+ const filtered = SESSION_SEARCH_ARGUMENT_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(query) || (item.label ?? item.value).toLowerCase().startsWith(query));
62
+ return filtered.length > 0 ? filtered : null;
63
+ }
64
+
65
+ function handleQolEditorInput(editor: CustomEditor, ctx: ExtensionContext, data: string): void {
66
+ if (matchesKey(data, "alt+c")) {
67
+ const caveman = readCavemanBridge();
68
+ if (caveman?.cycleMode) {
69
+ const next = caveman.cycleMode(ctx.cwd);
70
+ if (typeof next === "string") {
71
+ try { ctx.ui.notify(next === "off" ? "Caveman off." : `Caveman ${next} active.`, "info"); } catch { /* notify is best-effort */ }
72
+ }
73
+ return;
74
+ }
75
+ }
76
+ const fallback = newlineFallbackKey(ctx.cwd);
77
+ const newlineEnabled = settingBoolean("newlineOnShiftEnter", true, ctx.cwd);
78
+ const isShiftEnter = matchesKey(data, "shift+enter") || matchesKey(data, "shift+return");
79
+ const isFallback = fallback !== "none" && matchesKey(data, fallback);
80
+ CustomEditor.prototype.handleInput.call(editor, newlineEnabled && (isShiftEnter || isFallback) ? "\n" : data);
81
+ }
82
+
83
+ /// Push the latest attachment-count status only when it actually changes.
84
+ /// Called from render() so we follow the rainbow-editor pattern (pull from
85
+ /// render, don't push from every keystroke) and avoid the per-keystroke
86
+ /// setStatus side effect that previously contributed to resize/input races.
87
+ function syncQolEditorStatus(
88
+ ctx: ExtensionContext,
89
+ text: string,
90
+ cache: { last?: string },
91
+ ): void {
92
+ const next = statusText(ctx, text);
93
+ if (next === cache.last) return;
94
+ cache.last = next;
95
+ try {
96
+ ctx.ui.setStatus(STATUS_KEY, next);
97
+ } catch {
98
+ // best-effort visual; don't let a status push break render
99
+ }
100
+ }
101
+
102
+ export class QolEditor extends CustomEditor {
103
+ private readonly statusCache: { last?: string } = {};
104
+
105
+ constructor(
106
+ tui: TUI,
107
+ editorTheme: EditorTheme,
108
+ keybindings: KeybindingsManager,
109
+ private readonly ctx: ExtensionContext,
110
+ ) {
111
+ super(tui, editorTheme, keybindings);
112
+ }
113
+
114
+ handleInput(data: string): void {
115
+ handleQolEditorInput(this, this.ctx, data);
116
+ }
117
+
118
+ render(width: number): string[] {
119
+ syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
120
+ return super.render(width).map((line) => truncateToWidth(styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme), width, ""));
121
+ }
122
+ }
123
+
124
+ function isEditorBorderLine(line: string): boolean {
125
+ const visible = stripAnsi(line).trim();
126
+ return visible.length > 0 && /^[─━╭╮╰╯┌┐└┘]+$/.test(visible);
127
+ }
128
+
129
+ export class QolCompactPromptEditor extends CustomEditor {
130
+ private readonly statusCache: { last?: string } = {};
131
+
132
+ constructor(
133
+ tui: TUI,
134
+ editorTheme: EditorTheme,
135
+ keybindings: KeybindingsManager,
136
+ private readonly inputBottomPaddingLines: number,
137
+ private readonly ctx: ExtensionContext,
138
+ ) {
139
+ super(tui, editorTheme, keybindings, { paddingX: 0 });
140
+ }
141
+
142
+ handleInput(data: string): void {
143
+ handleQolEditorInput(this, this.ctx, data);
144
+ }
145
+
146
+ render(width: number): string[] {
147
+ syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
148
+ const prompt = this.borderColor("π");
149
+ const prefix = `${prompt} `;
150
+ const prefixWidth = visibleWidth("π ");
151
+ const continuationPrefix = " ".repeat(prefixWidth);
152
+ const innerWidth = Math.max(1, width - prefixWidth);
153
+ const rendered = super.render(innerWidth);
154
+
155
+ const inputLines: string[] = [];
156
+ let completionLines: string[] = [];
157
+ for (let index = 1; index < rendered.length; index++) {
158
+ const line = rendered[index] ?? "";
159
+ if (isEditorBorderLine(line)) {
160
+ completionLines = rendered.slice(index + 1);
161
+ break;
162
+ }
163
+ inputLines.push(line);
164
+ }
165
+
166
+ const lines = (inputLines.length > 0 ? inputLines : [""]).map((line, index) => {
167
+ const linePrefix = index === 0 ? prefix : continuationPrefix;
168
+ const content = styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme);
169
+ return truncateToWidth(linePrefix + content, width, "");
170
+ });
171
+ for (let index = 0; index < this.inputBottomPaddingLines; index++) lines.push("");
172
+ for (const line of completionLines) lines.push(truncateToWidth(`${this.ctx.ui.theme.fg("dim", continuationPrefix)}${line}`, width, ""));
173
+ return lines;
174
+ }
175
+ }
176
+
177
+ export function currentEditorText(ctx: ExtensionContext): string {
178
+ try {
179
+ return ctx.ui.getEditorText?.() ?? "";
180
+ } catch {
181
+ return "";
182
+ }
183
+ }