@vanillagreen/pi-qol 1.6.0 → 1.7.1

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
@@ -13,9 +13,10 @@ Quality-of-life extension for Pi: compact statusline, multiline input, session n
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
+ - Session search reads prompt snippets line-by-line, so very large session JSONL files do not have to be materialized just to browse, search, or import context.
16
17
  - `/context` shows a Claude-style context-window breakdown.
17
18
  - `/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
+ - `/schedule 20m <message>` or `/schedule 1h45m <message>` sends a delayed prompt without invoking the model until the timer fires.
19
20
  - Optional rate-limit auto-resume sends a configurable continuation after reset.
20
21
  - Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
21
22
  - Notifications for ready, questions, blocked states, and task completion.
@@ -53,11 +54,11 @@ Restart Pi after installation.
53
54
  | `/search [query]` | Open previous-session search. |
54
55
  | `/search:refresh` | Refresh the session search cache. |
55
56
  | `/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`. |
57
+ | `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule 1h45m retry the previous request`. |
57
58
 
58
59
  Arguments support autocomplete.
59
60
 
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
+ `/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Compact composite durations are accepted in largest-to-smallest order, like `1h45m`, `45m10s`, or `1h45m30s`. 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
62
 
62
63
  ## Settings
63
64
 
@@ -138,7 +139,7 @@ Master toggle: **Enable notifications**.
138
139
 
139
140
  Triggers (notify when): ready, direction needed, question popups, all tasks complete, critical/blocked.
140
141
 
141
- Channels: terminal bell, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
142
+ Channels: terminal bell, **Mute bell sound**, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
142
143
 
143
144
  Tuning: cooldown seconds, title, ready message, body length, tmux durations.
144
145
 
@@ -146,9 +147,10 @@ Notes:
146
147
 
147
148
  - **Terminal notification protocol** picks between OSC 99 (Kitty) and OSC 777 automatically.
148
149
  - **Bell when tmux window active** is off so you don't get bells while looking at Pi.
150
+ - **Mute bell sound** keeps notification routing enabled but suppresses QOL-emitted BEL bytes and uses ST terminators for OSC 777/99 where supported. Terminals or operating systems may still play their own sound for native notifications outside QOL control.
149
151
  - **tmux native via client TTY** sends OSC notifications to attached tmux clients so notifications still appear when the Pi window is inactive.
150
152
 
151
- Use `/qol notify-test` to verify your terminal/tmux setup.
153
+ Use `/qol notify-test` to verify your terminal/tmux setup, including silent behavior with **Mute bell sound** enabled.
152
154
 
153
155
  ### Permission gate
154
156
 
@@ -192,6 +194,8 @@ For autonomous/Flightdeck-style runs the agent never goes idle, so idle compacti
192
194
 
193
195
  When the budget guard fires it injects a sentinel into the compaction request so the QOL bounded handler always runs — chunked summarizer + handoff artifact — even if **Custom compaction summaries** is off. Manual compactions (`/tree`, idle compaction, user-triggered) still only use the QOL handler when **Custom compaction summaries** is on; otherwise they fall through to Pi's default compaction with no handoff artifact and no chunking. If you want every compaction to use the QOL bounded path, turn **Custom compaction summaries** on.
194
196
 
197
+ While budget-guard compaction is running, QOL keeps a persistent status line above the prompt (and in the normal status footer when the compact statusline is disabled). After Pi prints the compacted-summary block, the line changes to `QOL budget guard finalizing compaction…` until `ctx.compact()` reports completion, so long reload/finalization gaps do not look frozen.
198
+
195
199
  Recommended values for autonomous/Flightdeck runs:
196
200
 
197
201
  - Keep **Long-session budget guard** on. Lower **Budget guard percent** to `75` if your provider buffers are tight.
@@ -17,6 +17,7 @@ export interface GuardDispatchInput {
17
17
  trigger: BudgetTrigger | undefined;
18
18
  compact: ((options: GuardCompactOptions) => void) | undefined;
19
19
  notify: (message: string, level: GuardLevel) => void;
20
+ onStatus?: (message: string | undefined) => void;
20
21
  staleCtx?: () => boolean;
21
22
  }
22
23
 
@@ -70,6 +71,7 @@ export class BudgetGuardDriver {
70
71
  return { kind: "no-compact-fn" };
71
72
  }
72
73
  input.notify(`QOL budget guard starting compaction: ${trigger.reason}`, "info");
