@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.
- package/README.md +38 -5
- 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/rate-limit-auto-resume.ts +447 -0
- package/extensions/qol/schedule.ts +320 -0
- package/extensions/qol/session-search/component.ts +33 -11
- package/extensions/qol/status-message.ts +7 -1
- package/extensions/qol/statusline.ts +5 -4
- package/extensions/qol/transcript-risk.ts +28 -0
- package/extensions/qol.ts +101 -21
- package/package.json +140 -5
|
@@ -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
|
-
|
|
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";
|
|
@@ -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: ${
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
}
|