@vanillagreen/pi-qol 1.5.0 → 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 CHANGED
@@ -5,16 +5,18 @@
5
5
  ![Session search popup](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/session-search.gif)
6
6
  ![/context usage breakdown](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/context-usage.png)
7
7
 
8
- Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, notifications, and a permission gate.
8
+ Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, scheduled prompts, notifications, and a permission gate.
9
9
 
10
10
  ## Highlights
11
11
 
12
- - Compact statusline with repo, branch, model, thinking level, and context percent.
12
+ - Compact statusline with repo, branch, model, thinking level, and context percent; can be disabled.
13
13
  - Newline-insert in the editor (multi-line drafts without auto-submit), with a fallback binding for terminals that can't distinguish the primary key.
14
14
  - Sessions auto-name from your first prompt. `/rename` overrides anytime.
15
15
  - `/search` browses previous sessions with snippet previews; the configured shortcut opens it instantly.
16
16
  - `/context` shows a Claude-style context-window breakdown.
17
17
  - `/handoff <goal>` drafts a focused prompt for a new session.
18
+ - `/schedule 20m <message>` sends a delayed prompt without invoking the model until the timer fires.
19
+ - Optional rate-limit auto-resume sends a configurable continuation after reset.
18
20
  - Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
19
21
  - Notifications for ready, questions, blocked states, and task completion.
20
22
  - Thinking timer next to collapsed `Thinking...` labels.
@@ -51,9 +53,12 @@ Restart Pi after installation.
51
53
  | `/search [query]` | Open previous-session search. |
52
54
  | `/search:refresh` | Refresh the session search cache. |
53
55
  | `/handoff <goal>` | Draft a handoff prompt for a new session. |
56
+ | `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule 20m retry the previous request`. |
54
57
 
55
58
  Arguments support autocomplete.
56
59
 
