@vanillagreen/pi-qol 2.2.0 → 2.2.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.
@@ -33,6 +33,8 @@ export const DEFAULT_NOTIFICATION_TITLE = "Pi";
33
33
  export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
34
34
  export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
35
35
  export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
36
+ /** Deadline for each tmux call a notification makes; a stalled server is skipped, not waited on. */
37
+ export const TMUX_COMMAND_TIMEOUT_MS = 1000;
36
38
 
37
39
  export const DEFAULT_COMPACTION_MODEL = "current";
38
40
  export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
@@ -1,18 +1,22 @@
1
- import { execFile, execFileSync } from "node:child_process";
2
- import { writeFileSync } from "node:fs";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import {
5
6
  DEFAULT_NOTIFICATION_BODY_MAX_CHARS,
6
7
  DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
7
8
  DEFAULT_NOTIFICATION_TITLE,
8
9
  DEFAULT_TMUX_MESSAGE_DURATION_MS,
9
10
  QUESTION_NOTIFY_DEDUP_MS,
11
+ TMUX_COMMAND_TIMEOUT_MS,
10
12
  } from "./constants.js";
11
13
  import { settingBoolean, settingNumber, settingString } from "./settings.js";
12
14
  import type { QuestionOpenedEventLike, QuestionRequestLike } from "./bridges.js";
13
15
 
14
16
  export type QolNotificationKind = "ready" | "direction" | "question" | "task-complete" | "critical" | "test";
15
17
  export type QolNotificationLevel = "info" | "warning" | "error";
18
+ /** The one Pi call notifications need: `pi.exec`, whose `timeout` kills a stalled tmux. */
19
+ export type QolNotificationExec = Pick<ExtensionAPI, "exec">;
16
20
 
