@vanillagreen/pi-qol 1.4.3 → 1.5.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.
@@ -2,11 +2,30 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import { complete, type Message } from "@earendil-works/pi-ai";
3
3
  import { convertToLlm, serializeConversation, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
+ DEFAULT_BUDGET_GUARD_PERCENT,
6
+ DEFAULT_BUDGET_GUARD_TOKENS,
7
+ DEFAULT_BUDGET_MAX_INPUT_CHARS,
5
8
  DEFAULT_COMPACTION_MAX_TOKENS,
6
9
  DEFAULT_COMPACTION_MODEL,
7
10
  DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS,
8
11
  QOL_COMPACTION_SYSTEM_PROMPT,
9
12
  } from "./constants.js";
13
+ import {
14
+ chunkConversationText as chunkConversationTextRaw,
15
+ computeBudgetTrigger,
16
+ isBudgetGuardCompaction,
17
+ orchestrateChunkedSummary,
18
+ type BudgetTrigger,
19
+ type SummarizeOutcome,
20
+ type SummarizeRequest,
21
+ type TranscriptRiskResult,
22
+ } from "./budget-guard.js";
23
+ import {
24
+ buildBudgetHandoff as buildBudgetHandoffData,
25
+ writeBudgetHandoffArtifact as writeBudgetHandoffArtifactRaw,
26
+ type HandoffWriteResult,
27
+ type QolBudgetHandoff,
28
+ } from "./compaction-handoff.js";
10
29
  import { settingBoolean, settingNumber, settingString } from "./settings.js";
11
30
  import { stringifyError } from "./util.js";
12
31
 
@@ -95,7 +114,7 @@ async function summarizeWithRemote(endpoint: string, systemPrompt: string, promp
95
114
  }
96
115
 
97
116
  export function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
98
- if (!configured || configured === "current") return ctx.model;
117
+ if (!configured || configured.trim().toLowerCase() === "current") return ctx.model;
99
118
  const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
100
119
  const slash = withoutThinking.indexOf("/");
101
120
  if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
@@ -111,20 +130,20 @@ export function modelLabel(model: any): string {
111
130
  return model ? `${model.provider}/${model.id}` : "unknown model";
112
131
  }
113
132
 