60
+ `/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Pending prompts render above the statusline like steering/follow-up previews until they are sent or cancelled. Manage pending prompts with `/schedule list` and `/schedule cancel <id|all>`. Schedules are stored in the Pi session and re-armed on reload/resume; if Pi is not running at the due time, an overdue prompt sends when that session is next loaded.
61
+
57
62
  ## Settings
58
63
 
59
64
  Open `/extensions:settings`; settings appear under the **QOL** tab. Names below match the labels shown there.
@@ -65,7 +70,8 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
65
70
  | Setting | What it does |
66
71
  | --- | --- |
67
72
  | Enable QOL editor helpers | Master toggle for QOL statusline, commands, notifications, search, compaction, and editor helpers. |
68
- | Replace built-in footer | Hide Pi's default footer while the QOL statusline is active. |
73
+ | Show compact statusline | Render or disable the QOL statusline row. |
74
+ | Replace built-in footer | Hide Pi's default footer while the QOL statusline is enabled. |
69
75
  | Use π prompt editor | Use the compact prompt editor. |
70
76
  | Show session name title | Show the session name above the prompt and in the tmux pane title. |
71
77
  | Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
@@ -82,11 +88,13 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
82
88
  | Style image chips | Render `[Image #N]` placeholders as distinct chips. |
83
89
  | Show attachment count | Show a status badge when the draft has image placeholders. |
84
90
 
85
- ### Session naming
91
+ ### Session
86
92
 
87
93
  | Setting | What it does |
88
94
  | --- | --- |
89
95
  | Enable /rename command | Register the `/rename` command. |
96
+ | Enable /schedule command | Register `/schedule` for timer-based prompts, useful for retrying after rate limits reset. |
97
+ | Auto-resume after rate limits | Send the configured continuation after a detected reset; cancels on newer turn. |
90
98
  | Auto-name new sessions | Generate a friendly session name from the first prompt. |
91
99
  | Auto-rename model | Model used for title generation. |
92
100
  | Auto-rename fallback model | Model tried when the primary fails. |
@@ -0,0 +1,447 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ansiRed } from "./ansi.js";
4
+ import { boundedSettingNumber, settingBoolean, settingString } from "./settings.js";
5
+ import { stringifyError } from "./util.js";
6
+
7
+ export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
8
+ export const DEFAULT_RATE_LIMIT_RESUME_MESSAGE = "Hit API rate limit. It has been reset. Continue";
9
+
10
+ const MAX_TIMER_DELAY_MS = 2_147_000_000;
11
+ const DEFAULT_BUFFER_SECONDS = 10;
12
+ const DEFAULT_MAX_DELAY_MINUTES = 24 * 60;
13
+ const RATE_LIMIT_TOKEN = "[rate-limit]";
14
+
15
+ interface RateLimitTimer {
16
+ unref?: () => void;
17
+ }
18
+
19
+ export interface RateLimitClock {
20
+ now(): number;
21
+ setTimeout(callback: () => void, delayMs: number): RateLimitTimer;
22
+ clearTimeout(timer: RateLimitTimer): void;
23
+ }
24
+
25
+ export interface RateLimitHint {
26
+ message?: string;
27
+ model?: string;
28
+ observedAt: number;
29
+ provider?: string;
30
+ reason?: string;
31
+ resetAt?: number;
32
+ source: string;
33
+ turnId: number;
34
+ }
35
+
36
+ interface PendingRateLimitResume {
37
+ dueAt: number;
38
+ message: string;
39
+ reason?: string;
40
+ resetAt: number;
41
+ source: string;
42
+ timer?: RateLimitTimer;
43
+ turnId: number;
44
+ }
45
+
46
+ const DEFAULT_CLOCK: RateLimitClock = {
47
+ now: () => Date.now(),
48
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
49
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
50
+ };
51
+
52
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
53
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
54
+ }
55
+
56
+ function stringValue(value: unknown): string | undefined {
57
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
58
+ }
59
+
60
+ function numberValue(value: unknown): number | undefined {
61
+ const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN;
62
+ return Number.isFinite(parsed) ? parsed : undefined;
63
+ }
64
+
65
+ function allHeaderValues(headers: unknown, key: string): string[] {
66
+ const wanted = key.toLowerCase();
67
+ if (!headers || typeof headers !== "object") return [];
68
+ const anyHeaders = headers as any;
69
+ try {
70
+ if (typeof anyHeaders.get === "function") {
71
+ const value = anyHeaders.get(key) ?? anyHeaders.get(wanted);
72
+ if (typeof value === "string" && value.trim()) return [value.trim()];
73
+ }
74
+ } catch {
75
+ // Fall through to object form.
76
+ }
77
+ const out: string[] = [];
78
+ for (const [rawKey, rawValue] of Object.entries(headers as Record<string, unknown>)) {
79
+ if (rawKey.toLowerCase() !== wanted) continue;
80
+ if (Array.isArray(rawValue)) {
81
+ for (const item of rawValue) if (typeof item === "string" && item.trim()) out.push(item.trim());
82
+ } else if (typeof rawValue === "string" && rawValue.trim()) out.push(rawValue.trim());
83
+ else if (typeof rawValue === "number" && Number.isFinite(rawValue)) out.push(String(rawValue));
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export function looksLikeRateLimitText(text: string): boolean {
89
+ return /\b(rate[-\s]?limit(?:ed)?|too many requests|\b429\b|quota exceeded|resource exhausted|try again later|retry[-\s]?after|out of extra usage|extra usage)\b/i.test(text);
90
+ }
91
+
92
+ function parseUnitDurationMs(amount: number, unit: string | undefined, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
93
+ if (!Number.isFinite(amount) || amount < 0) return undefined;
94
+ const normalized = (unit ?? defaultUnit).toLowerCase();
95
+ const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(normalized)
96
+ ? 1
97
+ : ["s", "sec", "secs", "second", "seconds"].includes(normalized)
98
+ ? 1000
99
+ : ["m", "min", "mins", "minute", "minutes"].includes(normalized)
100
+ ? 60 * 1000
101
+ : ["h", "hr", "hrs", "hour", "hours"].includes(normalized)
102
+ ? 60 * 60 * 1000
103
+ : ["d", "day", "days"].includes(normalized)
104
+ ? 24 * 60 * 60 * 1000
105
+ : undefined;
106
+ if (!multiplier) return undefined;
107
+ const delay = Math.round(amount * multiplier);
108
+ return Number.isFinite(delay) ? delay : undefined;
109
+ }
110
+
111
+ export function parseDurationLikeMs(value: string, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
112
+ const text = value.trim().toLowerCase();
113
+ if (!text) return undefined;
114
+ if (/^\d+(?:\.\d+)?$/.test(text)) return parseUnitDurationMs(Number(text), undefined, defaultUnit);
115
+ const single = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/i);
116
+ if (single) return parseUnitDurationMs(Number(single[1]), single[2], defaultUnit);
117
+ let total = 0;
118
+ let matched = false;
119
+ const re = /(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)/gi;
120
+ for (const match of text.matchAll(re)) {
121
+ const part = parseUnitDurationMs(Number(match[1]), match[2], defaultUnit);
122
+ if (part === undefined) return undefined;
123
+ total += part;
124
+ matched = true;
125
+ }
126
+ return matched && Number.isFinite(total) ? Math.round(total) : undefined;
127
+ }
128
+
129
+ export function parseRetryAfterMs(value: string, now = Date.now(), defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
130
+ const duration = parseDurationLikeMs(value, defaultUnit);
131
+ if (duration !== undefined) return now + duration;
132
+ const parsedDate = Date.parse(value);
133
+ return Number.isFinite(parsedDate) ? parsedDate : undefined;
134
+ }
135
+
136
+ export function parseResetValueMs(value: string, now = Date.now()): number | undefined {
137
+ const trimmed = value.trim();
138
+ if (!trimmed) return undefined;
139
+ const numeric = Number(trimmed);
140
+ if (Number.isFinite(numeric)) {
141
+ if (numeric > 1_000_000_000_000) return numeric;
142
+ if (numeric > 1_000_000_000) return numeric * 1000;
143
+ return now + numeric * 1000;
144
+ }
145
+ const parsedDate = Date.parse(trimmed);
146
+ if (Number.isFinite(parsedDate)) return parsedDate;
147
+ const duration = parseDurationLikeMs(trimmed, "s");
148
+ return duration !== undefined ? now + duration : undefined;
149
+ }
150
+
151
+ export function extractResetAtFromHeaders(headers: unknown, now = Date.now()): { resetAt: number; source: string } | undefined {
152
+ for (const value of allHeaderValues(headers, "retry-after-ms")) {
153
+ const resetAt = parseRetryAfterMs(value, now, "ms");
154
+ if (resetAt !== undefined) return { resetAt, source: "retry-after-ms" };
155
+ }
156
+ for (const value of allHeaderValues(headers, "retry-after")) {
157
+ const resetAt = parseRetryAfterMs(value, now, "s");
158
+ if (resetAt !== undefined) return { resetAt, source: "retry-after" };
159
+ }
160
+ const resetHeaders = [
161
+ "x-ratelimit-reset",
162
+ "x-rate-limit-reset",
163
+ "ratelimit-reset",
164
+ "x-ratelimit-reset-requests",
165
+ "x-ratelimit-reset-tokens",
166
+ "anthropic-ratelimit-requests-reset",
167
+ "anthropic-ratelimit-tokens-reset",
168
+ "anthropic-ratelimit-input-tokens-reset",
169
+ "anthropic-ratelimit-output-tokens-reset",
170
+ ];
171
+ for (const header of resetHeaders) {
172
+ for (const value of allHeaderValues(headers, header)) {
173
+ const resetAt = parseResetValueMs(value, now);
174
+ if (resetAt !== undefined) return { resetAt, source: header };
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ function parseClockReset(text: string, now = Date.now()): number | undefined {
181
+ const match = text.match(/\bresets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(am|pm)?\b/i);
182
+ if (!match) return undefined;
183
+ let hour = Number(match[1]);
184
+ const minute = Number(match[2] ?? "0");
185
+ const second = Number(match[3] ?? "0");
186
+ const meridiem = match[4]?.toLowerCase();
187
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) || !Number.isInteger(second)) return undefined;
188
+ if (minute > 59 || second > 59) return undefined;
189
+ if (meridiem) {
190
+ if (hour < 1 || hour > 12) return undefined;
191
+ if (meridiem === "pm" && hour !== 12) hour += 12;
192
+ if (meridiem === "am" && hour === 12) hour = 0;
193
+ } else if (hour > 23) return undefined;
194
+ const date = new Date(now);
195
+ date.setHours(hour, minute, second, 0);
196
+ if (date.getTime() <= now) date.setDate(date.getDate() + 1);
197
+ return date.getTime();
198
+ }
199
+
200
+ export function extractResetAtFromText(text: string, now = Date.now()): { resetAt: number; source: string } | undefined {
201
+ const iso = text.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b/);
202
+ if (iso) {
203
+ const resetAt = Date.parse(iso[0]);
204
+ if (Number.isFinite(resetAt)) return { resetAt, source: "text-iso" };
205
+ }
206
+ const durationMatch = text.match(/\b(?:retry(?:\s+after|\s+in)?|try again in|wait)\s+(\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)(?:\s*\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?))*)\b/i);
207
+ if (durationMatch) {
208
+ const duration = parseDurationLikeMs(durationMatch[1], "s");
209
+ if (duration !== undefined) return { resetAt: now + duration, source: "text-duration" };
210
+ }
211
+ const clock = parseClockReset(text, now);
212
+ return clock !== undefined ? { resetAt: clock, source: "text-clock" } : undefined;
213
+ }
214
+
215
+ function messageText(message: any): string {
216
+ const parts: string[] = [];
217
+ if (typeof message?.errorMessage === "string") parts.push(message.errorMessage);
218
+ if (typeof message?.content === "string") parts.push(message.content);
219
+ else if (Array.isArray(message?.content)) {
220
+ for (const block of message.content) {
221
+ if (typeof block === "string") parts.push(block);
222
+ else if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
223
+ }
224
+ }
225
+ return parts.filter(Boolean).join("\n");
226
+ }
227
+
228
+ function lastAssistantMessage(event: any, ctx: ExtensionContext): any | undefined {
229
+ const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
230
+ for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
231
+ const message = eventMessages[index];
232
+ if (message?.role === "assistant") return message;
233
+ }
234
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
235
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
236
+ const entry = branch[index] as any;
237
+ if (entry?.type === "message" && entry.message?.role === "assistant") return entry.message;
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ function formatDueTime(timestamp: number): string {
243
+ return new Date(timestamp).toLocaleString(undefined, {
244
+ day: "numeric",
245
+ hour: "numeric",
246
+ minute: "2-digit",
247
+ month: "short",
248
+ timeZoneName: "short",
249
+ });
250
+ }
251
+
252
+ function previewMessage(message: string, max = 72): string {
253
+ const singleLine = message.replace(/\s+/g, " ").trim();
254
+ return singleLine.length <= max ? singleLine : `${singleLine.slice(0, Math.max(0, max - 1))}…`;
255
+ }
256
+
257
+ function settingsEnabled(ctx: ExtensionContext): boolean {
258
+ return settingBoolean("rateLimitAutoResume.enabled", false, ctx.cwd);
259
+ }
260
+
261
+ function settingsNotify(ctx: ExtensionContext): boolean {
262
+ return settingBoolean("rateLimitAutoResume.notify", true, ctx.cwd);
263
+ }
264
+
265
+ function settingsMessage(ctx: ExtensionContext): string {
266
+ return settingString("rateLimitAutoResume.message", DEFAULT_RATE_LIMIT_RESUME_MESSAGE, ctx.cwd);
267
+ }
268
+
269
+ function settingsBufferMs(ctx: ExtensionContext): number {
270
+ return boundedSettingNumber("rateLimitAutoResume.bufferSeconds", DEFAULT_BUFFER_SECONDS, 0, 600, ctx.cwd) * 1000;
271
+ }
272
+
273
+ function settingsMaxDelayMs(ctx: ExtensionContext): number {
274
+ return boundedSettingNumber("rateLimitAutoResume.maxDelayMinutes", DEFAULT_MAX_DELAY_MINUTES, 1, 30 * 24 * 60, ctx.cwd) * 60 * 1000;
275
+ }
276
+
277
+ export function createRateLimitAutoResumeController(pi: ExtensionAPI, clock: RateLimitClock = DEFAULT_CLOCK) {
278
+ let onChange: (() => void) | undefined;
279
+ let turnId = 0;
280
+ let latestHint: RateLimitHint | undefined;
281
+ let pending: PendingRateLimitResume | undefined;
282
+
283
+ const notifyChanged = (): void => {
284
+ try { onChange?.(); }
285
+ catch { /* UI refresh must never break recovery state. */ }
286
+ };
287
+
288
+ const clearPendingTimer = (): void => {
289
+ if (!pending?.timer) return;
290
+ clock.clearTimeout(pending.timer);
291
+ pending.timer = undefined;
292
+ };
293
+
294
+ const clearPending = (): void => {
295
+ const hadPending = Boolean(pending);
296
+ clearPendingTimer();
297
+ pending = undefined;
298
+ if (hadPending) notifyChanged();
299
+ };
300
+
301
+ const noteHint = (hint: Omit<RateLimitHint, "observedAt" | "turnId"> & { observedAt?: number; turnId?: number }): void => {
302
+ if (hint.resetAt !== undefined && (!Number.isFinite(hint.resetAt) || hint.resetAt <= 0)) return;
303
+ latestHint = {
304
+ ...hint,
305
+ observedAt: hint.observedAt ?? clock.now(),
306
+ turnId: hint.turnId ?? turnId,
307
+ };
308
+ };
309
+
310
+ const deliverPending = async (snapshot: PendingRateLimitResume, ctx: ExtensionContext): Promise<void> => {
311
+ if (pending !== snapshot) return;
312
+ if (snapshot.turnId !== turnId || !settingsEnabled(ctx)) {
313
+ clearPending();
314
+ return;
315
+ }
316
+ if (clock.now() < snapshot.dueAt) {
317
+ armPending(snapshot, ctx);
318
+ return;
319
+ }
320
+ clearPending();
321
+ try {
322
+ let idle = true;
323
+ try { idle = ctx.isIdle?.() ?? true; }
324
+ catch { idle = false; }
325
+ await Promise.resolve(idle ? pi.sendUserMessage(snapshot.message) : pi.sendUserMessage(snapshot.message, { deliverAs: "followUp" }));
326
+ if (ctx.hasUI && settingsNotify(ctx)) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume sent after rate-limit reset.`, "info");
327
+ } catch (error) {
328
+ if (ctx.hasUI) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume failed: ${stringifyError(error)}`, "error");
329
+ }
330
+ };
331
+
332
+ function armPending(snapshot: PendingRateLimitResume, ctx: ExtensionContext): void {
333
+ clearPendingTimer();
334
+ const remaining = Math.max(0, snapshot.dueAt - clock.now());
335
+ snapshot.timer = clock.setTimeout(() => { void deliverPending(snapshot, ctx); }, Math.min(remaining, MAX_TIMER_DELAY_MS));
336
+ snapshot.timer.unref?.();
337
+ }
338
+
339
+ const scheduleFromHint = (hint: RateLimitHint, ctx: ExtensionContext): boolean => {
340
+ if (!settingsEnabled(ctx)) return false;
341
+ if (hint.turnId !== turnId) return false;
342
+ if (ctx.hasPendingMessages?.()) return false;
343
+ if (hint.resetAt === undefined || !Number.isFinite(hint.resetAt)) return false;
344
+ const dueAt = Math.max(clock.now() + 1000, hint.resetAt + settingsBufferMs(ctx));
345
+ const delay = dueAt - clock.now();
346
+ if (delay > settingsMaxDelayMs(ctx)) return false;
347
+ const message = settingsMessage(ctx);
348
+ clearPendingTimer();
349
+ pending = { dueAt, message, reason: hint.reason, resetAt: hint.resetAt, source: hint.source, turnId };
350
+ armPending(pending, ctx);
351
+ notifyChanged();
352
+ if (ctx.hasUI && settingsNotify(ctx)) {
353
+ ctx.ui.notify(`${RATE_LIMIT_TOKEN} Rate limit hit. Auto-resume scheduled for ${formatDueTime(dueAt)}.`, "warning");
354
+ }
355
+ return true;
356
+ };
357
+
358
+ return {
359
+ enabled(ctx: ExtensionContext): boolean {
360
+ return settingsEnabled(ctx);
361
+ },
362
+ clearTimers(): void {
363
+ clearPending();
364
+ },
365
+ noteAgentStart(_ctx: ExtensionContext): void {
366
+ turnId += 1;
367
+ latestHint = undefined;
368
+ clearPending();
369
+ },
370
+ noteAgentEnd(event: any, ctx: ExtensionContext): boolean {
371
+ const assistant = lastAssistantMessage(event, ctx);
372
+ const text = messageText(assistant);
373
+ const textShowsRateLimit = Boolean(text && looksLikeRateLimitText(text));
374
+ if (textShowsRateLimit) {
375
+ const reset = extractResetAtFromText(text, clock.now());
376
+ noteHint({
377
+ message: text,
378
+ model: stringValue(assistant?.model),
379
+ provider: stringValue(assistant?.provider),
380
+ reason: "assistant error",
381
+ resetAt: reset?.resetAt ?? latestHint?.resetAt,
382
+ source: reset?.source ?? latestHint?.source ?? "assistant-message",
383
+ });
384
+ }
385
+ const stoppedOnHint = !assistant || assistant?.stopReason === "error" || assistant?.stopReason === "aborted";
386
+ if (!textShowsRateLimit && !stoppedOnHint) return false;
387
+ return latestHint ? scheduleFromHint(latestHint, ctx) : false;
388
+ },
389
+ noteMessageEnd(event: any, _ctx: ExtensionContext): void {
390
+ const message = event?.message;
391
+ if (!message || message.role !== "assistant") return;
392
+ const text = messageText(message);
393
+ if (!looksLikeRateLimitText(text)) return;
394
+ const reset = extractResetAtFromText(text, clock.now());
395
+ noteHint({
396
+ message: text,
397
+ model: stringValue(message.model),
398
+ provider: stringValue(message.provider),
399
+ reason: "assistant message",
400
+ resetAt: reset?.resetAt ?? latestHint?.resetAt,
401
+ source: reset?.source ?? latestHint?.source ?? "message_end",
402
+ });
403
+ },
404
+ noteProviderResponse(event: any, _ctx: ExtensionContext): void {
405
+ if (Number(event?.status) !== 429) return;
406
+ const reset = extractResetAtFromHeaders(event?.headers, clock.now());
407
+ noteHint({
408
+ model: stringValue(event?.model?.id) ?? stringValue(event?.model),
409
+ provider: stringValue(event?.provider?.id) ?? stringValue(event?.provider),
410
+ reason: "HTTP 429",
411
+ resetAt: reset?.resetAt ?? latestHint?.resetAt,
412
+ source: reset?.source ?? latestHint?.source ?? "after_provider_response",
413
+ });
414
+ },
415
+ noteExternalRateLimitEvent(payload: unknown, _ctx: ExtensionContext): void {
416
+ const event = asRecord(payload);
417
+ if (!event) return;
418
+ const status = stringValue(event.status);
419
+ if (status && status !== "rejected" && status !== "rate_limited" && status !== "error") return;
420
+ const rawReset = numberValue(event.resetAtMs) ?? numberValue(event.resetAtUnixMs);
421
+ const textReset = stringValue(event.resetAt) ?? stringValue(event.resetsAt);
422
+ const parsedTextReset = textReset ? parseResetValueMs(textReset, clock.now()) : undefined;
423
+ const resetAt = rawReset ?? parsedTextReset;
424
+ noteHint({
425
+ message: stringValue(event.message),
426
+ model: stringValue(event.model),
427
+ provider: stringValue(event.provider),
428
+ reason: stringValue(event.reason) ?? stringValue(event.rateLimitType),
429
+ resetAt: resetAt ?? latestHint?.resetAt,
430
+ source: stringValue(event.source) ?? latestHint?.source ?? RATE_LIMIT_AUTO_RESUME_EVENT,
431
+ });
432
+ },
433
+ renderPreviewLines(width: number): string[] {
434
+ if (!pending) return [];
435
+ const line = `| ${RATE_LIMIT_TOKEN} Auto-resume ${formatDueTime(pending.dueAt)} — ${previewMessage(pending.message)}`;
436
+ return [truncateToWidth(ansiRed(line), width, "")];
437
+ },
438
+ setOnChange(callback: (() => void) | undefined): void {
439
+ onChange = callback;
440
+ },
441
+ // Test helper / low-level extension bridge.
442
+ scheduleForHint(hint: RateLimitHint, ctx: ExtensionContext): boolean {
443
+ latestHint = hint;
444
+ return scheduleFromHint(hint, ctx);
445
+ },
446
+ };
447
+ }
@@ -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
+ }
@@ -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,6 +33,7 @@ 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)"}`,
package/extensions/qol.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  restorePendingQueueThemePatch,
47
47
  } from "./qol/pending-queue.js";