74
+ input.onStatus?.(`QOL budget guard compacting session: ${trigger.reason}`);
73
75
  this.inFlight = true;
74
76
  this.lastKey = trigger.key;
75
77
  const instructions = `${QOL_BUDGET_GUARD_SENTINEL} QOL budget guard triggered at agent_end because ${trigger.reason}. Bound the summary input, preserve current task state, decisions, files, blockers, and next steps.`;
@@ -78,6 +80,7 @@ export class BudgetGuardDriver {
78
80
  customInstructions: instructions,
79
81
  onComplete: () => {
80
82
  this.inFlight = false;
83
+ input.onStatus?.(undefined);
81
84
  input.notify("QOL budget guard compaction completed.", "info");
82
85
  },
83
86
  onError: (error: Error) => {
@@ -85,6 +88,7 @@ export class BudgetGuardDriver {
85
88
  // Allow the next agent_end to retry rather than poisoning the
86
89
  // crossing key after a transient failure.
87
90
  this.lastKey = undefined;
91
+ input.onStatus?.(undefined);
88
92
  input.notify(`QOL budget guard compaction failed: ${error.message}`, "error");
89
93
  },
90
94
  });
@@ -92,6 +96,7 @@ export class BudgetGuardDriver {
92
96
  } catch (error) {
93
97
  this.inFlight = false;
94
98
  this.lastKey = undefined;
99
+ input.onStatus?.(undefined);
95
100
  const message = error instanceof Error ? error.message : String(error);
96
101
  input.notify(`QOL budget guard compaction failed to start: ${message}`, "error");
97
102
  return { kind: "dispatch-threw", error: message, reason: trigger.reason };
@@ -52,6 +52,19 @@ function tmuxPassthrough(sequence: string): string {
52
52
  return `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
53
53
  }
54
54
 
55
+ export function terminalBellSequence(muteBellSound: boolean): string | undefined {
56
+ return muteBellSound ? undefined : "\x07";
57
+ }
58
+
59
+ export function osc777NotificationSequence(title: string, body: string, muteBellSound = false): string {
60
+ const terminator = muteBellSound ? "\x1b\\" : "\x07";
61
+ return `\x1b]777;notify;${title};${body}${terminator}`;
62
+ }
63
+
64
+ function notificationBellMuted(cwd?: string): boolean {
65
+ return settingBoolean("notification.muteBellSound", false, cwd);
66
+ }
67
+
55
68
  function sourcePaneTty(): string | undefined {
56
69
  try {
57
70
  if (!process.env.TMUX_PANE) return undefined;
@@ -127,21 +140,22 @@ function writeTerminalSequence(sequence: string, cwd?: string): void {
127
140
  if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
128
141
  // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
129
142
  // Send native terminal notifications straight to attached tmux client TTYs,
130
- // while BEL still goes through the source pane so tmux marks the right tab.
143
+ // while the explicit terminal bell still goes through the source pane when unmuted.
131
144
  if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
132
145
  }
133
146
  const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
134
147
  writeToTerminal(output);
135
148
  }
136
149
 
137
- function writeTerminalBell(): void {
150
+ function writeTerminalBell(cwd?: string): void {
138
151
  // Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
139
152
  // This lets tmux set window_bell_flag for the correct source window.
140
- writeToTerminal("\x07");
153
+ const sequence = terminalBellSequence(notificationBellMuted(cwd));
154
+ if (sequence) writeToTerminal(sequence);
141
155
  }
142
156
 
143
157
  function notifyOSC777(title: string, body: string, cwd?: string): void {
144
- writeTerminalSequence(`\x1b]777;notify;${title};${body}\x07`, cwd);
158
+ writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
145
159
  }
146
160
 
147
161
  function notifyOSC99(title: string, body: string, cwd?: string): void {
@@ -242,7 +256,7 @@ export function sendQolNotification(ctx: ExtensionContext | undefined, kind: Qol
242
256
  const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
243
257
  const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
244
258
  const tmuxWindowActive = sourceTmuxWindowActive();
245
- if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell();
259
+ if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
246
260
  if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
247
261
  if (!tmuxWindowActive) markTmuxWindow(cwd);
248
262
  if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
@@ -56,6 +56,7 @@ const SCHEDULE_COMPLETIONS: AutocompleteItem[] = [
56
56
  { value: "list", label: "list", description: "Show pending scheduled messages" },
57
57
  { value: "cancel ", label: "cancel <id|all>", description: "Cancel one scheduled message or all pending messages" },
58
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" },
59
60
  { value: "1h ", label: "1h <message>", description: "Send a message after 1 hour" },
60
61
  ];
61
62
 
@@ -66,25 +67,47 @@ export function getScheduleArgumentCompletions(prefix: string): AutocompleteItem
66
67
  }
67
68
 
68
69
  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);
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 {
84
98
  if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SCHEDULE_DELAY_MS) return undefined;
85
99
  return delayMs;
86
100
  }
87
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
+
88
111
  export function parseScheduleCommandArgs(args: string): ParsedScheduleCommand {
89
112
  const trimmed = args.trim();
90
113
  if (!trimmed) return { kind: "list" };
@@ -131,7 +154,8 @@ function usageText(): string {
131
154
  return [
132
155
  "Usage: /schedule <duration> <message>",
133
156
  "Examples: /schedule 20m retry the previous request",
134
- " /schedule 1h retry after the rate limit reset",
157
+ " /schedule 1h30m retry after the rate limit reset",
158
+ "Durations: 20, 20m, 90s, 500ms, 1h45m30s (bare numbers mean minutes)",
135
159
  "Manage: /schedule list, /schedule cancel <id|all>",
136
160
  ].join("\n");
137
161
  }
@@ -10,6 +10,7 @@ import {
10
10
  SESSION_SEARCH_STATUS_KEY,
11
11
  } from "../constants.js";
12
12
  import { expandHome, piSettingsPaths, settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
13
+ import { forEachSessionJsonlLine } from "./jsonl.js";
13
14
  import type {
14
15
  QolSessionPaletteAction,
15
16
  QolSessionSearchPendingMessage,
@@ -166,15 +167,14 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
166
167
  if (cached) return cached;
167
168
  const messages: QolSessionUserMessage[] = [];
168
169
  try {
169
- const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
170
- for (const line of lines) {
171
- if (!line.trim()) continue;
170
+ forEachSessionJsonlLine(sessionPath, (line) => {
171
+ if (!line.trim()) return;
172
172
  let entry: any;
173
- try { entry = JSON.parse(line); } catch { continue; }
173
+ try { entry = JSON.parse(line); } catch { return; }
174
174
  const message = entry?.type === "message" ? entry.message : undefined;
175
- if (!message || message.role !== "user") continue;
175
+ if (!message || message.role !== "user") return;
176
176
  const text = oneLine(messageContentText(message.content));
177
- if (!text) continue;
177
+ if (!text) return;
178
178
  messages.push({
179
179
  entryId: typeof entry.id === "string" ? entry.id : undefined,
180
180
  index: messages.length + 1,
@@ -182,7 +182,7 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
182
182
  text,
183
183
  timestamp: sessionMessageTimestamp(entry, message),
184
184
  });
185
- }
185
+ });
186
186
  } catch {
187
187
  // Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
188
188
  }
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
2
  import { SessionManager, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
4
3
  import { Text } from "@earendil-works/pi-tui";
@@ -13,6 +12,7 @@ import {
13
12
  import { settingNumber, settingString } from "../settings.js";
14
13
  import { sessionDisplayName } from "./cache.js";
15
14
  import { QolSessionSearchLoadingComponent } from "./component.js";
15
+ import { forEachSessionJsonlLine } from "./jsonl.js";
16
16
  import type { QolSessionSearchPendingMessage, QolSessionSearchResult } from "./types.js";
17
17
 
18
18
  export function trimSessionSummaryInput(text: string, cwd: string): string {
@@ -33,14 +33,13 @@ function messagesForSessionSummary(sessionPath: string): AgentMessage[] {
33
33
  // Fall back to a direct JSONL parse below.
34
34
  }
35
35
  try {
36
- const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
37
36
  const messages: AgentMessage[] = [];
38
- for (const line of lines) {
39
- if (!line.trim()) continue;
37
+ forEachSessionJsonlLine(sessionPath, (line) => {
38
+ if (!line.trim()) return;
40
39
  let entry: any;
41
- try { entry = JSON.parse(line); } catch { continue; }
40
+ try { entry = JSON.parse(line); } catch { return; }
42
41
  if (entry?.type === "message" && entry.message) messages.push(entry.message);
43
- }
42
+ });
44
43
  return messages;
45
44
  } catch {
46
45
  return [];
@@ -0,0 +1,31 @@
1
+ import { closeSync, openSync, readSync } from "node:fs";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export function forEachSessionJsonlLine(sessionPath: string, onLine: (line: string) => void, chunkSize = 64 * 1024): void {
5
+ const fd = openSync(sessionPath, "r");
6
+ const buffer = Buffer.allocUnsafe(Math.max(1, Math.floor(chunkSize)));
7
+ const decoder = new StringDecoder("utf8");
8
+ let pending = "";
9
+ try {
10
+ for (;;) {
11
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
12
+ if (bytesRead === 0) break;
13
+ pending += decoder.write(buffer.subarray(0, bytesRead));
14
+ let start = 0;
15
+ for (;;) {
16
+ const newline = pending.indexOf("\n", start);
17
+ if (newline < 0) {
18
+ pending = pending.slice(start);
19
+ break;
20
+ }
21
+ const line = pending.slice(start, newline);
22
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
23
+ start = newline + 1;
24
+ }
25
+ }
26
+ pending += decoder.end();
27
+ if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
28
+ } finally {
29
+ closeSync(fd);
30
+ }
31
+ }
@@ -39,7 +39,7 @@ export function statusMessage(ctx: ExtensionContext): string {
39
39
  `Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
