@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.
@@ -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: ${settingBoolean("replaceFooter", true, ctx.cwd) ? "replaces footer" : "footer preserved"}; prompt=${settingBoolean("compactPrompt", true, ctx.cwd) ? `${glyphs(ctx.cwd).prompt} compact` : "default chrome"}`,
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 { TUI } from "@earendil-works/pi-tui";
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
- void refreshStatusline(ctx);
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
- const statusWidgetTimer = setTimeout(() => {
562
- ctx.ui.setWidget("statusline", (tui, theme) => {
563
- activeTui = tui;
564
- return {
565
- invalidate() {},
566
- render(width: number): string[] {
567
- return [renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme)];
568
- },
569
- };
570
- });
571
- }, 0);
572
- statusWidgetTimer.unref?.();
573
- if (settingBoolean("replaceFooter", true, ctx.cwd)) {
574
- ctx.ui.setFooter((tui, _theme, footerData) => {
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
- return { dispose: unsubscribe, invalidate() {}, render: () => [] };
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", {