@vanillagreen/pi-qol 1.4.2 → 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.
- package/README.md +26 -1
- package/extensions/qol/budget-guard-runtime.ts +100 -0
- package/extensions/qol/budget-guard.ts +317 -0
- package/extensions/qol/compaction-handoff.ts +190 -0
- package/extensions/qol/compaction.ts +135 -20
- package/extensions/qol/constants.ts +23 -1
- package/extensions/qol/context-usage.ts +35 -0
- package/extensions/qol/editor.ts +13 -5
- package/extensions/qol/glyphs.ts +122 -0
- package/extensions/qol/session-search/component.ts +33 -11
- package/extensions/qol/status-message.ts +2 -1
- package/extensions/qol/statusline.ts +5 -4
- package/extensions/qol/transcript-risk.ts +28 -0
- package/extensions/qol.ts +37 -0
- package/package.json +90 -3
|
@@ -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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
126
|
-
customInstructions:
|
|
127
|
-
previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
};
|
package/extensions/qol/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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) ?
|
|
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
|
|
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 ?
|
|
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",
|
|
218
|
+
const bar = " ".repeat(empty) + theme.fg("warning", glyphs(ctx.cwd).line.repeat(filled));
|
|
218
219
|
return truncateToWidth(`${leftColored} ${bar} ${right}`, width, "");
|
|
219
220
|
}
|