40
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"}`,
41
41
  `Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
42
- `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"}`,
43
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"}`,
44
44
  `Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
45
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,
@@ -262,9 +263,19 @@ export default function qol(pi: ExtensionAPI): void {
262
263
  };
263
264
 
264
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
+ };
265
275
 
266
276
  const resetBudgetGuard = () => {
267
277
  budgetGuardDriver.reset();
278
+ budgetGuardStatus = undefined;
268
279
  };
269
280
 
270
281
  const maybeFireBudgetGuard = (ctx: ExtensionContext) => {
@@ -286,6 +297,7 @@ export default function qol(pi: ExtensionAPI): void {
286
297
  budgetGuardDriver.dispatch({
287
298
  compact: typeof ctx.compact === "function" ? ctx.compact.bind(ctx) : undefined,
288
299
  notify: notifySafely,
300
+ onStatus: (message) => setBudgetGuardStatus(ctx, message),
289
301
  trigger,
290
302
  });
291
303
  };
@@ -584,6 +596,7 @@ export default function qol(pi: ExtensionAPI): void {
584
596
  render(width: number): string[] {
585
597
  const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
586
598
  if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
599
+ if (budgetGuardStatus) lines.push(truncateToWidth(ansiGreen(`┃ ${budgetGuardStatus}`), width, ""));
587
600
  if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
588
601
  return lines;
589
602
  },
@@ -635,10 +648,12 @@ export default function qol(pi: ExtensionAPI): void {
635
648
  questionUnsubscribe?.();
636
649
  questionUnsubscribe = undefined;
637
650
  currentCtx = undefined;
651
+ budgetGuardStatus = undefined;
638
652
  const host = globalThis as unknown as Record<PropertyKey, unknown>;
639
653
  if (host[QOL_NOTIFICATION_SERVICE_SYMBOL] === notificationService) delete host[QOL_NOTIFICATION_SERVICE_SYMBOL];
640
654
  ctx.ui.setStatus(STATUS_KEY, undefined);
641
655
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
656
+ ctx.ui.setStatus("qol-budget-guard", undefined);
642
657
  restorePendingQueueThemePatch(ctx);
643
658
  resetStatuslineUi(ctx);
644
659
  ctx.ui.setEditorComponent(undefined);
@@ -721,6 +736,7 @@ export default function qol(pi: ExtensionAPI): void {
721
736
  sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
722
737
  });
723
738
  pi.on("session_compact", (_event, ctx) => {
739
+ if (budgetGuardStatus) setBudgetGuardStatus(ctx, "QOL budget guard finalizing compaction…");
724
740
  // After a successful compaction usage drops below the budget, so reset the
725
741
  // crossing key so the next threshold crossing re-fires the guard.
726
742
  budgetGuardDriver.noteSessionCompacted();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
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": [
@@ -582,6 +582,15 @@
582
582
  "category": "Notifications Channels",
583
583
  "apply": "live"
584
584
  },
585
+ {
586
+ "key": "notification.muteBellSound",
587
+ "label": "Mute bell sound",
588
+ "description": "Keep notifications enabled while suppressing QOL-emitted BEL bytes; OSC terminal notifications use ST terminators where supported.",
589
+ "type": "boolean",
590
+ "default": false,
591
+ "category": "Notifications Channels",
592
+ "apply": "live"
593
+ },
585
594
  {
586
595
  "key": "notification.bellWhenActive",
587
596
  "label": "Bell when tmux window active",