114
- export async function generateQolSummary(ctx: ExtensionContext, options: {
115
- conversationText: string;
116
- customInstructions?: string;
117
- previousSummary?: string;
118
- maxTokens?: number;
119
- model?: string;
120
- purpose: QolSummaryPurpose;
121
- signal?: AbortSignal;
122
- }): Promise<{ model: string; summary: string; via: "model" | "remote" }> {
123
- const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
133
+ export function budgetMaxInputChars(ctx: ExtensionContext): number {
134
+ const raw = Math.floor(settingNumber("compaction.maxInputChars", DEFAULT_BUDGET_MAX_INPUT_CHARS, ctx.cwd));
135
+ // 0 or negative disables chunking. Anything above the hard floor is honored.
136
+ return raw <= 0 ? 0 : Math.max(20_000, raw);
137
+ }
138
+
139
+ export const chunkConversationText = chunkConversationTextRaw;
140
+ export type { QolBudgetHandoff } from "./compaction-handoff.js";
141
+
142
+ async function singleShotSummary(ctx: ExtensionContext, request: SummarizeRequest, options: { maxTokens: number; model?: string; purpose: QolSummaryPurpose; signal?: AbortSignal }): Promise<SummarizeOutcome> {
124
143
  const promptText = buildSummaryPrompt({
125
- conversationText: options.conversationText,
126
- customInstructions: options.customInstructions,
127
- previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? options.previousSummary : undefined,
144
+ conversationText: request.text,
145
+ customInstructions: request.customInstructions,
146
+ previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? request.previousSummary : undefined,
128
147
  profile: compactionProfile(ctx.cwd),
129
148
  purpose: options.purpose,
130
149
  });
@@ -132,7 +151,7 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
132
151
  const remoteEndpoint = settingString("compaction.remoteEndpoint", "", ctx.cwd);
133
152
  if (settingBoolean("compaction.remoteEnabled", false, ctx.cwd) && remoteEndpoint) {
134
153
  try {
135
- const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, maxTokens, options.signal);
154
+ const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, options.maxTokens, options.signal);
136
155
  return { model: remoteEndpoint, summary, via: "remote" };
137
156
  } catch (error) {
138
157
  compactionNotify(ctx, `Remote compaction failed, trying model fallback: ${stringifyError(error)}`, "warning");
@@ -154,7 +173,7 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
154
173
  const response = await complete(
155
174
  model,
156
175
  { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
157
- { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal: options.signal },
176
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens: options.maxTokens, signal: options.signal },
158
177
  );
159
178
  const summary = response.content
160
179
  .filter((content): content is { type: "text"; text: string } => content.type === "text")
@@ -164,13 +183,79 @@ export async function generateQolSummary(ctx: ExtensionContext, options: {
164
183
  return { model: modelLabel(model), summary, via: "model" };
165
184
  }
166
185
 
186
+ export async function generateQolSummary(ctx: ExtensionContext, options: {
187
+ conversationText: string;
188
+ customInstructions?: string;
189
+ previousSummary?: string;
190
+ maxTokens?: number;
191
+ model?: string;
192
+ purpose: QolSummaryPurpose;
193
+ signal?: AbortSignal;
194
+ /** Internal: set true on recursive summary-of-summaries pass to skip rechunking. */
195
+ skipChunking?: boolean;
196
+ }): Promise<{ model: string; summary: string; via: "model" | "remote"; chunkCount?: number; reduceLevels?: number; requestCount?: number }> {
197
+ const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
198
+ const maxInputChars = budgetMaxInputChars(ctx);
199
+ const summarize = (request: SummarizeRequest) => singleShotSummary(ctx, request, {
200
+ maxTokens,
201
+ model: options.model,
202
+ purpose: options.purpose,
203
+ signal: options.signal,
204
+ });
205
+ if (options.skipChunking || maxInputChars <= 0 || options.conversationText.length <= maxInputChars) {
206
+ return summarize({
207
+ customInstructions: options.customInstructions,
208
+ previousSummary: options.previousSummary,
209
+ skipChunking: true,
210
+ text: options.conversationText,
211
+ });
212
+ }
213
+ const orchestrated = await orchestrateChunkedSummary({
214
+ customInstructions: options.customInstructions,
215
+ maxInputChars,
216
+ notify: (message, level = "info") => compactionNotify(ctx, message, level),
217
+ previousSummary: options.previousSummary,
218
+ signal: options.signal,
219
+ summarize,
220
+ text: options.conversationText,
221
+ });
222
+ return orchestrated;
223
+ }
224
+
225
+ export function buildBudgetHandoff(ctx: ExtensionContext, options: {
226
+ reason: string;
227
+ preparation?: { messagesToSummarize?: AgentMessage[]; turnPrefixMessages?: AgentMessage[]; previousSummary?: string; tokensBefore?: number };
228
+ }): QolBudgetHandoff {
229
+ return buildBudgetHandoffData({
230
+ preparation: options.preparation,
231
+ reason: options.reason,
232
+ sessionManager: ctx.sessionManager as any,
233
+ });
234
+ }
235
+
236
+ export function writeBudgetHandoffArtifact(ctx: ExtensionContext, handoff: QolBudgetHandoff): HandoffWriteResult {
237
+ const enabled = settingBoolean("compaction.handoffArtifactEnabled", true, ctx.cwd);
238
+ const result = writeBudgetHandoffArtifactRaw(handoff, { enabled });
239
+ if (result.error) {
240
+ compactionNotify(ctx, `QOL handoff artifact write failed: ${result.error}`, "warning");
241
+ }
242
+ return result;
243
+ }
244
+
167
245
  export async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<any> {
168
- if (!settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
246
+ const isBudgetGuard = isBudgetGuardCompaction(event?.customInstructions);
247
+ // Budget-guard-triggered compactions force the QOL bounded path so the
248
+ // chunked summarizer + handoff artifact always run, even when the user
249
+ // has not flipped compaction.customEnabled on.
250
+ if (!isBudgetGuard && !settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
169
251
  const preparation = event.preparation ?? {};
170
252
  const messages = [...(preparation.messagesToSummarize ?? []), ...(preparation.turnPrefixMessages ?? [])];
171
253
  if (messages.length === 0) return undefined;
172
254
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
173
- compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
255
+ const handoff = buildBudgetHandoff(ctx, { preparation, reason: event.customInstructions ?? "session_before_compact" });
256
+ const handoffResult = writeBudgetHandoffArtifact(ctx, handoff);
257
+ const sourceLabel = isBudgetGuard ? "pi-qol budget-guard" : "pi-qol";
258
+ compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s)${isBudgetGuard ? " (budget guard)" : ""}.`, "info");
174
259
  try {
175
260
  const conversationText = serializeMessagesForSummary(messages);
176
261
  const result = await generateQolSummary(ctx, {
@@ -181,14 +266,22 @@ export async function handleQolCompaction(event: any, ctx: ExtensionContext): Pr
181
266
  signal: event.signal,
182
267
  });
183
268
  if (!result.summary.trim()) throw new Error("Compaction summary was empty");
184
- compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}`, "info");
269
+ const chunkSuffix = result.chunkCount && result.chunkCount > 1 ? ` (${result.chunkCount} chunks, ${result.reduceLevels ?? 1} reduce level${(result.reduceLevels ?? 1) === 1 ? "" : "s"})` : "";
270
+ compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}${chunkSuffix}`, "info");
185
271
  return {
186
272
  compaction: {
187
273
  details: {
274
+ chunkCount: result.chunkCount,
275
+ handoffArtifact: handoffResult.path,
276
+ handoffArtifactLatest: handoffResult.latestPath,
277
+ handoffArtifactError: handoffResult.error,
188
278
  messageCount: messages.length,
189
279
  model: result.model,
190
280
  profile: compactionProfile(ctx.cwd),
191
- source: "pi-qol",
281
+ reduceLevels: result.reduceLevels,
282
+ requestCount: result.requestCount,
283
+ source: sourceLabel,
284
+ trigger: isBudgetGuard ? "budget-guard" : "session_before_compact",
192
285
  via: result.via,
193
286
  },
194
287
  firstKeptEntryId: preparation.firstKeptEntryId,
@@ -262,3 +355,25 @@ export function compactionTriggerReason(ctx: ExtensionContext): string | undefin
262
355
  if (usage.tokens >= idleLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(idleLimit).toLocaleString()} idle threshold`;
263
356
  return undefined;
264
357
  }
358
+
359
+ export type BudgetGuardTrigger = BudgetTrigger;
360
+ export type TranscriptRiskState = TranscriptRiskResult;
361
+
362
+ /**
363
+ * Budget guard fires on agent_end (no idle wait) when context usage crosses a
364
+ * percent of the model window or an absolute token limit. Returns a stable key
365
+ * per crossing so the caller can suppress repeated triggers while usage stays
366
+ * above the threshold.
367
+ */
368
+ export function budgetGuardTrigger(ctx: ExtensionContext): BudgetGuardTrigger | undefined {
369
+ if (!settingBoolean("compaction.budgetGuardEnabled", true, ctx.cwd)) return undefined;
370
+ const usage = contextUsage(ctx);
371
+ if (!usage) return undefined;
372
+ return computeBudgetTrigger({
373
+ contextWindow: usage.contextWindow,
374
+ enabled: true,
375
+ percentLimit: settingNumber("compaction.budgetPercent", DEFAULT_BUDGET_GUARD_PERCENT, ctx.cwd),
376
+ tokenLimit: settingNumber("compaction.budgetTokens", DEFAULT_BUDGET_GUARD_TOKENS, ctx.cwd),
377
+ tokens: usage.tokens,
378
+ });
379
+ }
@@ -33,11 +33,33 @@ export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
33
33
  export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
34
34
  export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
35
35
 
36
- export const DEFAULT_COMPACTION_MODEL = "google/gemini-2.5-flash";
36
+ export const DEFAULT_COMPACTION_MODEL = "current";
37
37
  export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
38
38
  export const DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS = 200000;
39
39
  export const DEFAULT_IDLE_COMPACTION_SECONDS = 300;
40
40
 
41
+ // Budget guard: catches long autonomous sessions before they hit provider/buffer
42
+ // limits. Fires on agent_end (no idle wait) when usage crosses a percent or
43
+ // absolute token threshold. One notification + one compaction trigger per
44
+ // threshold crossing so it does not loop.
45
+ export const DEFAULT_BUDGET_GUARD_PERCENT = 85;
46
+ export const DEFAULT_BUDGET_GUARD_TOKENS = -1;
47
+
48
+ // Chunked summary input caps. Long transcripts are split on paragraph
49
+ // boundaries (which align with serializeConversation message separators) into
50
+ // chunks of at most this many characters, summarized chunk-by-chunk in the
51
+ // original order, then a summary-of-summaries reduce pass merges them with
52
+ // "prefer the most recent" semantics so recency is preserved.
53
+ export const DEFAULT_BUDGET_MAX_INPUT_CHARS = 240_000;
54
+
55
+ // Transcript-risk warning: shown in /context when the serialized payload of
56
+ // messages-to-send is larger than this character budget, even if token count is
57
+ // still below the model window.
58
+ export const DEFAULT_TRANSCRIPT_RISK_WARN_CHARS = 600_000;
59
+
60
+ export const QOL_BUDGET_HANDOFF_FOLDER = "pi-qol/handoff";
61
+ export const QOL_BUDGET_HANDOFF_LATEST = "latest.json";
62
+
41
63
  export const DEFAULT_PERMISSION_GATE_COMMANDS = "rm -Rf";
42
64
  export const DEFAULT_PERMISSION_GATE_PREVIEW_LINES = 12;
43
65
  export const DEFAULT_PERMISSION_GATE_PREVIEW_CHARS = 1200;
@@ -2,6 +2,12 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { TranscriptRiskResult } from "./budget-guard.js";
6
+ import { DEFAULT_TRANSCRIPT_RISK_WARN_CHARS } from "./constants.js";
7
+ import { settingNumber } from "./settings.js";
8
+ import { transcriptRiskState } from "./transcript-risk.js";
9
+
10
+ type TranscriptRiskState = TranscriptRiskResult;
5
11
 
6
12
  type QolContextColor = "accent" | "success" | "warning" | "error" | "muted" | "dim" | "borderMuted" | "text";
7
13
 
@@ -53,6 +59,7 @@ export interface QolContextUsageMessageDetails {
53
59
  user: number;
54
60
  };
55
61
  compactSummaries: QolContextDetailItem[];
62
+ transcriptRisk?: TranscriptRiskState;
56
63
  note?: string;
57
64
  }
58
65
 
@@ -319,6 +326,14 @@ function qcuExtractProjectAgents(systemPrompt: string): { agents: QolContextDeta
319
326
  return { agents: qcuUniqDetails(agents), tokens: qcuEstimateTokens(section) };
320
327
  }
321
328
 
329
+ function qcuMessagesForRisk(ctx: ExtensionCommandContext): any[] {
330
+ const sm = ctx.sessionManager as any;
331
+ const built = typeof sm.buildSessionContext === "function" ? sm.buildSessionContext() : undefined;
332
+ if (Array.isArray(built?.messages)) return built.messages;
333
+ const branch = Array.isArray(sm.getBranch?.()) ? sm.getBranch() : [];
334
+ return branch.filter((entry: any) => entry?.type === "message").map((entry: any) => entry.message);
335
+ }
336
+
322
337
  export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionCommandContext, promptOptions: unknown): QolContextUsageMessageDetails | undefined {
323
338
  const usage = ctx.getContextUsage?.() as { contextWindow?: unknown; percent?: unknown; tokens?: unknown } | undefined;
324
339
  const tokens = Number(usage?.tokens);
@@ -355,6 +370,8 @@ export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionComm
355
370
  const modelId = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : "unknown";
356
371
  const modelName = typeof model.name === "string" ? model.name : modelId;
357
372
  const thinking = typeof (pi as any).getThinkingLevel === "function" ? (pi as any).getThinkingLevel() : undefined;
373
+ const transcriptThreshold = Math.floor(settingNumber("compaction.transcriptRiskWarnChars", DEFAULT_TRANSCRIPT_RISK_WARN_CHARS, ctx.cwd));
374
+ const transcriptRisk = transcriptRiskState(qcuMessagesForRisk(ctx), transcriptThreshold);
358
375
  return {
359
376
  builtinTools,
360
377
  categories,
@@ -365,6 +382,7 @@ export function buildQolContextUsageDetails(pi: ExtensionAPI, ctx: ExtensionComm
365
382
  freeTokens: safeContextWindow ? Math.max(0, safeContextWindow - tokens) : undefined,
366
383
  mcpTools,
367
384
  messageStats: stats,
385
+ transcriptRisk: transcriptRisk.chars > 0 || transcriptRisk.error ? transcriptRisk : undefined,
368
386
  model: {
369
387
  contextWindow: safeContextWindow,
370
388
  id: modelId,
@@ -486,6 +504,23 @@ export function renderQolContextUsageMessage(message: any, _options: any, theme:
486
504
  qcuRenderDetailSection(lines, "Context / memory files", details.contextFiles, theme, { limit: 12, showDescriptions: false });
487
505
  qcuRenderDetailSection(lines, "Skills · /skills", details.skills, theme, { empty: "No skill commands discovered.", limit: 14, showDescriptions: false });
488
506
  qcuRenderDetailSection(lines, "Compact buffer", details.compactSummaries, theme, { limit: 6, showDescriptions: false });
507
+ if (details.transcriptRisk?.error) {
508
+ const r = details.transcriptRisk;
509
+ const safe = r.error.replace(/[\x00-\x1f]+/g, " ").slice(0, 200);
510
+ lines.push("");
511
+ lines.push(`${theme.fg("warning", theme.bold("Transcript risk"))}`);
512
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("warning", `risk calculation failed: ${safe}`)}`);
513
+ } else if (details.transcriptRisk?.exceeded) {
514
+ const r = details.transcriptRisk;
515
+ lines.push("");
516
+ lines.push(`${theme.fg("warning", theme.bold("Transcript risk"))}`);
517
+ lines.push(`${qcuBranch(theme, "├")}${theme.fg("warning", `serialized payload ${qcuFormatTokens(Math.ceil(r.chars / 4))} tokens (${r.chars.toLocaleString()} chars) >= ${r.threshold.toLocaleString()} char warn budget`)}`);
518
+ lines.push(`${qcuBranch(theme, "└")}${theme.fg("muted", "compact soon or raise compaction.transcriptRiskWarnChars to silence")}`);
519
+ } else if (details.transcriptRisk && details.transcriptRisk.chars > 0) {
520
+ const r = details.transcriptRisk;
521
+ lines.push("");
522
+ lines.push(`${theme.fg("muted", `Transcript payload: ${qcuFormatTokens(Math.ceil(r.chars / 4))} tokens (${r.chars.toLocaleString()} chars / ${r.threshold.toLocaleString()} warn budget).`)}`);
523
+ }
489
524
  return lines.map((line) => truncateToWidth(line, safeWidth, ""));
490
525
  },
491
526
  };
@@ -3,6 +3,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type
3
3
  import { stripAnsi } from "./ansi.js";
4
4
  import { readCavemanBridge } from "./bridges.js";
5
5
  import { STATUS_KEY } from "./constants.js";
6
+ import { glyphs } from "./glyphs.js";
6
7
  import { statusText, styleImageChips } from "./images.js";
7
8
  import { newlineFallbackKey, settingBoolean } from "./settings.js";
8
9
 
@@ -101,14 +102,16 @@ function syncQolEditorStatus(
101
102
 
102
103
  export class QolEditor extends CustomEditor {
103
104
  private readonly statusCache: { last?: string } = {};
105
+ private readonly ctx: ExtensionContext;
104
106
 
105
107
  constructor(
106
108
  tui: TUI,
107
109
  editorTheme: EditorTheme,
108
110
  keybindings: KeybindingsManager,
109
- private readonly ctx: ExtensionContext,
111
+ ctx: ExtensionContext,
110
112
  ) {
111
113
  super(tui, editorTheme, keybindings);
114
+ this.ctx = ctx;
112
115
  }
113
116
 
114
117
  handleInput(data: string): void {
@@ -128,15 +131,19 @@ function isEditorBorderLine(line: string): boolean {
128
131
 
129
132
  export class QolCompactPromptEditor extends CustomEditor {
130
133
  private readonly statusCache: { last?: string } = {};
134
+ private readonly inputBottomPaddingLines: number;
135
+ private readonly ctx: ExtensionContext;
131
136
 
132
137
  constructor(
133
138
  tui: TUI,
134
139
  editorTheme: EditorTheme,
135
140
  keybindings: KeybindingsManager,
136
- private readonly inputBottomPaddingLines: number,
137
- private readonly ctx: ExtensionContext,
141
+ inputBottomPaddingLines: number,
142
+ ctx: ExtensionContext,
138
143
  ) {
139
144
  super(tui, editorTheme, keybindings, { paddingX: 0 });
145
+ this.inputBottomPaddingLines = inputBottomPaddingLines;
146
+ this.ctx = ctx;
140
147
  }
141
148
 
142
149
  handleInput(data: string): void {
@@ -145,9 +152,10 @@ export class QolCompactPromptEditor extends CustomEditor {
145
152
 
146
153
  render(width: number): string[] {
147
154
  syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
148
- const prompt = this.borderColor("π");
155
+ const promptGlyph = glyphs(this.ctx.cwd).prompt;
156
+ const prompt = this.borderColor(promptGlyph);
149
157
  const prefix = `${prompt} `;
150
- const prefixWidth = visibleWidth("π ");
158
+ const prefixWidth = visibleWidth(`${promptGlyph} `);
151
159
  const continuationPrefix = " ".repeat(prefixWidth);
152
160
  const innerWidth = Math.max(1, width - prefixWidth);
153
161
  const rendered = super.render(innerWidth);
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export type GlyphStyle = "unicode" | "ascii";
6
+ export type GlobalGlyphStyleOverride = "inherit" | GlyphStyle;
7
+
8
+ const LOCAL_CONFIG_ID = "@vanillagreen/pi-qol";
9
+ const GLOBAL_CONFIG_ID = "@vanillagreen/pi-tool-renderer";
10
+
11
+ function expandHome(input: string): string {
12
+ if (input === "~") return homedir();
13
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
14
+ return input;
15
+ }
16
+
17
+ function projectSettingsPath(cwd: string): string {
18
+ let current = resolve(cwd);
19
+ while (true) {
20
+ const candidate = join(current, ".pi", "settings.json");
21
+ if (existsSync(candidate)) return candidate;
22
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".vstack-lock.json"))) return candidate;
23
+ const parent = dirname(current);
24
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
25
+ current = parent;
26
+ }
27
+ }
28
+
29
+ function piSettingsPaths(cwd = process.cwd()): string[] {
30
+ const userDir = resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
31
+ return [join(userDir, "settings.json"), projectSettingsPath(cwd)];
32
+ }
33
+
34
+ function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
35
+ const merged: Record<string, unknown> = {};
36
+ for (const settingsPath of piSettingsPaths(cwd)) {
37
+ if (!existsSync(settingsPath)) continue;
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
40
+ const config = parsed?.vstack?.extensionManager?.config?.[packageId];
41
+ if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
42
+ } catch {
43
+ // Ignore malformed optional manager config.
44
+ }
45
+ }
46
+ return merged;
47
+ }
48
+
49
+ function asGlyphStyle(value: unknown): GlyphStyle | undefined {
50
+ return value === "unicode" || value === "ascii" ? value : undefined;
51
+ }
52
+
53
+ export function glyphStyle(cwd?: string): GlyphStyle {
54
+ const globalOverride = readPackageConfig(GLOBAL_CONFIG_ID, cwd).globalGlyphStyleOverride;
55
+ const forced = asGlyphStyle(globalOverride);
56
+ if (forced) return forced;
57
+ const local = readPackageConfig(LOCAL_CONFIG_ID, cwd);
58
+ return asGlyphStyle(local.glyphStyle) ?? asGlyphStyle(local.treeStyle) ?? "unicode";
59
+ }
60
+
61
+ export const GLYPHS = {
62
+ unicode: {
63
+ frame: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
64
+ line: "─",
65
+ tree: { mid: "├─ ", last: "└─ ", stem: "│ ", blank: " " },
66
+ bullet: "● ",
67
+ emptyBullet: "○ ",
68
+ dot: " · ",
69
+ ok: "✓",
70
+ fail: "✗",
71
+ warn: "▲",
72
+ diamond: "◆",
73
+ prompt: "π",
74
+ ellipsis: "…",
75
+ arrow: "→",
76
+ codeBar: "▌",
77
+ },
78
+ ascii: {
79
+ frame: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" },
80
+ line: "-",
81
+ tree: { mid: "|-- ", last: "`-- ", stem: "| ", blank: " " },
82
+ bullet: "* ",
83
+ emptyBullet: "o ",
84
+ dot: " - ",
85
+ ok: "+",
86
+ fail: "x",
87
+ warn: "!",
88
+ diamond: "*",
89
+ prompt: "pi",
90
+ ellipsis: "...",
91
+ arrow: "->",
92
+ codeBar: "|",
93
+ },
94
+ } as const;
95
+
96
+ export function glyphs(cwd?: string): (typeof GLYPHS)[GlyphStyle] {
97
+ return GLYPHS[glyphStyle(cwd)];
98
+ }
99
+
100
+ export function truncateIndicator(cwd?: string): string {
101
+ return glyphs(cwd).ellipsis;
102
+ }
103
+
104
+ export function truncateText(text: string, maxChars: number, cwd?: string): string {
105
+ if (text.length <= maxChars) return text;
106
+ const indicator = truncateIndicator(cwd);
107
+ return `${text.slice(0, Math.max(0, maxChars - indicator.length))}${indicator}`;
108
+ }
109
+
110
+ export function dot(cwd?: string): string {
111
+ return glyphs(cwd).dot;
112
+ }
113
+
114
+ export function treeGlyph(branch: "├" | "└" | "│", cwd?: string): string {
115
+ const tree = glyphs(cwd).tree;
116
+ if (branch === "│") return tree.stem;
117
+ return branch === "└" ? tree.last : tree.mid;
118
+ }
119
+
120
+ export function frameGlyphs(cwd?: string): (typeof GLYPHS)[GlyphStyle]["frame"] {
121
+ return glyphs(cwd).frame;
122
+ }
@@ -105,6 +105,12 @@ function isPrintableInput(data: string): boolean {
105
105
  }
106
106
 
107
107
  export class QolSessionSearchComponent {
108
+ private readonly done: (action: QolSessionPaletteAction) => void;
109
+ private readonly tui: { requestRender(): void; terminal?: { rows?: number } };
110
+ private readonly theme: Theme;
111
+ private readonly sessions: QolSessionSearchSession[];
112
+ private readonly cwd: string;
113
+ private readonly currentModel: QolModelInfo | undefined;
108
114
  private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" | "confirmModel" = "search";
109
115
  private searchState: QolSessionSearchState;
110
116
  private messagesState: QolSessionMessagesState | undefined;
@@ -116,14 +122,20 @@ export class QolSessionSearchComponent {
116
122
 
117
123
 
118
124
  constructor(
119
- private readonly done: (action: QolSessionPaletteAction) => void,
120
- private readonly tui: { requestRender(): void; terminal?: { rows?: number } },
121
- private readonly theme: Theme,
122
- private readonly sessions: QolSessionSearchSession[],
123
- private readonly cwd: string,
125
+ done: (action: QolSessionPaletteAction) => void,
126
+ tui: { requestRender(): void; terminal?: { rows?: number } },
127
+ theme: Theme,
128
+ sessions: QolSessionSearchSession[],
129
+ cwd: string,
124
130
  initialQuery = "",
125
- private readonly currentModel: QolModelInfo | undefined = undefined,
131
+ currentModel: QolModelInfo | undefined = undefined,
126
132
  ) {
133
+ this.done = done;
134
+ this.tui = tui;
135
+ this.theme = theme;
136
+ this.sessions = sessions;
137
+ this.cwd = cwd;
138
+ this.currentModel = currentModel;
127
139
  const query = initialQuery.trim();
128
140
  const scope = defaultSessionSearchScope(cwd);
129
141
  const scopedSessions = this.sessionsForScope(scope);
@@ -878,18 +890,28 @@ export class QolSessionSearchComponent {
878
890
  }
879
891
 
880
892
  export class QolSessionSearchLoadingComponent {
893
+ private readonly tui: { requestRender(): void };
894
+ private readonly theme: Theme;
895
+ private readonly title: string;
896
+ private readonly message: string;
897
+ private readonly done: (value: null) => void;
881
898
  private readonly controller = new AbortController();
882
899
  private readonly startedAt = Date.now();
883
900
  private frame = 0;
884
901
  private timer: ReturnType<typeof setInterval> | undefined;
885
902
 
886
903
  constructor(
887
- private readonly tui: { requestRender(): void },
888
- private readonly theme: Theme,
889
- private readonly title: string,
890
- private readonly message: string,
891
- private readonly done: (value: null) => void,
904
+ tui: { requestRender(): void },
905
+ theme: Theme,
906
+ title: string,
907
+ message: string,
908
+ done: (value: null) => void,
892
909
  ) {
910
+ this.tui = tui;
911
+ this.theme = theme;
912
+ this.title = title;
913
+ this.message = message;
914
+ this.done = done;
893
915
  this.timer = setInterval(() => {
894
916
  this.frame += 1;
895
917
  this.tui.requestRender();
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_PERMISSION_GATE_PREVIEW_LINES,
9
9
  } from "./constants.js";
10
10
  import { currentEditorText } from "./editor.js";
11
+ import { glyphs } from "./glyphs.js";
11
12
  import { attachmentLabels } from "./images.js";
12
13
  import { permissionGateCommands } from "./permission-gate.js";
13
14
  import { autoRenameEnabled } from "./session-rename.js";
@@ -19,7 +20,7 @@ export function statusMessage(ctx: ExtensionContext): string {
19
20
  const searchShortcut = sessionSearchShortcut(ctx.cwd);
20
21
  return [
21
22
  "Pi QOL status",
22
- `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
23
+ `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? `${glyphs(ctx.cwd).prompt} compact` : "default chrome"}`,
23
24
  `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
24
25
  `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
25
26
  `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
@@ -5,6 +5,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
5
  import { subagentStatuslineMarker } from "./agent-statusline.js";
6
6
  import { stripAnsi } from "./ansi.js";
7
7
  import { readCavemanBridge } from "./bridges.js";
8
+ import { glyphs } from "./glyphs.js";
8
9
  import { CAVEMAN_ICON_ACTIVE, CAVEMAN_ICON_INACTIVE } from "./constants.js";
9
10
  import { settingBoolean, settingNumber } from "./settings.js";
10
11
 
@@ -136,14 +137,14 @@ export function sessionNameHeader(width: number, pi: ExtensionAPI, theme: Pick<T
136
137
  const prefixPlain = "Session ";
137
138
  const prefix = theme.fg("muted", prefixPlain);
138
139
  const innerWidth = Math.max(1, width - visibleWidth(prefixPlain) - 2);
139
- const inner = truncateToWidth(name, innerWidth, "…");
140
+ const inner = truncateToWidth(name, innerWidth, glyphs().ellipsis);
140
141
  const plain = ` ${inner} `;
141
142
  const badge = theme.bg("selectedBg", theme.fg("text", plain));
142
143
  return [truncateToWidth(`${prefix}${badge}`, width, "")];
143
144
  }
144
145
 
145
146
  export function formatTmuxSessionTitle(sessionName: string): string {
146
- return `π ${sessionName}`;
147
+ return `${glyphs().prompt} ${sessionName}`;
147
148
  }
148
149
 
149
150
  export function tmuxPaneTarget(): string | undefined {
@@ -201,7 +202,7 @@ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitS
201
202
  const cavemanSegment = caveman ? `${statusSeparator}${cavemanGlyph}` : "";
202
203
  const contextSeparator = caveman ? ` ${statusSeparator}` : "";
203
204
  const leftPlain = `${projectChunk}${statusSeparator}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}`;
204
- const percentPlain = percent === null ? "…%" : `${percent}%`;
205
+ const percentPlain = percent === null ? `${glyphs(ctx.cwd).ellipsis}%` : `${percent}%`;
205
206
  const subagentMarker = subagentStatuslineMarker(ctx.cwd);
206
207
  const rightPlain = subagentMarker ? `${percentPlain} ${subagentMarker.plain}` : percentPlain;
207
208
  const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
@@ -214,6 +215,6 @@ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitS
214
215
  const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
215
216
  const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
216
217
  const empty = Math.max(0, gapWidth - filled);
217
- const bar = " ".repeat(empty) + theme.fg("warning", "─".repeat(filled));
218
+ const bar = " ".repeat(empty) + theme.fg("warning", glyphs(ctx.cwd).line.repeat(filled));
218
219
  return truncateToWidth(`${leftColored} ${bar} ${right}`, width, "");
219
220
  }