@vanillagreen/pi-qol 1.5.0 → 1.7.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 +17 -6
- package/extensions/qol/budget-guard-runtime.ts +5 -0
- package/extensions/qol/notifications.ts +19 -5
- package/extensions/qol/rate-limit-auto-resume.ts +447 -0
- package/extensions/qol/schedule.ts +344 -0
- package/extensions/qol/status-message.ts +7 -2
- package/extensions/qol.ts +81 -22
- package/package.json +77 -3
|
@@ -0,0 +1,344 @@
|
|
|
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: "1h30m ", label: "1h30m <message>", description: "Send a message after 1 hour 30 minutes" },
|
|
60
|
+
{ value: "1h ", label: "1h <message>", description: "Send a message after 1 hour" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export function getScheduleArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
64
|
+
const query = prefix.trimStart().toLowerCase();
|
|
65
|
+
const filtered = SCHEDULE_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(query) || (item.label ?? item.value).toLowerCase().startsWith(query));
|
|
66
|
+
return filtered.length > 0 ? filtered : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseDurationMs(input: string): number | undefined {
|
|
70
|
+
const text = input.trim().toLowerCase();
|
|
71
|
+
if (!text) return undefined;
|
|
72
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return clampScheduleDelay(Number(text) * 60 * 1000);
|
|
73
|
+
|
|
74
|
+
let totalMs = 0;
|
|
75
|
+
let matched = false;
|
|
76
|
+
let previousRank = Number.POSITIVE_INFINITY;
|
|
77
|
+
const seenUnits = new Set<string>();
|
|
78
|
+
const re = /(\d+(?:\.\d+)?)(ms|seconds?|secs?|s|minutes?|mins?|min|m|hours?|hrs?|hr|h|days?|d)/gy;
|
|
79
|
+
|
|
80
|
+
while (re.lastIndex < text.length) {
|
|
81
|
+
const match = re.exec(text);
|
|
82
|
+
if (!match) return undefined;
|
|
83
|
+
const amount = Number(match[1]);
|
|
84
|
+
const unit = scheduleUnit(match[2]);
|
|
85
|
+
if (!Number.isFinite(amount) || amount < 0 || !unit) return undefined;
|
|
86
|
+
if (seenUnits.has(unit.name) || unit.rank >= previousRank) return undefined;
|
|
87
|
+
seenUnits.add(unit.name);
|
|
88
|
+
previousRank = unit.rank;
|
|
89
|
+
totalMs += amount * unit.multiplier;
|
|
90
|
+
matched = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const delayMs = Math.round(totalMs);
|
|
94
|
+
return matched ? clampScheduleDelay(delayMs) : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clampScheduleDelay(delayMs: number): number | undefined {
|
|
98
|
+
if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SCHEDULE_DELAY_MS) return undefined;
|
|
99
|
+
return delayMs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function scheduleUnit(unit: string): { multiplier: number; name: string; rank: number } | undefined {
|
|
103
|
+
if (unit === "ms") return { multiplier: 1, name: "ms", rank: 0 };
|
|
104
|
+
if (["s", "sec", "secs", "second", "seconds"].includes(unit)) return { multiplier: 1000, name: "s", rank: 1 };
|
|
105
|
+
if (["m", "min", "mins", "minute", "minutes"].includes(unit)) return { multiplier: 60 * 1000, name: "m", rank: 2 };
|
|
106
|
+
if (["h", "hr", "hrs", "hour", "hours"].includes(unit)) return { multiplier: 60 * 60 * 1000, name: "h", rank: 3 };
|
|
107
|
+
if (["d", "day", "days"].includes(unit)) return { multiplier: 24 * 60 * 60 * 1000, name: "d", rank: 4 };
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseScheduleCommandArgs(args: string): ParsedScheduleCommand {
|
|
112
|
+
const trimmed = args.trim();
|
|
113
|
+
if (!trimmed) return { kind: "list" };
|
|
114
|
+
const firstSpace = trimmed.search(/\s/);
|
|
115
|
+
const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
|
|
116
|
+
const rest = firstSpace < 0 ? "" : trimmed.slice(firstSpace + 1).trim();
|
|
117
|
+
if (head === "list" || head === "ls" || head === "status") return { kind: "list" };
|
|
118
|
+
if (head === "cancel" || head === "clear") {
|
|
119
|
+
if (!rest) return { kind: "usage", error: "Usage: /schedule cancel <id|all>" };
|
|
120
|
+
if (rest.toLowerCase() === "all") return { kind: "cancel", all: true };
|
|
121
|
+
return { kind: "cancel", all: false, id: rest };
|
|
122
|
+
}
|
|
123
|
+
const delayMs = parseDurationMs(head);
|
|
124
|
+
if (delayMs === undefined) return { kind: "usage", error: "Usage: /schedule <duration> <message>" };
|
|
125
|
+
if (!rest) return { kind: "usage", error: "Usage: /schedule <duration> <message>" };
|
|
126
|
+
return { kind: "schedule", delayMs, message: rest };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatScheduleDuration(ms: number): string {
|
|
130
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
131
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
132
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3600);
|
|
133
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
134
|
+
const seconds = totalSeconds % 60;
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
if (days) parts.push(`${days}d`);
|
|
137
|
+
if (hours) parts.push(`${hours}h`);
|
|
138
|
+
if (minutes) parts.push(`${minutes}m`);
|
|
139
|
+
if (seconds || parts.length === 0) parts.push(`${seconds}s`);
|
|
140
|
+
return parts.slice(0, 2).join(" ");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function previewMessage(message: string, max = 80): string {
|
|
144
|
+
const singleLine = message.replace(/\s+/g, " ").trim();
|
|
145
|
+
if (singleLine.length <= max) return singleLine;
|
|
146
|
+
return `${singleLine.slice(0, Math.max(0, max - 1))}…`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatDueTime(timestamp: number): string {
|
|
150
|
+
return new Date(timestamp).toLocaleString();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function usageText(): string {
|
|
154
|
+
return [
|
|
155
|
+
"Usage: /schedule <duration> <message>",
|
|
156
|
+
"Examples: /schedule 20m retry the previous request",
|
|
157
|
+
" /schedule 1h30m retry after the rate limit reset",
|
|
158
|
+
"Durations: 20, 20m, 90s, 500ms, 1h45m30s (bare numbers mean minutes)",
|
|
159
|
+
"Manage: /schedule list, /schedule cancel <id|all>",
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function dataToJob(data: ScheduleEntryData): ScheduledMessage | undefined {
|
|
164
|
+
if (data.action !== "scheduled") return undefined;
|
|
165
|
+
if (!data.id || typeof data.id !== "string") return undefined;
|
|
166
|
+
if (!data.message || typeof data.message !== "string") return undefined;
|
|
167
|
+
if (typeof data.createdAt !== "number" || typeof data.dueAt !== "number") return undefined;
|
|
168
|
+
if (!Number.isFinite(data.createdAt) || !Number.isFinite(data.dueAt)) return undefined;
|
|
169
|
+
return { createdAt: data.createdAt, dueAt: data.dueAt, id: data.id, message: data.message };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createScheduleController(pi: ExtensionAPI, clock: ScheduleClock = DEFAULT_CLOCK) {
|
|
173
|
+
let onChange: (() => void) | undefined;
|
|
174
|
+
let sequence = 0;
|
|
175
|
+
const jobs = new Map<string, ScheduledMessage>();
|
|
176
|
+
|
|
177
|
+
const notifyChanged = (): void => {
|
|
178
|
+
try {
|
|
179
|
+
onChange?.();
|
|
180
|
+
} catch {
|
|
181
|
+
// UI refresh hooks must never break schedule state transitions.
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const nextId = (): string => {
|
|
186
|
+
let id = "";
|
|
187
|
+
do {
|
|
188
|
+
sequence += 1;
|
|
189
|
+
id = `${clock.now().toString(36)}-${sequence.toString(36)}`;
|
|
190
|
+
} while (jobs.has(id));
|
|
191
|
+
return id;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const appendEntry = (data: ScheduleEntryData): void => {
|
|
195
|
+
try {
|
|
196
|
+
pi.appendEntry?.(SCHEDULE_ENTRY_TYPE, data);
|
|
197
|
+
} catch {
|
|
198
|
+
// Scheduling still works in-memory if session persistence is unavailable.
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const clearJobTimer = (job: ScheduledMessage): void => {
|
|
203
|
+
if (!job.timer) return;
|
|
204
|
+
clock.clearTimeout(job.timer);
|
|
205
|
+
job.timer = undefined;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const armJob = (job: ScheduledMessage, ctx: ExtensionContext): void => {
|
|
209
|
+
clearJobTimer(job);
|
|
210
|
+
const remaining = Math.max(0, job.dueAt - clock.now());
|
|
211
|
+
job.timer = clock.setTimeout(() => {
|
|
212
|
+
if (!jobs.has(job.id)) return;
|
|
213
|
+
if (clock.now() < job.dueAt) {
|
|
214
|
+
armJob(job, ctx);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
void deliver(job, ctx);
|
|
218
|
+
}, Math.min(remaining, MAX_TIMER_DELAY_MS));
|
|
219
|
+
job.timer.unref?.();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const deliver = async (job: ScheduledMessage, ctx: ExtensionContext): Promise<void> => {
|
|
223
|
+
if (!jobs.has(job.id)) return;
|
|
224
|
+
jobs.delete(job.id);
|
|
225
|
+
clearJobTimer(job);
|
|
226
|
+
notifyChanged();
|
|
227
|
+
try {
|
|
228
|
+
let idle = true;
|
|
229
|
+
try {
|
|
230
|
+
idle = ctx.isIdle?.() ?? true;
|
|
231
|
+
} catch {
|
|
232
|
+
idle = false;
|
|
233
|
+
}
|
|
234
|
+
await Promise.resolve(idle ? pi.sendUserMessage(job.message) : pi.sendUserMessage(job.message, { deliverAs: "followUp" }));
|
|
235
|
+
appendEntry({ action: "delivered", deliveredAt: clock.now(), id: job.id });
|
|
236
|
+
if (ctx.hasUI) ctx.ui.notify(`Scheduled message sent (${job.id}).`, "info");
|
|
237
|
+
} catch (error) {
|
|
238
|
+
appendEntry({ action: "failed", failedAt: clock.now(), id: job.id, message: stringifyError(error) });
|
|
239
|
+
if (ctx.hasUI) ctx.ui.notify(`Scheduled message failed (${job.id}): ${stringifyError(error)}`, "error");
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const listText = (): string => {
|
|
244
|
+
const now = clock.now();
|
|
245
|
+
const pending = [...jobs.values()].sort((a, b) => a.dueAt - b.dueAt);
|
|
246
|
+
if (pending.length === 0) return "No scheduled messages.";
|
|
247
|
+
return [
|
|
248
|
+
"Scheduled messages:",
|
|
249
|
+
...pending.map((job) => `${job.id} — in ${formatScheduleDuration(job.dueAt - now)} (${formatDueTime(job.dueAt)}) — ${previewMessage(job.message)}`),
|
|
250
|
+
].join("\n");
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const schedule = (delayMs: number, message: string, ctx: ExtensionCommandContext): void => {
|
|
254
|
+
if (jobs.size >= MAX_ACTIVE_SCHEDULES) {
|
|
255
|
+
ctx.ui.notify(`Too many scheduled messages (${MAX_ACTIVE_SCHEDULES}). Cancel one before adding another.`, "warning");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const createdAt = clock.now();
|
|
259
|
+
const job: ScheduledMessage = { createdAt, dueAt: createdAt + delayMs, id: nextId(), message };
|
|
260
|
+
jobs.set(job.id, job);
|
|
261
|
+
appendEntry({ action: "scheduled", createdAt: job.createdAt, dueAt: job.dueAt, id: job.id, message: job.message });
|
|
262
|
+
armJob(job, ctx);
|
|
263
|
+
notifyChanged();
|
|
264
|
+
ctx.ui.notify(`Scheduled ${job.id} in ${formatScheduleDuration(delayMs)} (${formatDueTime(job.dueAt)}): ${previewMessage(message)}`, "info");
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const cancel = (id: string): boolean => {
|
|
268
|
+
const job = jobs.get(id);
|
|
269
|
+
if (!job) return false;
|
|
270
|
+
clearJobTimer(job);
|
|
271
|
+
jobs.delete(id);
|
|
272
|
+
appendEntry({ action: "cancelled", cancelledAt: clock.now(), id });
|
|
273
|
+
notifyChanged();
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
activeCount(): number {
|
|
279
|
+
return jobs.size;
|
|
280
|
+
},
|
|
281
|
+
clearTimers(): void {
|
|
282
|
+
for (const job of jobs.values()) clearJobTimer(job);
|
|
283
|
+
const changed = jobs.size > 0;
|
|
284
|
+
jobs.clear();
|
|
285
|
+
if (changed) notifyChanged();
|
|
286
|
+
},
|
|
287
|
+
async handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
288
|
+
const parsed = parseScheduleCommandArgs(args);
|
|
289
|
+
if (parsed.kind === "usage") {
|
|
290
|
+
ctx.ui.notify(`${parsed.error ?? "Invalid schedule command"}\n${usageText()}`, "warning");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (parsed.kind === "list") {
|
|
294
|
+
ctx.ui.notify(`${listText()}\n\n${usageText()}`, "info");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (parsed.kind === "cancel") {
|
|
298
|
+
if (parsed.all) {
|
|
299
|
+
const count = jobs.size;
|
|
300
|
+
for (const job of [...jobs.values()]) cancel(job.id);
|
|
301
|
+
ctx.ui.notify(`Cancelled ${count} scheduled message(s).`, "info");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!parsed.id || !cancel(parsed.id)) {
|
|
305
|
+
ctx.ui.notify(`No scheduled message found for ${parsed.id ?? "<missing id>"}.`, "warning");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
ctx.ui.notify(`Cancelled scheduled message ${parsed.id}.`, "info");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
schedule(parsed.delayMs, parsed.message, ctx);
|
|
312
|
+
},
|
|
313
|
+
restoreFromBranch(ctx: ExtensionContext): void {
|
|
314
|
+
for (const job of jobs.values()) clearJobTimer(job);
|
|
315
|
+
jobs.clear();
|
|
316
|
+
const branch = ctx.sessionManager.getBranch?.() ?? [];
|
|
317
|
+
for (const entry of branch as any[]) {
|
|
318
|
+
if (entry?.type !== "custom" || entry.customType !== SCHEDULE_ENTRY_TYPE) continue;
|
|
319
|
+
const data = entry.data as ScheduleEntryData | undefined;
|
|
320
|
+
if (!data || typeof data !== "object") continue;
|
|
321
|
+
if (data.action === "scheduled") {
|
|
322
|
+
const job = dataToJob(data);
|
|
323
|
+
if (job) jobs.set(job.id, job);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (typeof data.id === "string") jobs.delete(data.id);
|
|
327
|
+
}
|
|
328
|
+
for (const job of jobs.values()) armJob(job, ctx);
|
|
329
|
+
notifyChanged();
|
|
330
|
+
},
|
|
331
|
+
renderPreviewLines(width: number): string[] {
|
|
332
|
+
const pending = [...jobs.values()].sort((a, b) => a.dueAt - b.dueAt);
|
|
333
|
+
const shown = pending.slice(0, 3).map((job) => {
|
|
334
|
+
const line = `┃ Scheduled ${formatDueTime(job.dueAt)} — ${previewMessage(job.message, 120)}`;
|
|
335
|
+
return truncateToWidth(ansiGreen(line), width, "");
|
|
336
|
+
});
|
|
337
|
+
if (pending.length > shown.length) shown.push(truncateToWidth(ansiGreen(`┃ Scheduled: +${pending.length - shown.length} more`), width, ""));
|
|
338
|
+
return shown;
|
|
339
|
+
},
|
|
340
|
+
setOnChange(callback: (() => void) | undefined): void {
|
|
341
|
+
onChange = callback;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -18,9 +18,13 @@ import { boundedSettingNumber, newlineFallbackKey, settingBoolean, settingNumber
|
|
|
18
18
|
export function statusMessage(ctx: ExtensionContext): string {
|
|
19
19
|
const labels = attachmentLabels(currentEditorText(ctx), ctx.cwd);
|
|
20
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";
|
|
21
25
|
return [
|
|
22
26
|
"Pi QOL status",
|
|
23
|
-
`Statusline: ${
|
|
27
|
+
`Statusline: ${statuslineMode}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? `${glyphs(ctx.cwd).prompt} compact` : "default chrome"}`,
|
|
24
28
|
`shift+enter newline: ${settingBoolean("newlineOnShiftEnter", true, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
25
29
|
`Fallback newline key: ${newlineFallbackKey(ctx.cwd)}`,
|
|
26
30
|
`Pending queue preview: ${settingBoolean("pendingQueue.asciiGreen", true, ctx.cwd) ? "ANSI green" : "Pi default"}`,
|
|
@@ -29,12 +33,13 @@ export function statusMessage(ctx: ExtensionContext): string {
|
|
|
29
33
|
`Rename command: ${settingBoolean("enableSessionNameCommand", true, ctx.cwd) ? "enabled (/rename)" : "disabled"}`,
|
|
30
34
|
`Auto session rename: ${autoRenameEnabled(ctx.cwd) ? `enabled (${settingString("sessionAutoRename.model", DEFAULT_AUTO_RENAME_MODEL, ctx.cwd)})` : "disabled"}`,
|
|
31
35
|
`Handoff command: ${settingBoolean("enableHandoffCommand", true, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
36
|
+
`Schedule command: ${settingBoolean("enableScheduleCommand", true, ctx.cwd) ? "enabled (/schedule)" : "disabled"}`,
|
|
32
37
|
`Handoff prompt review: ${settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
33
38
|
`Session search: ${settingBoolean("sessionSearch.enabled", true, ctx.cwd) ? `enabled (/search${searchShortcut ? `, ${searchShortcut}` : ""})` : "disabled"}`,
|
|
34
39
|
`Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
|
|
35
40
|
`Idle compaction: ${settingBoolean("compaction.idleEnabled", false, ctx.cwd) ? `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle` : "disabled"}`,
|
|
36
41
|
`Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
37
|
-
`Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
|
|
42
|
+
`Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, muteBell=${settingBoolean("notification.muteBellSound", false, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
|
|
38
43
|
`Permission gate: ${settingBoolean("permissionGate.enabled", false, ctx.cwd) ? `enabled (${permissionGateCommands(ctx.cwd).join(", ") || "none configured"}; preview ${boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, ctx.cwd)} lines/${boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, ctx.cwd)} chars)` : "disabled"}`,
|
|
39
44
|
`Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
40
45
|
"If shift+enter still submits, configure your terminal/tmux to send a distinct shift+enter sequence or use the fallback key.",
|
package/extensions/qol.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type
|
|
2
|
+
import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
|
|
4
4
|
import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
|
|
5
5
|
import { BudgetGuardDriver } from "./qol/budget-guard-runtime.js";
|
|
6
|
+
import { ansiGreen } from "./qol/ansi.js";
|
|
6
7
|
import {
|
|
7
8
|
budgetGuardTrigger,
|
|
8
9
|
compactionTriggerReason,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
restorePendingQueueThemePatch,
|
|
47
48
|
} from "./qol/pending-queue.js";
|
|
48
49
|
import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
|
|
50
|
+
import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
|
|
49
51
|
import {
|
|
50
52
|
autoRenameEnabled,
|
|
51
53
|
autoRenameNotify,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
isStaleCtxError,
|
|
56
58
|
withAutoRenamePrefix,
|
|
57
59
|
} from "./qol/session-rename.js";
|
|
60
|
+
import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
|
|
58
61
|
import {
|
|
59
62
|
consumePendingSessionSearchContext,
|
|
60
63
|
openQolSessionSearch,
|
|
@@ -260,9 +263,19 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
260
263
|
};
|
|
261
264
|
|
|
262
265
|
const budgetGuardDriver = new BudgetGuardDriver();
|
|
266
|
+
let budgetGuardStatus: string | undefined;
|
|
267
|
+
|
|
268
|
+
const setBudgetGuardStatus = (ctx: ExtensionContext, message: string | undefined) => {
|
|
269
|
+
budgetGuardStatus = message;
|
|
270
|
+
if (ctx.hasUI) {
|
|
271
|
+
ctx.ui.setStatus("qol-budget-guard", message ? ctx.ui.theme.fg("accent", message) : undefined);
|
|
272
|
+
requestRender();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
263
275
|
|
|
264
276
|
const resetBudgetGuard = () => {
|
|
265
277
|
budgetGuardDriver.reset();
|
|
278
|
+
budgetGuardStatus = undefined;
|
|
266
279
|
};
|
|
267
280
|
|
|
268
281
|
const maybeFireBudgetGuard = (ctx: ExtensionContext) => {
|
|
@@ -284,6 +297,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
284
297
|
budgetGuardDriver.dispatch({
|
|
285
298
|
compact: typeof ctx.compact === "function" ? ctx.compact.bind(ctx) : undefined,
|
|
286
299
|
notify: notifySafely,
|
|
300
|
+
onStatus: (message) => setBudgetGuardStatus(ctx, message),
|
|
287
301
|
trigger,
|
|
288
302
|
});
|
|
289
303
|
};
|
|
@@ -375,7 +389,13 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
375
389
|
let lastSessionTitle: string | undefined;
|
|
376
390
|
|
|
377
391
|
const requestRender = () => activeTui?.requestRender();
|
|
392
|
+
const scheduleController = createScheduleController(pi);
|
|
393
|
+
scheduleController.setOnChange(requestRender);
|
|
394
|
+
const rateLimitAutoResumeController = createRateLimitAutoResumeController(pi);
|
|
395
|
+
rateLimitAutoResumeController.setOnChange(requestRender);
|
|
396
|
+
const statuslineEnabled = (ctx: ExtensionContext): boolean => settingBoolean("statusline.enabled", true, ctx.cwd);
|
|
378
397
|
const refreshStatusline = (ctx: ExtensionContext) => {
|
|
398
|
+
if (!statuslineEnabled(ctx)) return Promise.resolve();
|
|
379
399
|
if (refreshInFlight) return refreshInFlight;
|
|
380
400
|
refreshInFlight = refreshGitState(pi, ctx)
|
|
381
401
|
.then((next) => {
|
|
@@ -534,6 +554,10 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
534
554
|
const event = data as QuestionOpenedEventLike;
|
|
535
555
|
notifyQuestionOpened(currentCtx, event, "question");
|
|
536
556
|
});
|
|
557
|
+
pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
|
|
558
|
+
if (!currentCtx) return;
|
|
559
|
+
rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
|
|
560
|
+
});
|
|
537
561
|
|
|
538
562
|
pi.on("session_start", (event, ctx) => {
|
|
539
563
|
currentCtx = ctx;
|
|
@@ -542,6 +566,10 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
542
566
|
resetAutoRename();
|
|
543
567
|
resetBudgetGuard();
|
|
544
568
|
resetThinkingTimer(ctx);
|
|
569
|
+
const scheduleEnabled = settingBoolean("enableScheduleCommand", true, ctx.cwd);
|
|
570
|
+
const rateLimitAutoResumeEnabled = rateLimitAutoResumeController.enabled(ctx);
|
|
571
|
+
if (scheduleEnabled) scheduleController.restoreFromBranch(ctx);
|
|
572
|
+
else scheduleController.clearTimers();
|
|
545
573
|
void consumePendingSessionSearchContext(pi, ctx, event.reason);
|
|
546
574
|
installAutocompleteHintStyling(ctx);
|
|
547
575
|
installPendingQueueThemePatch(ctx);
|
|
@@ -549,7 +577,8 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
549
577
|
ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
|
|
550
578
|
applyWorkingIndicatorMode(ctx);
|
|
551
579
|
gitState = makeFallbackGitState(ctx.cwd);
|
|
552
|
-
|
|
580
|
+
const showStatusline = statuslineEnabled(ctx);
|
|
581
|
+
if (showStatusline) void refreshStatusline(ctx);
|
|
553
582
|
installSessionTitle(ctx);
|
|
554
583
|
installSessionTitleSync(ctx);
|
|
555
584
|
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
@@ -558,27 +587,33 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
558
587
|
? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
|
|
559
588
|
: new QolEditor(tui, theme, keybindings, ctx);
|
|
560
589
|
});
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
activeTui = tui;
|
|
576
|
-
const unsubscribe = footerData.onBranchChange(() => {
|
|
577
|
-
void refreshStatusline(ctx);
|
|
578
|
-
requestRender();
|
|
590
|
+
if (showStatusline || scheduleEnabled || rateLimitAutoResumeEnabled) {
|
|
591
|
+
const statusWidgetTimer = setTimeout(() => {
|
|
592
|
+
ctx.ui.setWidget("statusline", (tui, theme) => {
|
|
593
|
+
activeTui = tui;
|
|
594
|
+
return {
|
|
595
|
+
invalidate() {},
|
|
596
|
+
render(width: number): string[] {
|
|
597
|
+
const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
|
|
598
|
+
if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
|
|
599
|
+
if (budgetGuardStatus) lines.push(truncateToWidth(ansiGreen(`┃ ${budgetGuardStatus}`), width, ""));
|
|
600
|
+
if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
|
|
601
|
+
return lines;
|
|
602
|
+
},
|
|
603
|
+
};
|
|
579
604
|
});
|
|
580
|
-
|
|
581
|
-
|
|
605
|
+
}, 0);
|
|
606
|
+
statusWidgetTimer.unref?.();
|
|
607
|
+
if (showStatusline && settingBoolean("replaceFooter", true, ctx.cwd)) {
|
|
608
|
+
ctx.ui.setFooter((tui, _theme, footerData) => {
|
|
609
|
+
activeTui = tui;
|
|
610
|
+
const unsubscribe = footerData.onBranchChange(() => {
|
|
611
|
+
void refreshStatusline(ctx);
|
|
612
|
+
requestRender();
|
|
613
|
+
});
|
|
614
|
+
return { dispose: unsubscribe, invalidate() {}, render: () => [] };
|
|
615
|
+
});
|
|
616
|
+
}
|
|
582
617
|
}
|
|
583
618
|
}
|
|
584
619
|
startQuestionSubscription(ctx);
|
|
@@ -600,6 +635,8 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
600
635
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
601
636
|
cavemanUnsubscribe?.();
|
|
602
637
|
cavemanUnsubscribe = undefined;
|
|
638
|
+
scheduleController.clearTimers();
|
|
639
|
+
rateLimitAutoResumeController.clearTimers();
|
|
603
640
|
resetAutoRename();
|
|
604
641
|
resetBudgetGuard();
|
|
605
642
|
clearIdleCompactionTimer();
|
|
@@ -611,10 +648,12 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
611
648
|
questionUnsubscribe?.();
|
|
612
649
|
questionUnsubscribe = undefined;
|
|
613
650
|
currentCtx = undefined;
|
|
651
|
+
budgetGuardStatus = undefined;
|
|
614
652
|
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
615
653
|
if (host[QOL_NOTIFICATION_SERVICE_SYMBOL] === notificationService) delete host[QOL_NOTIFICATION_SERVICE_SYMBOL];
|
|
616
654
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
617
655
|
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
656
|
+
ctx.ui.setStatus("qol-budget-guard", undefined);
|
|
618
657
|
restorePendingQueueThemePatch(ctx);
|
|
619
658
|
resetStatuslineUi(ctx);
|
|
620
659
|
ctx.ui.setEditorComponent(undefined);
|
|
@@ -631,6 +670,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
631
670
|
pi.on("agent_start", (_event, ctx) => {
|
|
632
671
|
clearIdleCompactionTimer();
|
|
633
672
|
clearTmuxWindowMark();
|
|
673
|
+
rateLimitAutoResumeController.noteAgentStart(ctx);
|
|
634
674
|
if (ctx.hasUI) {
|
|
635
675
|
void refreshStatusline(ctx);
|
|
636
676
|
requestRender();
|
|
@@ -657,6 +697,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
657
697
|
}
|
|
658
698
|
});
|
|
659
699
|
pi.on("message_end", (event, ctx) => {
|
|
700
|
+
rateLimitAutoResumeController.noteMessageEnd(event, ctx);
|
|
660
701
|
if (!updateThinkingTimerEnabled(ctx)) return;
|
|
661
702
|
const message = event.message as any;
|
|
662
703
|
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
@@ -670,6 +711,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
670
711
|
void refreshStatusline(ctx);
|
|
671
712
|
requestRender();
|
|
672
713
|
}
|
|
714
|
+
rateLimitAutoResumeController.noteAgentEnd(event, ctx);
|
|
673
715
|
maybeFireBudgetGuard(ctx);
|
|
674
716
|
scheduleIdleCompaction(ctx);
|
|
675
717
|
void attemptAutoRename(ctx);
|
|
@@ -694,6 +736,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
694
736
|
sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
|
|
695
737
|
});
|
|
696
738
|
pi.on("session_compact", (_event, ctx) => {
|
|
739
|
+
if (budgetGuardStatus) setBudgetGuardStatus(ctx, "QOL budget guard finalizing compaction…");
|
|
697
740
|
// After a successful compaction usage drops below the budget, so reset the
|
|
698
741
|
// crossing key so the next threshold crossing re-fires the guard.
|
|
699
742
|
budgetGuardDriver.noteSessionCompacted();
|
|
@@ -701,6 +744,14 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
701
744
|
void refreshStatusline(ctx);
|
|
702
745
|
requestRender();
|
|
703
746
|
});
|
|
747
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
748
|
+
currentCtx = ctx;
|
|
749
|
+
if (settingBoolean("enableScheduleCommand", true, ctx.cwd)) scheduleController.restoreFromBranch(ctx);
|
|
750
|
+
else scheduleController.clearTimers();
|
|
751
|
+
});
|
|
752
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
753
|
+
rateLimitAutoResumeController.noteProviderResponse(event, ctx);
|
|
754
|
+
});
|
|
704
755
|
pi.on("session_before_compact", (event, ctx) => handleQolCompaction(event, ctx));
|
|
705
756
|
pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
|
|
706
757
|
pi.on("tool_call", async (event: any, ctx) => {
|
|
@@ -758,6 +809,14 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
758
809
|
});
|
|
759
810
|
}
|
|
760
811
|
|
|
812
|
+
if (settingBoolean("enableScheduleCommand", true)) {
|
|
813
|
+
pi.registerCommand("schedule", {
|
|
814
|
+
description: "Send a user message after a timer without invoking the model now.",
|
|
815
|
+
getArgumentCompletions: getScheduleArgumentCompletions,
|
|
816
|
+
handler: async (args, ctx) => scheduleController.handleCommand(args, ctx),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
761
820
|
if (settingBoolean("enableContextCommand", true)) {
|
|
762
821
|
pi.registerMessageRenderer(CONTEXT_USAGE_MESSAGE_TYPE, renderQolContextUsageMessage);
|
|
763
822
|
pi.registerCommand("context", {
|