@vanillagreen/pi-qol 1.4.3 → 1.6.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,320 @@
1
+ import { truncateToWidth, type AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ansiGreen } from "./ansi.js";
4
+ import { stringifyError } from "./util.js";
5
+
6
+ export const SCHEDULE_ENTRY_TYPE = "qol-schedule";
7
+
8
+ const MAX_TIMER_DELAY_MS = 2_147_000_000;
9
+ const MAX_SCHEDULE_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
10
+ const MAX_ACTIVE_SCHEDULES = 100;
11
+
12
+ interface ScheduleTimer {
13
+ unref?: () => void;
14
+ }
15
+
16
+ export interface ScheduleClock {
17
+ now(): number;
18
+ setTimeout(callback: () => void, delayMs: number): ScheduleTimer;
19
+ clearTimeout(timer: ScheduleTimer): void;
20
+ }
21
+
22
+ interface ScheduledMessage {
23
+ createdAt: number;
24
+ dueAt: number;
25
+ id: string;
26
+ message: string;
27
+ timer?: ScheduleTimer;
28
+ }
29
+
30
+ type ScheduleEntryAction = "scheduled" | "delivered" | "cancelled" | "failed";
31
+
32
+ interface ScheduleEntryData {
33
+ action: ScheduleEntryAction;
34
+ cancelledAt?: number;
35
+ createdAt?: number;
36
+ deliveredAt?: number;
37
+ dueAt?: number;
38
+ failedAt?: number;
39
+ id: string;
40
+ message?: string;
41
+ }
42
+
43
+ export type ParsedScheduleCommand =
44
+ | { kind: "list" }
45
+ | { kind: "cancel"; all: boolean; id?: string }
46
+ | { kind: "schedule"; delayMs: number; message: string }
47
+ | { kind: "usage"; error?: string };
48
+
49
+ const DEFAULT_CLOCK: ScheduleClock = {
50
+ now: () => Date.now(),
51
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
52
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
53
+ };
54
+
55
+ const SCHEDULE_COMPLETIONS: AutocompleteItem[] = [
56
+ { value: "list", label: "list", description: "Show pending scheduled messages" },
57
+ { value: "cancel ", label: "cancel <id|all>", description: "Cancel one scheduled message or all pending messages" },
58
+ { value: "20m ", label: "20m <message>", description: "Send a message after 20 minutes" },
59
+ { value: "1h ", label: "1h <message>", description: "Send a message after 1 hour" },
60
+ ];
61
+
62
+ export function getScheduleArgumentCompletions(prefix: string): AutocompleteItem[] | null {
63
+ const query = prefix.trimStart().toLowerCase();
64
+ const filtered = SCHEDULE_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(query) || (item.label ?? item.value).toLowerCase().startsWith(query));
65
+ return filtered.length > 0 ? filtered : null;
66
+ }
67
+
68
+ export function parseDurationMs(input: string): number | undefined {
69
+ const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)?$/);
70
+ if (!match) return undefined;
71
+ const amount = Number(match[1]);
72
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
73
+ const unit = match[2] ?? "m";
74
+ const multiplier = unit === "ms"
75
+ ? 1
76
+ : ["s", "sec", "secs", "second", "seconds"].includes(unit)
77
+ ? 1000
78
+ : ["h", "hr", "hrs", "hour", "hours"].includes(unit)
79
+ ? 60 * 60 * 1000
80
+ : ["d", "day", "days"].includes(unit)
81
+ ? 24 * 60 * 60 * 1000
82
+ : 60 * 1000;
83
+ const delayMs = Math.round(amount * multiplier);
84
+ if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SCHEDULE_DELAY_MS) return undefined;
85
+ return delayMs;
86
+ }
87
+
88
+ export function parseScheduleCommandArgs(args: string): ParsedScheduleCommand {
89
+ const trimmed = args.trim();
90
+ if (!trimmed) return { kind: "list" };
91
+ const firstSpace = trimmed.search(/\s/);
92
+ const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
93
+ const rest = firstSpace < 0 ? "" : trimmed.slice(firstSpace + 1).trim();
94
+ if (head === "list" || head === "ls" || head === "status") return { kind: "list" };
95
+ if (head === "cancel" || head === "clear") {
96
+ if (!rest) return { kind: "usage", error: "Usage: /schedule cancel <id|all>" };
97
+ if (rest.toLowerCase() === "all") return { kind: "cancel", all: true };
98
+ return { kind: "cancel", all: false, id: rest };
99
+ }
100
+ const delayMs = parseDurationMs(head);
101
+ if (delayMs === undefined) return { kind: "usage", error: "Usage: /schedule <duration> <message>" };
102
+ if (!rest) return { kind: "usage", error: "Usage: /schedule <duration> <message>" };
103
+ return { kind: "schedule", delayMs, message: rest };
104
+ }
105
+
106
+ export function formatScheduleDuration(ms: number): string {
107
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
108
+ const days = Math.floor(totalSeconds / 86_400);
109
+ const hours = Math.floor((totalSeconds % 86_400) / 3600);
110
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
111
+ const seconds = totalSeconds % 60;
112
+ const parts: string[] = [];
113
+ if (days) parts.push(`${days}d`);
114
+ if (hours) parts.push(`${hours}h`);
115
+ if (minutes) parts.push(`${minutes}m`);
116
+ if (seconds || parts.length === 0) parts.push(`${seconds}s`);
117
+ return parts.slice(0, 2).join(" ");
118
+ }
119
+
120
+ function previewMessage(message: string, max = 80): string {
121
+ const singleLine = message.replace(/\s+/g, " ").trim();
122
+ if (singleLine.length <= max) return singleLine;
123
+ return `${singleLine.slice(0, Math.max(0, max - 1))}…`;
124
+ }
125
+
126
+ function formatDueTime(timestamp: number): string {
127
+ return new Date(timestamp).toLocaleString();
128
+ }
129
+
130
+ function usageText(): string {
131
+ return [
132
+ "Usage: /schedule <duration> <message>",
133
+ "Examples: /schedule 20m retry the previous request",
134
+ " /schedule 1h retry after the rate limit reset",
135
+ "Manage: /schedule list, /schedule cancel <id|all>",
136
+ ].join("\n");
137
+ }
138
+
139
+ function dataToJob(data: ScheduleEntryData): ScheduledMessage | undefined {
140
+ if (data.action !== "scheduled") return undefined;
141
+ if (!data.id || typeof data.id !== "string") return undefined;
142
+ if (!data.message || typeof data.message !== "string") return undefined;
143
+ if (typeof data.createdAt !== "number" || typeof data.dueAt !== "number") return undefined;
144
+ if (!Number.isFinite(data.createdAt) || !Number.isFinite(data.dueAt)) return undefined;
145
+ return { createdAt: data.createdAt, dueAt: data.dueAt, id: data.id, message: data.message };
146
+ }
147
+
148
+ export function createScheduleController(pi: ExtensionAPI, clock: ScheduleClock = DEFAULT_CLOCK) {
149
+ let onChange: (() => void) | undefined;
150
+ let sequence = 0;
151
+ const jobs = new Map<string, ScheduledMessage>();
152
+
153
+ const notifyChanged = (): void => {
154
+ try {
155
+ onChange?.();
156
+ } catch {
157
+ // UI refresh hooks must never break schedule state transitions.
158
+ }
159
+ };
160
+
161
+ const nextId = (): string => {
162
+ let id = "";
163
+ do {
164
+ sequence += 1;
165
+ id = `${clock.now().toString(36)}-${sequence.toString(36)}`;
166
+ } while (jobs.has(id));
167
+ return id;
168
+ };
169
+
170
+ const appendEntry = (data: ScheduleEntryData): void => {
171
+ try {
172
+ pi.appendEntry?.(SCHEDULE_ENTRY_TYPE, data);
173
+ } catch {
174
+ // Scheduling still works in-memory if session persistence is unavailable.
175
+ }
176
+ };
177
+
178
+ const clearJobTimer = (job: ScheduledMessage): void => {
179
+ if (!job.timer) return;
180
+ clock.clearTimeout(job.timer);
181
+ job.timer = undefined;
182
+ };
183
+
184
+ const armJob = (job: ScheduledMessage, ctx: ExtensionContext): void => {
185
+ clearJobTimer(job);
186
+ const remaining = Math.max(0, job.dueAt - clock.now());
187
+ job.timer = clock.setTimeout(() => {
188
+ if (!jobs.has(job.id)) return;
189
+ if (clock.now() < job.dueAt) {
190
+ armJob(job, ctx);
191
+ return;
192
+ }
193
+ void deliver(job, ctx);
194
+ }, Math.min(remaining, MAX_TIMER_DELAY_MS));
195
+ job.timer.unref?.();
196
+ };
197
+
198
+ const deliver = async (job: ScheduledMessage, ctx: ExtensionContext): Promise<void> => {
199
+ if (!jobs.has(job.id)) return;
200
+ jobs.delete(job.id);
201
+ clearJobTimer(job);
202
+ notifyChanged();
203
+ try {
204
+ let idle = true;
205
+ try {
206
+ idle = ctx.isIdle?.() ?? true;
207
+ } catch {
208
+ idle = false;
209
+ }
210
+ await Promise.resolve(idle ? pi.sendUserMessage(job.message) : pi.sendUserMessage(job.message, { deliverAs: "followUp" }));
211
+ appendEntry({ action: "delivered", deliveredAt: clock.now(), id: job.id });
212
+ if (ctx.hasUI) ctx.ui.notify(`Scheduled message sent (${job.id}).`, "info");
213
+ } catch (error) {
214
+ appendEntry({ action: "failed", failedAt: clock.now(), id: job.id, message: stringifyError(error) });
215
+ if (ctx.hasUI) ctx.ui.notify(`Scheduled message failed (${job.id}): ${stringifyError(error)}`, "error");
216
+ }
217
+ };
218
+
219
+ const listText = (): string => {
220
+ const now = clock.now();
221
+ const pending = [...jobs.values()].sort((a, b) => a.dueAt - b.dueAt);
222
+ if (pending.length === 0) return "No scheduled messages.";
223
+ return [
224
+ "Scheduled messages:",
225
+ ...pending.map((job) => `${job.id} — in ${formatScheduleDuration(job.dueAt - now)} (${formatDueTime(job.dueAt)}) — ${previewMessage(job.message)}`),
226
+ ].join("\n");
227
+ };
228
+
229
+ const schedule = (delayMs: number, message: string, ctx: ExtensionCommandContext): void => {
230
+ if (jobs.size >= MAX_ACTIVE_SCHEDULES) {
231
+ ctx.ui.notify(`Too many scheduled messages (${MAX_ACTIVE_SCHEDULES}). Cancel one before adding another.`, "warning");
232
+ return;
233
+ }
234
+ const createdAt = clock.now();
235
+ const job: ScheduledMessage = { createdAt, dueAt: createdAt + delayMs, id: nextId(), message };
236
+ jobs.set(job.id, job);
237
+ appendEntry({ action: "scheduled", createdAt: job.createdAt, dueAt: job.dueAt, id: job.id, message: job.message });
238
+ armJob(job, ctx);
239
+ notifyChanged();
240
+ ctx.ui.notify(`Scheduled ${job.id} in ${formatScheduleDuration(delayMs)} (${formatDueTime(job.dueAt)}): ${previewMessage(message)}`, "info");
241
+ };
242
+
243
+ const cancel = (id: string): boolean => {
244
+ const job = jobs.get(id);
245
+ if (!job) return false;
246
+ clearJobTimer(job);
247
+ jobs.delete(id);
248
+ appendEntry({ action: "cancelled", cancelledAt: clock.now(), id });
249
+ notifyChanged();
250
+ return true;
251
+ };
252
+
253
+ return {
254
+ activeCount(): number {
255
+ return jobs.size;
256
+ },
257
+ clearTimers(): void {
258
+ for (const job of jobs.values()) clearJobTimer(job);
259
+ const changed = jobs.size > 0;
260
+ jobs.clear();
261
+ if (changed) notifyChanged();
262
+ },
263
+ async handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
264
+ const parsed = parseScheduleCommandArgs(args);
265
+ if (parsed.kind === "usage") {
266
+ ctx.ui.notify(`${parsed.error ?? "Invalid schedule command"}\n${usageText()}`, "warning");
267
+ return;
268
+ }
269
+ if (parsed.kind === "list") {
270
+ ctx.ui.notify(`${listText()}\n\n${usageText()}`, "info");
271
+ return;
272
+ }
273
+ if (parsed.kind === "cancel") {
274
+ if (parsed.all) {
275
+ const count = jobs.size;
276
+ for (const job of [...jobs.values()]) cancel(job.id);
277
+ ctx.ui.notify(`Cancelled ${count} scheduled message(s).`, "info");
278
+ return;
279
+ }
280
+ if (!parsed.id || !cancel(parsed.id)) {
281
+ ctx.ui.notify(`No scheduled message found for ${parsed.id ?? "<missing id>"}.`, "warning");
282
+ return;
283
+ }
284
+ ctx.ui.notify(`Cancelled scheduled message ${parsed.id}.`, "info");
285
+ return;
286
+ }
287
+ schedule(parsed.delayMs, parsed.message, ctx);
288
+ },
289
+ restoreFromBranch(ctx: ExtensionContext): void {
290
+ for (const job of jobs.values()) clearJobTimer(job);
291
+ jobs.clear();
292
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
293
+ for (const entry of branch as any[]) {
294
+ if (entry?.type !== "custom" || entry.customType !== SCHEDULE_ENTRY_TYPE) continue;
295
+ const data = entry.data as ScheduleEntryData | undefined;
296
+ if (!data || typeof data !== "object") continue;
297
+ if (data.action === "scheduled") {
298
+ const job = dataToJob(data);
299
+ if (job) jobs.set(job.id, job);
300
+ continue;
301
+ }
302
+ if (typeof data.id === "string") jobs.delete(data.id);
303
+ }
304
+ for (const job of jobs.values()) armJob(job, ctx);
305
+ notifyChanged();
306
+ },
307
+ renderPreviewLines(width: number): string[] {
308
+ const pending = [...jobs.values()].sort((a, b) => a.dueAt - b.dueAt);
309
+ const shown = pending.slice(0, 3).map((job) => {
310
+ const line = `┃ Scheduled ${formatDueTime(job.dueAt)} — ${previewMessage(job.message, 120)}`;
311
+ return truncateToWidth(ansiGreen(line), width, "");
312
+ });
313
+ if (pending.length > shown.length) shown.push(truncateToWidth(ansiGreen(`┃ Scheduled: +${pending.length - shown.length} more`), width, ""));
314
+ return shown;
315
+ },
316
+ setOnChange(callback: (() => void) | undefined): void {
317
+ onChange = callback;
318
+ },
319
+ };
320
+ }
@@ -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";
@@ -17,9 +18,13 @@ import { boundedSettingNumber, newlineFallbackKey, settingBoolean, settingNumber
17
18
  export function statusMessage(ctx: ExtensionContext): string {
18
19
  const labels = attachmentLabels(currentEditorText(ctx), ctx.cwd);
19
20
  const searchShortcut = sessionSearchShortcut(ctx.cwd);
21
+ const statuslineEnabled = settingBoolean("statusline.enabled", true, ctx.cwd);
22
+ const statuslineMode = statuslineEnabled
23
+ ? (settingBoolean("replaceFooter", true, ctx.cwd) ? "enabled, replaces footer" : "enabled, footer preserved")
24
+ : "disabled";
20
25
  return [
21
26
  "Pi QOL status",
22
- `Statusline: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? "π compact" : "default chrome"}`,
27
+ `Statusline: ${statuslineMode}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? `${glyphs(ctx.cwd).prompt} compact` : "default chrome"}`,
23
28
  `shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
24
29
  `Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
25
30
  `Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
@@ -28,6 +33,7 @@ export function statusMessage(ctx: ExtensionContext): string {
28
33
  `Rename command: ${settingBoolean("enableSessionNameCommand", true, ctx.cwd) ? "enabled (/rename)" : "disabled"}`,
29
34
  `Auto session rename: ${autoRenameEnabled(ctx.cwd) ? `enabled (${settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, ctx.cwd)})` : "disabled"}`,
30
35
  `Handoff command: ${settingBoolean("enableHandoffCommand", true, ctx.cwd) ? "enabled" : "disabled"}`,
36
+ `Schedule command: ${settingBoolean("enableScheduleCommand", true, ctx.cwd) ? "enabled (/schedule)" : "disabled"}`,
31
37
  `Handoff prompt review: ${settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? "enabled" : "disabled"}`,
32
38
  `Session search: ${settingBoolean("sessionSearch.enabled", true, ctx.cwd) ? `enabled (/search${searchShortcut ? `, ${searchShortcut}` : ""})` : "disabled"}`,
33
39
  `Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (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
  }
@@ -0,0 +1,28 @@
1
+ // Transcript risk for /context: estimate serialized request payload size and
2
+ // warn before it crosses a char budget, even when the token count alone has
3
+ // not reached the context window. Keeps /context independent of compaction.ts
4
+ // (no summarizer/fs/artifact dependencies on the UI path).
5
+
6
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
8
+ import { evaluateTranscriptRisk, type TranscriptRiskResult } from "./budget-guard.js";
9
+
10
+ function stripThinking(messages: any[]): any[] {
11
+ return messages.map((message: any) => {
12
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
13
+ return { ...message, content: message.content.filter((part: any) => part?.type !== "thinking") };
14
+ });
15
+ }
16
+
17
+ export function transcriptRiskState(messages: AgentMessage[], threshold: number): TranscriptRiskResult {
18
+ if (!Array.isArray(messages) || messages.length === 0 || threshold <= 0) {
19
+ return { chars: 0, exceeded: false, messageCount: messages?.length ?? 0, threshold };
20
+ }
21
+ try {
22
+ const serialized = serializeConversation(stripThinking(convertToLlm(messages)));
23
+ return evaluateTranscriptRisk({ chars: serialized.length, messageCount: messages.length, threshold });
24
+ } catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return evaluateTranscriptRisk({ chars: 0, error: message, messageCount: messages.length, threshold });
27
+ }
28
+ }