@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.
- package/extensions/qol/constants.ts +2 -0
- package/extensions/qol/notifications.ts +158 -96
- package/extensions/qol/pending-queue.ts +49 -4
- package/extensions/qol.ts +15 -13
- package/package.json +1 -1
|
@@ -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
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
235
|
+
void tmux(pi, args);
|
|
192
236
|
}
|
|
193
237
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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 || !
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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.
|
|
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": [
|