17
21
  export interface QolNotificationService {
18
22
  notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
@@ -65,54 +69,93 @@ function notificationBellMuted(cwd?: string): boolean {
65
69
  return settingBoolean("notification.muteBellSound", false, cwd);
66
70
  }
67
71
 
68
- function sourcePaneTty(): string | undefined {
69
- try {
70
- if (!process.env.TMUX_PANE) return undefined;
71
- const tty = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{pane_tty}"], { encoding: "utf8" }).trim();
72
- return tty || undefined;
73
- } catch {
74
- return undefined;
75
- }
72
+ /** The source pane as one tmux query reports it when a notification fires. */
73
+ interface TmuxPaneState {
74
+ paneTty: string;
75
+ windowActive: boolean;
76
+ sessionId: string;
77
+ windowId: string;
78
+ windowName: string;
76
79
  }
77
80
 
78
- function sourceTmuxSession(): string | undefined {
81
+ /** `#W` is last: a window name may hold a tab, so every field after the fourth is the name. */
82
+ const PANE_STATE_FORMAT = "#{pane_tty}\t#{window_active}\t#{session_id}\t#{window_id}\t#W";
83
+
84
+ /**
85
+ * Every terminal write joins this chain and runs alone, in queue order. Each
86
+ * write opens its tty non-blocking, so a full terminal fails the write rather
87
+ * than holding it, and the chain always advances.
88
+ */
89
+ let terminalWrites: Promise<void> = Promise.resolve();
90
+ /** Bumped by `clearTmuxWindowMark`; a notification decided before the bump does not mark. */
91
+ let tmuxMarkGeneration = 0;
92
+
93
+ /**
94
+ * Runs one tmux command under `TMUX_COMMAND_TIMEOUT_MS`. Undefined means no
95
+ * answer: a non-zero exit, a timeout, or a Pi runtime that went stale after
96
+ * session replacement (`pi.exec` then throws). Pi reports a command its
97
+ * timeout killed with `killed` and, when the signal left no exit status,
98
+ * with code 0, so `killed` is read first.
99
+ */
100
+ async function tmux(pi: QolNotificationExec, args: string[]): Promise<string | undefined> {
79
101
  try {
80
- if (!process.env.TMUX_PANE) return undefined;
81
- const session = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}"], { encoding: "utf8" }).trim();
82
- return session || undefined;
102
+ const result = await pi.exec("tmux", args, { timeout: TMUX_COMMAND_TIMEOUT_MS });
103
+ if (result.killed || result.code !== 0) return undefined;
104
+ return result.stdout;
83
105
  } catch {
84
106
  return undefined;
85
107
  }
86
108
  }
87
109
 
88
- export function sourceTmuxWindowActive(): boolean {
89
- try {
90
- if (!process.env.TMUX_PANE) return false;
91
- const active = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{window_active}"], { encoding: "utf8" }).trim();
92
- return active === "1";
93
- } catch {
94
- return false;
95
- }
110
+ /**
111
+ * Reads the source pane once per notification. The session and window are
112
+ * read fresh each time: tmux `break-pane`, `join-pane` and `move-window` move
113
+ * a pane to another window or session and keep its id and tty.
114
+ */
115
+ async function sourceTmuxPaneState(pi: QolNotificationExec): Promise<TmuxPaneState | undefined> {
116
+ const pane = process.env.TMUX_PANE;
117
+ if (!pane) return undefined;
118
+ const stdout = await tmux(pi, ["display-message", "-p", "-t", pane, PANE_STATE_FORMAT]);
119
+ const [paneTty = "", windowActive = "", sessionId = "", windowId = "", ...name] = (stdout ?? "").replace(/\r?\n$/, "").split("\t");
120
+ if (!paneTty || !sessionId || !windowId) return undefined;
121
+ return { paneTty, windowActive: windowActive === "1", sessionId, windowId, windowName: name.join("\t") };
122
+ }
123
+
124
+ async function tmuxClientTtys(pi: QolNotificationExec, pane: TmuxPaneState | undefined): Promise<string[]> {
125
+ if (!process.env.TMUX) return [];
126
+ const args = ["list-clients", "-F", "#{client_tty}"];
127
+ if (pane) args.splice(1, 0, "-t", pane.sessionId);
128
+ const output = await tmux(pi, args);
129
+ if (output === undefined) return [];
130
+ return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
96
131
  }
97
132
 
98
- function tmuxClientTtys(): string[] {
133
+ function queueTerminalWrite(write: () => Promise<void>): Promise<void> {
134
+ const queued = terminalWrites.then(write);
135
+ terminalWrites = queued.catch(() => undefined);
136
+ return queued;
137
+ }
138
+
139
+ /**
140
+ * Writes to a terminal without waiting on it. A tmux client whose SSH link
141
+ * dropped stays attached with a full tty buffer, and a blocking write to it
142
+ * would hang until the link times out; non-blocking, it fails with `EAGAIN`.
143
+ * `O_NOCTTY` keeps the open from making the tty Pi's controlling terminal.
144
+ */
145
+ async function writeTty(path: string, output: string): Promise<void> {
146
+ const handle = await open(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOCTTY);
99
147
  try {
100
- if (!process.env.TMUX) return [];
101
- const session = sourceTmuxSession();
102
- const args = ["list-clients", "-F", "#{client_tty}"];
103
- if (session) args.splice(1, 0, "-t", session);
104
- const output = execFileSync("tmux", args, { encoding: "utf8" });
105
- return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
106
- } catch {
107
- return [];
148
+ await handle.writeFile(output, "utf8");
149
+ } finally {
150
+ await handle.close();
108
151
  }
109
152
  }
110
153
 
111
- function writeRawToPaths(paths: string[], output: string): boolean {
154
+ async function writeRawToPaths(paths: string[], output: string): Promise<boolean> {
112
155
  let wrote = false;
113
156
  for (const path of paths) {
114
157
  try {
115
- writeFileSync(path, output, "utf8");
158
+ await writeTty(path, output);
116
159
  wrote = true;
117
160
  } catch {
118
161
  // Try remaining paths.
@@ -121,10 +164,9 @@ function writeRawToPaths(paths: string[], output: string): boolean {
121
164
  return wrote;
122
165
  }
123
166
 
124
- function writeToTerminal(output: string): void {
125
- const tty = sourcePaneTty();
167
+ async function writeToTerminal(pane: TmuxPaneState | undefined, output: string): Promise<void> {
126
168
  try {
127
- writeFileSync(tty ?? "/dev/tty", output, "utf8");
169
+ await writeTty(pane?.paneTty ?? "/dev/tty", output);
128
170
  return;
129
171
  } catch {
130
172
  // Fall through to stdout best-effort.
@@ -136,38 +178,40 @@ function writeToTerminal(output: string): void {
136
178
  }
137
179
  }
138
180
 
139
- function writeTerminalSequence(sequence: string, cwd?: string): void {
140
- if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
141
- // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
142
- // Send native terminal notifications straight to attached tmux client TTYs,
143
- // while the explicit terminal bell still goes through the source pane when unmuted.
144
- if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
145
- }
181
+ async function writeTerminalSequence(pi: QolNotificationExec, pane: TmuxPaneState | undefined, sequence: string, cwd?: string): Promise<void> {
182
+ // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
183
+ // Send native terminal notifications straight to attached tmux client TTYs,
184
+ // while the explicit terminal bell still goes through the source pane when unmuted.
185
+ const clientTtys = process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd) ? await tmuxClientTtys(pi, pane) : [];
146
186
  const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
147
- writeToTerminal(output);
187
+ await queueTerminalWrite(async () => {
188
+ if (await writeRawToPaths(clientTtys, sequence)) return;
189
+ await writeToTerminal(pane, output);
190
+ });
148
191
  }
149
192
 
150
- function writeTerminalBell(cwd?: string): void {
193
+ function writeTerminalBell(pane: TmuxPaneState | undefined, cwd?: string): Promise<void> {
151
194
  // Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
152
195
  // This lets tmux set window_bell_flag for the correct source window.
153
196
  const sequence = terminalBellSequence(notificationBellMuted(cwd));
154
- if (sequence) writeToTerminal(sequence);
197
+ if (!sequence) return Promise.resolve();
198
+ return queueTerminalWrite(() => writeToTerminal(pane, sequence));
155
199
  }
156
200
 
157
- function notifyOSC777(title: string, body: string, cwd?: string): void {
158
- writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
201
+ function notifyOSC777(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
202
+ return writeTerminalSequence(pi, pane, osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
159
203
  }
160
204
 
161
- function notifyOSC99(title: string, body: string, cwd?: string): void {
162
- writeTerminalSequence(`\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
163
- writeTerminalSequence(`\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
205
+ async function notifyOSC99(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
206
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
207
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
164
208
  }
165
209
 
166
210
  function notifyWindows(title: string, body: string): void {
167
211
  execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
168
212
  }
169
213
 
170
- function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
214
+ async function notifyNativeTerminal(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
171
215
  const protocol = settingString("notification.oscProtocol", "auto", cwd);
172
216
  if (process.env.WT_SESSION) {
173
217
  notifyWindows(title, body);
@@ -175,60 +219,61 @@ function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
175
219
  }
176
220
  if (protocol === "off") return;
177
221
  if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
178
- notifyOSC99(title, body, cwd);
222
+ await notifyOSC99(pi, pane, title, body, cwd);
179
223
  return;
180
224
  }
181
- notifyOSC777(title, body, cwd);
225
+ await notifyOSC777(pi, pane, title, body, cwd);
182
226
  }
183
227
 
184
- function notifyTmux(title: string, body: string, cwd?: string): void {
228
+ function notifyTmux(pi: QolNotificationExec, title: string, body: string, cwd?: string): void {
185
229
  if (!process.env.TMUX && !process.env.TMUX_PANE) return;
186
230
  const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
187
231
  const message = `${title}: ${body}`;
188
232
  const args = ["display-message", "-d", String(duration)];
189
233
  if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
190
234
  args.push(message);
191
- execFile("tmux", args, () => undefined);
235
+ void tmux(pi, args);
192
236
  }
193
237
 
194
- export function clearTmuxWindowMark(): void {
195
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
196
- tmuxWindowMarkTimer = undefined;
238
+ /** Renames the marked window back, if one is marked. */
239
+ function unmarkTmuxWindow(pi: QolNotificationExec): void {
197
240
  const target = tmuxMarkedTarget;
198
241
  const original = tmuxOriginalWindowName;
199
242
  tmuxMarkedTarget = undefined;
200
243
  tmuxOriginalWindowName = undefined;
201
244
  if (!target || !original) return;
202
- execFile("tmux", ["rename-window", "-t", target, original], () => undefined);
245
+ void tmux(pi, ["rename-window", "-t", target, original]);
246
+ }
247
+
248
+ /** Removes the window mark, and keeps a notification still in flight from setting one. */
249
+ export function clearTmuxWindowMark(pi: QolNotificationExec): void {
250
+ tmuxMarkGeneration += 1;
251
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
252
+ tmuxWindowMarkTimer = undefined;
253
+ unmarkTmuxWindow(pi);
203
254
  }
204
255
 
205
- function markTmuxWindow(cwd?: string): void {
256
+ function markTmuxWindow(pi: QolNotificationExec, pane: TmuxPaneState | undefined, generation: number, cwd?: string): void {
206
257
  if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
207
- if (!process.env.TMUX || !process.env.TMUX_PANE) return;
258
+ if (!process.env.TMUX || !pane || generation !== tmuxMarkGeneration) return;
208
259
  const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
209
260
  const prefix = `${mark} `;
210
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}:#{window_index}"], (targetError, targetStdout) => {
211
- if (targetError) return;
212
- const target = targetStdout.trim();
213
- if (!target) return;
214
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE!, "#W"], (nameError, nameStdout) => {
215
- if (nameError) return;
216
- const current = nameStdout.replace(/\r?\n$/, "");
217
- if (!current) return;
218
- const original = current.startsWith(prefix) ? current.slice(prefix.length) : current;
219
- if (!tmuxMarkedTarget || tmuxMarkedTarget !== target) {
220
- tmuxMarkedTarget = target;
221
- tmuxOriginalWindowName = original;
222
- }
223
- if (!current.startsWith(prefix)) execFile("tmux", ["rename-window", "-t", target, `${prefix}${current}`], () => undefined);
224
- const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
225
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
226
- if (duration > 0) {
227
- tmuxWindowMarkTimer = setTimeout(clearTmuxWindowMark, duration);
228
- tmuxWindowMarkTimer.unref?.();
229
- }
230
- });
231
- });
261
+ const target = pane.windowId;
262
+ const current = pane.windowName;
263
+ if (!current) return;
264
+ // The pane moved to another window since the last mark: that window gets its name back.
265
+ if (tmuxMarkedTarget && tmuxMarkedTarget !== target) unmarkTmuxWindow(pi);
266
+ if (!tmuxMarkedTarget) {
267
+ tmuxMarkedTarget = target;
268
+ tmuxOriginalWindowName = current.startsWith(prefix) ? current.slice(prefix.length) : current;
269
+ }
270
+ if (!current.startsWith(prefix)) void tmux(pi, ["rename-window", "-t", target, `${prefix}${current}`]);
271
+ const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
272
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
273
+ if (duration > 0) {
274
+ tmuxWindowMarkTimer = setTimeout(() => clearTmuxWindowMark(pi), duration);
275
+ tmuxWindowMarkTimer.unref?.();
276
+ }
232
277
  }
233
278
 
234
279
  function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
@@ -243,24 +288,41 @@ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolea
243
288
  }
244
289
  }
245
290
 
246
- export function sendQolNotification(ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): void {
291
+ /**
292
+ * The window mark and the tmux message never touch the terminal, so they start
293
+ * beside the terminal writes rather than behind them.
294
+ */
295
+ async function deliverTerminalNotification(pi: QolNotificationExec, title: string, text: string, markGeneration: number, cwd?: string): Promise<void> {
296
+ const pane = await sourceTmuxPaneState(pi);
297
+ const windowActive = pane?.windowActive ?? false;
298
+ if (!windowActive) markTmuxWindow(pi, pane, markGeneration, cwd);
299
+ if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(pi, title, text, cwd);
300
+ if (settingBoolean("notification.bell", true, cwd) && (!windowActive || settingBoolean("notification.bellWhenActive", false, cwd))) await writeTerminalBell(pane, cwd);
301
+ if (settingBoolean("notification.native", true, cwd)) await notifyNativeTerminal(pi, pane, title, text, cwd);
302
+ }
303
+
304
+ /**
305
+ * Decides and records the notification synchronously, then delivers the
306
+ * terminal and tmux channels in the background: the returned promise settles
307
+ * when delivery is done, and no caller on Pi's event path awaits it, so a
308
+ * stalled tmux server or terminal never holds up a turn end.
309
+ */
310
+ export function sendQolNotification(pi: QolNotificationExec, ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): Promise<void> {
247
311
  const cwd = ctx?.cwd;
248
- if (ctx && !ctx.hasUI) return;
249
- if (!notificationEnabledFor(kind, cwd)) return;
312
+ if (ctx && !ctx.hasUI) return Promise.resolve();
313
+ if (!notificationEnabledFor(kind, cwd)) return Promise.resolve();
250
314
  const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
251
315
  const now = Date.now();
252
316
  const last = lastNotificationAt.get(key) ?? 0;
253
- if (cooldownMs > 0 && now - last < cooldownMs) return;
317
+ if (cooldownMs > 0 && now - last < cooldownMs) return Promise.resolve();
254
318
  lastNotificationAt.set(key, now);
255
319
 
256
320
  const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
257
321
  const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
258
- const tmuxWindowActive = sourceTmuxWindowActive();
259
- if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
260
- if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
261
- if (!tmuxWindowActive) markTmuxWindow(cwd);
262
- if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
263
322
  if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
323
+ // Every channel already absorbs its own failure; this catch keeps an
324
+ // unexpected throw from becoming an unhandled rejection, which ends Pi.
325
+ return deliverTerminalNotification(pi, title, text, tmuxMarkGeneration, cwd).catch(() => undefined);
264
326
  }
265
327
 
266
328
  function questionNotificationTitle(request?: QuestionRequestLike): string {
@@ -269,7 +331,7 @@ function questionNotificationTitle(request?: QuestionRequestLike): string {
269
331
  return "Question";
270
332
  }
271
333
 
272
- export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
334
+ export function notifyQuestionOpened(pi: QolNotificationExec, ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
273
335
  const title = questionNotificationTitle(event.request);
274
336
  const key = `${keyPrefix}:${event.requestId ?? title}`;
275
337
  const now = Date.now();
@@ -279,5 +341,5 @@ export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: Q
279
341
  for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
280
342
  if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
281
343
  }
282
- sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
344
+ void sendQolNotification(pi, ctx, "question", `Input required: ${title}`, "warning", key);
283
345
  }
@@ -9,6 +9,19 @@ interface PendingQueueThemePatch {
9
9
  cwd?: string;
10
10
  }
11
11
 
12
+ interface StatusTextAlignmentPatch {
13
+ originalRender: (this: unknown, width: number) => string[];
14
+ }
15
+
16
+ function isStatusTextAlignmentPatch(value: unknown): value is StatusTextAlignmentPatch {
17
+ return typeof value === "object" && value !== null && typeof (value as Partial<StatusTextAlignmentPatch>).originalRender === "function";
18
+ }
19
+
20
+ interface StatusTextClassification {
21
+ text: string;
22
+ status: boolean;
23
+ }
24
+
12
25
  function isPendingQueuePreviewText(text: string): boolean {
13
26
  const plain = stripAnsi(text);
14
27
  return plain.startsWith("Steering: ") || plain.startsWith("Follow-up: ");
@@ -28,15 +41,33 @@ function isQueuedMessageStatusText(text: string): boolean {
28
41
  return /^Restored \d+ queued messages? to editor$/.test(plain) || plain === "No queued messages to restore";
29
42
  }
30
43
 
31
- export function installStatusTextAlignmentPatch(): void {
44
+ /**
45
+ * Pads Pi's dequeue status line ("Restored N queued messages to editor") flush
46
+ * with the pending-queue preview. The patch wraps every pi-tui `Text`, so the
47
+ * classification is cached per instance and recomputed only when its text
48
+ * changes: an unchanged `Text`, which pi-tui answers from its own render
49
+ * cache, pays one string comparison, not a scan of its whole text.
50
+ */
51
+ export function installStatusTextAlignmentPatch(ctx: ExtensionContext): void {
52
+ if (!ctx.hasUI) return;
32
53
  const proto = Text.prototype as unknown as Record<PropertyKey, any>;
33
- if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL]) return;
54
+ // Any marker already here keeps its wrapper, including pi-qol 2.2.0's `true`.
55
+ if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] !== undefined) return;
34
56
  const originalRender = proto.render;
35
57
  if (typeof originalRender !== "function") return;
36
- proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = true;
58
+ const classifications = new WeakMap<object, StatusTextClassification>();
59
+ const isStatusText = (component: object, text: string): boolean => {
60
+ const cached = classifications.get(component);
61
+ if (cached && cached.text === text) return cached.status;
62
+ const status = isQueuedMessageStatusText(text);
63
+ classifications.set(component, { text, status });
64
+ return status;
65
+ };
66
+ const patch: StatusTextAlignmentPatch = { originalRender };
67
+ proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = patch;
37
68
  proto.render = function patchedQolStatusTextRender(this: any, width: number): string[] {
38
69
  const text = typeof this?.text === "string" ? this.text : "";
39
- if (!isQueuedMessageStatusText(text)) return originalRender.call(this, width);
70
+ if (!isStatusText(this, text)) return originalRender.call(this, width);
40
71
  const originalPaddingX = this.paddingX;
41
72
  try {
42
73
  this.paddingX = 0;
@@ -49,6 +80,20 @@ export function installStatusTextAlignmentPatch(): void {
49
80
  };
50
81
  }
51
82
 
83
+ /**
84
+ * Removes the patch, but only one this module's install wrote. Any other
85
+ * marker keeps its wrapper: pi-qol 2.2.0 marked the prototype with `true` and
86
+ * kept no original render, and Pi's `/reload` carries that marker into this
87
+ * module because pi-tui's prototype outlives the reload.
88
+ */
89
+ export function restoreStatusTextAlignmentPatch(): void {
90
+ const proto = Text.prototype as unknown as Record<PropertyKey, any>;
91
+ const patch: unknown = proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
92
+ if (!isStatusTextAlignmentPatch(patch)) return;
93
+ proto.render = patch.originalRender;
94
+ delete proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
95
+ }
96
+
52
97
  export function installPendingQueueThemePatch(ctx: ExtensionContext): void {
53
98
  if (!ctx.hasUI) return;
54
99
  const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
package/extensions/qol.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  installPendingQueueThemePatch,
47
47
  installStatusTextAlignmentPatch,
48
48
  restorePendingQueueThemePatch,
49
+ restoreStatusTextAlignmentPatch,
49
50
  } from "./qol/pending-queue.js";
50
51
  import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
51
52
  import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
@@ -102,7 +103,6 @@ export default function qol(pi: ExtensionAPI): void {
102
103
  if (!settingBoolean("enabled", true)) return;
103
104
 
104
105
  installThinkingTimerPatch();
105
- installStatusTextAlignmentPatch();
106
106
  const thinkingTimerStore: ThinkingTimerStore = {
107
107
  enabled: false,
108
108
  starts: new Map(),
@@ -381,7 +381,7 @@ export default function qol(pi: ExtensionAPI): void {
381
381
  if (!service) return false;
382
382
  questionUnsubscribe = service.subscribe((event: any) => {
383
383
  if (event?.action !== "opened") return;
384
- notifyQuestionOpened(ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
384
+ notifyQuestionOpened(pi, ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
385
385
  });
386
386
  return true;
387
387
  };
@@ -570,7 +570,7 @@ export default function qol(pi: ExtensionAPI): void {
570
570
  };
571
571
  const notificationService: QolNotificationService = {
572
572
  notifyQuestionOpened(ctx, event) {
573
- notifyQuestionOpened(ctx ?? currentCtx, event, "question");
573
+ notifyQuestionOpened(pi, ctx ?? currentCtx, event, "question");
574
574
  return true;
575
575
  },
576
576
  };
@@ -579,7 +579,7 @@ export default function qol(pi: ExtensionAPI): void {
579
579
  pi.events.on(QUESTION_OPENED_EVENT, (data: unknown) => {
580
580
  if (!data || typeof data !== "object") return;
581
581
  const event = data as QuestionOpenedEventLike;
582
- notifyQuestionOpened(currentCtx, event, "question");
582
+ notifyQuestionOpened(pi, currentCtx, event, "question");
583
583
  });
584
584
  pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
585
585
  if (!currentCtx) return;
@@ -601,6 +601,7 @@ export default function qol(pi: ExtensionAPI): void {
601
601
  void consumePendingSessionSearchContext(pi, ctx, event.reason);
602
602
  installAutocompleteHintStyling(ctx);
603
603
  installPendingQueueThemePatch(ctx);
604
+ installStatusTextAlignmentPatch(ctx);
604
605
  if (ctx.hasUI) {
605
606
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
606
607
  applyWorkingIndicatorMode(ctx);
@@ -673,7 +674,7 @@ export default function qol(pi: ExtensionAPI): void {
673
674
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
674
675
  sessionSearchWarmupTimer = undefined;
675
676
  resetThinkingTimer(undefined);
676
- clearTmuxWindowMark();
677
+ clearTmuxWindowMark(pi);
677
678
  questionUnsubscribe?.();
678
679
  questionUnsubscribe = undefined;
679
680
  currentCtx = undefined;
@@ -683,6 +684,7 @@ export default function qol(pi: ExtensionAPI): void {
683
684
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
684
685
  ctx.ui.setStatus("qol-budget-guard", undefined);
685
686
  restorePendingQueueThemePatch(ctx);
687
+ restoreStatusTextAlignmentPatch();
686
688
  resetStatuslineUi(ctx);
687
689
  ctx.ui.setEditorComponent(undefined);
688
690
  });
@@ -704,7 +706,7 @@ export default function qol(pi: ExtensionAPI): void {
704
706
  });
705
707
  pi.on("agent_start", (_event, ctx) => {
706
708
  clearIdleCompactionTimer();
707
- clearTmuxWindowMark();
709
+ clearTmuxWindowMark(pi);
708
710
  rateLimitAutoResumeController.noteAgentStart(ctx);
709
711
  if (ctx.hasUI) {
710
712
  void refreshStatusline(ctx);
@@ -754,21 +756,21 @@ export default function qol(pi: ExtensionAPI): void {
754
756
  const critical = criticalInfo(text);
755
757
  if (critical) {
756
758
  pendingTaskCompleteNotification = undefined;
757
- sendQolNotification(ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
759
+ void sendQolNotification(pi, ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
758
760
  return;
759
761
  }
760
762
  if (pendingTaskCompleteNotification) {
761
763
  const body = pendingTaskCompleteNotification;
762
764
  pendingTaskCompleteNotification = undefined;
763
- sendQolNotification(ctx, "task-complete", body, "info", "task-complete");
765
+ void sendQolNotification(pi, ctx, "task-complete", body, "info", "task-complete");
764
766
  return;
765
767
  }
766
768
  if (ctx.hasPendingMessages?.()) return;
767
769
  if (needsDirection(text)) {
768
- sendQolNotification(ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
770
+ void sendQolNotification(pi, ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
769
771
  return;
770
772
  }
771
- sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
773
+ void sendQolNotification(pi, ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
772
774
  });
773
775
  pi.on("agent_settled", async (_event, ctx) => {
774
776
  await fireStagedBudgetGuard(ctx);
@@ -796,7 +798,7 @@ export default function qol(pi: ExtensionAPI): void {
796
798
  pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
797
799
  pi.on("tool_call", async (event: any, ctx) => {
798
800
  if (event?.toolName === "question") {
799
- notifyQuestionOpened(ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
801
+ notifyQuestionOpened(pi, ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
800
802
  return undefined;
801
803
  }
802
804
  if (!settingBoolean("permissionGate.enabled", false, ctx.cwd)) return undefined;
@@ -815,7 +817,7 @@ export default function qol(pi: ExtensionAPI): void {
815
817
  });
816
818
 
817
819
  pi.on("input", async (event) => {
818
- clearTmuxWindowMark();
820
+ clearTmuxWindowMark(pi);
819
821
  if (event.source === "extension") return { action: "continue" };
820
822
  const text = event.text ?? "";
821
823
  const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
@@ -952,7 +954,7 @@ export default function qol(pi: ExtensionAPI): void {
952
954
  return;
953
955
  }
954
956
  if (sub === "notify-test") {
955
- sendQolNotification(ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
957
+ void sendQolNotification(pi, ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
956
958
  ctx.ui.notify("Sent QOL notification test.", "info");
957
959
  return;
958
960
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.2.0",
3
+ "version": "2.2.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": [