48
48
  import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
49
+ import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
49
50
  import {
50
51
  autoRenameEnabled,
51
52
  autoRenameNotify,
@@ -55,6 +56,7 @@ import {
55
56
  isStaleCtxError,
56
57
  withAutoRenamePrefix,
57
58
  } from "./qol/session-rename.js";
59
+ import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
58
60
  import {
59
61
  consumePendingSessionSearchContext,
60
62
  openQolSessionSearch,
@@ -375,7 +377,13 @@ export default function qol(pi: ExtensionAPI): void {
375
377
  let lastSessionTitle: string | undefined;
376
378
 
377
379
  const requestRender = () => activeTui?.requestRender();
380
+ const scheduleController = createScheduleController(pi);
381
+ scheduleController.setOnChange(requestRender);
382
+ const rateLimitAutoResumeController = createRateLimitAutoResumeController(pi);
383
+ rateLimitAutoResumeController.setOnChange(requestRender);
384
+ const statuslineEnabled = (ctx: ExtensionContext): boolean => settingBoolean("statusline.enabled", true, ctx.cwd);
378
385
  const refreshStatusline = (ctx: ExtensionContext) => {
386
+ if (!statuslineEnabled(ctx)) return Promise.resolve();
379
387
  if (refreshInFlight) return refreshInFlight;
380
388
  refreshInFlight = refreshGitState(pi, ctx)
381
389
  .then((next) => {
@@ -534,6 +542,10 @@ export default function qol(pi: ExtensionAPI): void {
534
542
  const event = data as QuestionOpenedEventLike;
535
543
  notifyQuestionOpened(currentCtx, event, "question");
536
544
  });
545
+ pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
546
+ if (!currentCtx) return;
547
+ rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
548
+ });
537
549
 
538
550
  pi.on("session_start", (event, ctx) => {
539
551
  currentCtx = ctx;
@@ -542,6 +554,10 @@ export default function qol(pi: ExtensionAPI): void {
542
554
  resetAutoRename();
543
555
  resetBudgetGuard();
544
556
  resetThinkingTimer(ctx);
557
+ const scheduleEnabled = settingBoolean("enableScheduleCommand", true, ctx.cwd);
558
+ const rateLimitAutoResumeEnabled = rateLimitAutoResumeController.enabled(ctx);
559
+ if (scheduleEnabled) scheduleController.restoreFromBranch(ctx);
560
+ else scheduleController.clearTimers();
545
561
  void consumePendingSessionSearchContext(pi, ctx, event.reason);
546
562
  installAutocompleteHintStyling(ctx);
547
563
  installPendingQueueThemePatch(ctx);
@@ -549,7 +565,8 @@ export default function qol(pi: ExtensionAPI): void {
549
565
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
550
566
  applyWorkingIndicatorMode(ctx);
551
567
  gitState = makeFallbackGitState(ctx.cwd);
552
- void refreshStatusline(ctx);
568
+ const showStatusline = statuslineEnabled(ctx);
569
+ if (showStatusline) void refreshStatusline(ctx);
553
570
  installSessionTitle(ctx);
554
571
  installSessionTitleSync(ctx);
555
572
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
@@ -558,27 +575,32 @@ export default function qol(pi: ExtensionAPI): void {
558
575
  ? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
559
576
  : new QolEditor(tui, theme, keybindings, ctx);
560
577
  });
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();
578
+ if (showStatusline || scheduleEnabled || rateLimitAutoResumeEnabled) {
579
+ const statusWidgetTimer = setTimeout(() => {
580
+ ctx.ui.setWidget("statusline", (tui, theme) => {
581
+ activeTui = tui;
582
+ return {
583
+ invalidate() {},
584
+ render(width: number): string[] {
585
+ const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
586
+ if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
587
+ if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
588
+ return lines;
589
+ },
590
+ };
579
591
  });
580
- return { dispose: unsubscribe, invalidate() {}, render: () => [] };
581
- });
592
+ }, 0);
593
+ statusWidgetTimer.unref?.();
594
+ if (showStatusline && settingBoolean("replaceFooter", true, ctx.cwd)) {
595
+ ctx.ui.setFooter((tui, _theme, footerData) => {
596
+ activeTui = tui;
597
+ const unsubscribe = footerData.onBranchChange(() => {
598
+ void refreshStatusline(ctx);
599
+ requestRender();
600
+ });
601
+ return { dispose: unsubscribe, invalidate() {}, render: () => [] };
602
+ });
603
+ }
582
604
  }
583
605
  }
584
606
  startQuestionSubscription(ctx);
@@ -600,6 +622,8 @@ export default function qol(pi: ExtensionAPI): void {
600
622
  pi.on("session_shutdown", (_event, ctx) => {
601
623
  cavemanUnsubscribe?.();
602
624
  cavemanUnsubscribe = undefined;
625
+ scheduleController.clearTimers();
626
+ rateLimitAutoResumeController.clearTimers();
603
627
  resetAutoRename();
604
628
  resetBudgetGuard();
605
629
  clearIdleCompactionTimer();
@@ -631,6 +655,7 @@ export default function qol(pi: ExtensionAPI): void {
631
655
  pi.on("agent_start", (_event, ctx) => {
632
656
  clearIdleCompactionTimer();
633
657
  clearTmuxWindowMark();
658
+ rateLimitAutoResumeController.noteAgentStart(ctx);
634
659
  if (ctx.hasUI) {
635
660
  void refreshStatusline(ctx);
636
661
  requestRender();
@@ -657,6 +682,7 @@ export default function qol(pi: ExtensionAPI): void {
657
682
  }
658
683
  });
659
684
  pi.on("message_end", (event, ctx) => {
685
+ rateLimitAutoResumeController.noteMessageEnd(event, ctx);
660
686
  if (!updateThinkingTimerEnabled(ctx)) return;
661
687
  const message = event.message as any;
662
688
  if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
@@ -670,6 +696,7 @@ export default function qol(pi: ExtensionAPI): void {
670
696
  void refreshStatusline(ctx);
671
697
  requestRender();
672
698
  }
699
+ rateLimitAutoResumeController.noteAgentEnd(event, ctx);
673
700
  maybeFireBudgetGuard(ctx);
674
701
  scheduleIdleCompaction(ctx);
675
702
  void attemptAutoRename(ctx);
@@ -701,6 +728,14 @@ export default function qol(pi: ExtensionAPI): void {
701
728
  void refreshStatusline(ctx);
702
729
  requestRender();
703
730
  });
731
+ pi.on("session_tree", (_event, ctx) => {
732
+ currentCtx = ctx;
733
+ if (settingBoolean("enableScheduleCommand", true, ctx.cwd)) scheduleController.restoreFromBranch(ctx);
734
+ else scheduleController.clearTimers();
735
+ });
736
+ pi.on("after_provider_response", (event, ctx) => {
737
+ rateLimitAutoResumeController.noteProviderResponse(event, ctx);
738
+ });
704
739
  pi.on("session_before_compact", (event, ctx) => handleQolCompaction(event, ctx));
705
740
  pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
706
741
  pi.on("tool_call", async (event: any, ctx) => {
@@ -758,6 +793,14 @@ export default function qol(pi: ExtensionAPI): void {
758
793
  });
759
794
  }
760
795
 
796
+ if (settingBoolean("enableScheduleCommand", true)) {
797
+ pi.registerCommand("schedule", {
798
+ description: "Send a user message after a timer without invoking the model now.",
799
+ getArgumentCompletions: getScheduleArgumentCompletions,
800
+ handler: async (args, ctx) => scheduleController.handleCommand(args, ctx),
801
+ });
802
+ }
803
+
761
804
  if (settingBoolean("enableContextCommand", true)) {
762
805
  pi.registerMessageRenderer(CONTEXT_USAGE_MESSAGE_TYPE, renderQolContextUsageMessage);
763
806
  pi.registerCommand("context", {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.5.0",
4
- "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
3
+ "version": "1.6.0",
4
+ "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -46,10 +46,20 @@
46
46
  "category": "Rendering",
47
47
  "apply": "live"
48
48
  },
49
+ {
50
+ "key": "statusline.enabled",
51
+ "label": "Show compact statusline",
52
+ "description": "Render the QOL statusline row with repo, branch, model, thinking level, and context percentage. Disable when another statusline/footer extension owns this UI.",
53
+ "type": "boolean",
54
+ "default": true,
55
+ "category": "Statusline",
56
+ "apply": "reload",
57
+ "requiresReload": true
58
+ },
49
59
  {
50
60
  "key": "replaceFooter",
51
61
  "label": "Replace built-in footer",
52
- "description": "Hide Pi default footer while the compact QOL statusline is active.",
62
+ "description": "Hide Pi default footer while the compact QOL statusline is enabled.",
53
63
  "type": "boolean",
54
64
  "default": true,
55
65
  "category": "Statusline",
@@ -334,6 +344,61 @@
334
344
  "apply": "reload",
335
345
  "requiresReload": true
336
346
  },
347
+ {
348
+ "key": "enableScheduleCommand",
349
+ "label": "Enable /schedule command",
350
+ "description": "Register /schedule for timer-based user messages, e.g. /schedule 20m retry after rate reset. The timer runs in the extension without invoking the model immediately.",
351
+ "type": "boolean",
352
+ "default": true,
353
+ "category": "Session",
354
+ "apply": "reload",
355
+ "requiresReload": true
356
+ },
357
+ {
358
+ "key": "rateLimitAutoResume.enabled",
359
+ "label": "Auto-resume after rate limits",
360
+ "description": "When Pi detects a rate-limit stop with a reset time, schedule a follow-up user message at the reset time so the agent can continue if nothing else has continued since.",
361
+ "type": "boolean",
362
+ "default": false,
363
+ "category": "Rate Limit Recovery",
364
+ "apply": "live"
365
+ },
366
+ {
367
+ "key": "rateLimitAutoResume.message",
368
+ "label": "Auto-resume message",
369
+ "description": "User message sent after a detected rate limit resets.",
370
+ "type": "string",
371
+ "default": "Hit API rate limit. It has been reset. Continue",
372
+ "category": "Rate Limit Recovery",
373
+ "apply": "live"
374
+ },
375
+ {
376
+ "key": "rateLimitAutoResume.bufferSeconds",
377
+ "label": "Reset buffer seconds",
378
+ "description": "Seconds to wait after the reported reset time before sending the auto-resume message.",
379
+ "type": "number",
380
+ "default": 10,
381
+ "category": "Rate Limit Recovery",
382
+ "apply": "live"
383
+ },
384
+ {
385
+ "key": "rateLimitAutoResume.maxDelayMinutes",
386
+ "label": "Maximum auto-resume delay",
387
+ "description": "Ignore detected reset times farther away than this many minutes, to avoid stale or malformed schedules.",
388
+ "type": "number",
389
+ "default": 1440,
390
+ "category": "Rate Limit Recovery",
391
+ "apply": "live"
392
+ },
393
+ {
394
+ "key": "rateLimitAutoResume.notify",
395
+ "label": "Notify on auto-resume",
396
+ "description": "Show Pi UI notices when rate-limit auto-resume is scheduled, sent, or fails.",
397
+ "type": "boolean",
398
+ "default": true,
399
+ "category": "Rate Limit Recovery",
400
+ "apply": "live"
401
+ },
337
402
  {
338
403
  "key": "handoffReviewPrompt",
339
404
  "label": "Review handoff prompt",