@vanillagreen/pi-qol 2.2.0 → 2.3.2
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 +11 -0
- package/extensions/qol/compaction-handoff.ts +6 -19
- package/extensions/qol/compaction.ts +1 -1
- package/extensions/qol/constants.ts +14 -1
- package/extensions/qol/glyphs.ts +1 -1
- package/extensions/qol/images.ts +2 -1
- package/extensions/qol/notifications.ts +172 -97
- package/extensions/qol/package-config.ts +445 -0
- package/extensions/qol/pending-queue.ts +49 -4
- package/extensions/qol/session-search/cache.ts +68 -17
- package/extensions/qol/session-search/component.ts +19 -5
- package/extensions/qol/session-search/index.ts +17 -9
- package/extensions/qol/session-search/types.ts +7 -0
- package/extensions/qol/settings.ts +5 -100
- package/extensions/qol.ts +50 -19
- package/package.json +4 -3
- package/scripts/lane-retention.ts +187 -0
package/README.md
CHANGED
|
@@ -29,6 +29,17 @@ Requires Pi 0.86.0 or newer. Restart Pi after installation. Use `kendex update-p
|
|
|
29
29
|
|
|
30
30
|
The extension reads your enabled features when Pi starts. It adds their editor controls, commands and event handlers. Session actions update the session or queue messages for the agent. Notifications report events through your selected channels. Compaction settings control when long conversations are summarized.
|
|
31
31
|
|
|
32
|
+
## Memory use
|
|
33
|
+
|
|
34
|
+
- A session without a UI loads no session-search index. With a UI, the index is loaded shortly after start and kept for `sessionSearch.cacheTtlSeconds` (default 300 seconds; 0 keeps it until the session ends). A search after the index was released opens at once and fills in when the index has loaded again.
|
|
35
|
+
- The index keeps at most 32,768 characters of message text per session and 8,388,608 characters in total, newest session first. A session past the total is searched by name, path and first prompt only.
|
|
36
|
+
- Where Pi cannot switch sessions directly, a chosen resume or fork waits as one `/search:resume-pending` command in the editor. Only the latest one is kept, and it is dropped when the session ends.
|
|
37
|
+
- Parsed prompts are kept for at most 64 sessions, and finished thinking times for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
|
|
38
|
+
|
|
39
|
+
## Saved files
|
|
40
|
+
|
|
41
|
+
At session start, the extension deletes budget handoff files whose recorded working directory no longer exists. It also deletes files older than 5 days. Both timestamped snapshots and `latest.json` follow this rule.
|
|
42
|
+
|
|
32
43
|
## Settings
|
|
33
44
|
|
|
34
45
|
The settings editor writes project values to `.pi/settings.json`. The default user file is `~/.pi/agent/settings.json`. `PI_CODING_AGENT_DIR` changes the user directory. Package values are stored under `kendex.extensionManager.config["@vanillagreen/pi-qol"]`.
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// drops something critical. The pure data-building + write logic lives here
|
|
5
5
|
// so it can be unit-tested without the pi-coding-agent / pi-ai peer deps.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { basename, dirname, join
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
9
|
|
|
10
|
+
import { openLaneDir } from "../../scripts/lane-retention.js";
|
|
10
11
|
import { QOL_BUDGET_HANDOFF_FOLDER, QOL_BUDGET_HANDOFF_LATEST } from "./constants.js";
|
|
11
|
-
import {
|
|
12
|
+
import { piUserDir } from "./package-config.js";
|
|
12
13
|
|
|
13
14
|
export interface QolBudgetHandoff {
|
|
14
15
|
reason: string;
|
|
@@ -22,17 +23,6 @@ export interface QolBudgetHandoff {
|
|
|
22
23
|
model?: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
|
|
26
|
-
* means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
|
|
27
|
-
* where the renderer does not, putting the two on different roots. Hoisted, so
|
|
28
|
-
* a circular import cannot reach it inside a temporal dead zone. */
|
|
29
|
-
function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
|
|
30
|
-
|
|
31
|
-
export function piUserDir(): string {
|
|
32
|
-
const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
|
|
33
|
-
return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
export function safeFileName(value: string): string {
|
|
37
27
|
return value.replace(/[^\w.-]+/g, "_");
|
|
38
28
|
}
|
|
@@ -157,16 +147,13 @@ export function handoffBaseDir(sessionId: string, root: string = piUserDir()): s
|
|
|
157
147
|
|
|
158
148
|
export interface WriteHandoffOptions {
|
|
159
149
|
enabled: boolean;
|
|
150
|
+
cwd: string;
|
|
160
151
|
root?: string;
|
|
161
152
|
/** Injection point so tests can stub fs without mocking node:fs. */
|
|
162
153
|
writer?: (path: string, payload: string) => void;
|
|
163
154
|
mkdir?: (path: string) => void;
|
|
164
155
|
}
|
|
165
156
|
|
|
166
|
-
function defaultMkdir(path: string): void {
|
|
167
|
-
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
157
|
function defaultWriter(path: string, payload: string): void {
|
|
171
158
|
writeFileSync(path, payload, { mode: 0o600 });
|
|
172
159
|
}
|
|
@@ -176,7 +163,7 @@ export function writeBudgetHandoffArtifact(handoff: QolBudgetHandoff, options: W
|
|
|
176
163
|
const baseDir = handoffBaseDir(handoff.sessionId, options.root);
|
|
177
164
|
const stampedPath = handoffStampedPath(baseDir, handoff.timestamp);
|
|
178
165
|
const latestPath = join(dirname(baseDir), basename(baseDir), QOL_BUDGET_HANDOFF_LATEST);
|
|
179
|
-
const mkdir = options.mkdir ??
|
|
166
|
+
const mkdir = options.mkdir ?? ((path: string) => openLaneDir(path, options.cwd));
|
|
180
167
|
const writer = options.writer ?? defaultWriter;
|
|
181
168
|
try {
|
|
182
169
|
mkdir(baseDir);
|
|
@@ -252,7 +252,7 @@ export function buildBudgetHandoff(ctx: ExtensionContext, options: {
|
|
|
252
252
|
|
|
253
253
|
export function writeBudgetHandoffArtifact(ctx: ExtensionContext, handoff: QolBudgetHandoff): HandoffWriteResult {
|
|
254
254
|
const enabled = settingBoolean("compaction.handoffArtifactEnabled", true, ctx.cwd);
|
|
255
|
-
const result = writeBudgetHandoffArtifactRaw(handoff, { enabled });
|
|
255
|
+
const result = writeBudgetHandoffArtifactRaw(handoff, { cwd: ctx.cwd, enabled });
|
|
256
256
|
if (result.error) {
|
|
257
257
|
compactionNotify(ctx, `QOL handoff artifact write failed: ${result.error}`, "warning");
|
|
258
258
|
}
|
|
@@ -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;
|
|
@@ -72,7 +74,18 @@ export const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
|
|
|
72
74
|
export const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
|
|
73
75
|
export const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
|
|
74
76
|
export const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
|
|
75
|
-
|
|
77
|
+
/** How long a loaded session-search index is kept. Past it the index is
|
|
78
|
+
* released, and the next search loads it again. */
|
|
79
|
+
export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 300;
|
|
80
|
+
/** Characters of one session's message text the search index keeps. */
|
|
81
|
+
export const SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION = 32_768;
|
|
82
|
+
/** Characters of message text the whole search index keeps, newest session
|
|
83
|
+
* first; sessions past it are searched by name, path and first prompt only. */
|
|
84
|
+
export const SESSION_SEARCH_TEXT_MAX_CHARS = 8_388_608;
|
|
85
|
+
/** Sessions whose user prompts the search overlay keeps parsed. */
|
|
86
|
+
export const SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS = 64;
|
|
87
|
+
/** Finished thinking blocks whose time a redrawn message can show again. */
|
|
88
|
+
export const THINKING_TIMER_MAX_DURATIONS = 256;
|
|
76
89
|
export const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
|
|
77
90
|
|
|
78
91
|
export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
|
package/extensions/qol/glyphs.ts
CHANGED
package/extensions/qol/images.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { basename, resolve } from "node:path";
|
|
|
3
3
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
|
|
5
5
|
import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js";
|
|
6
|
-
import { expandHome
|
|
6
|
+
import { expandHome } from "./package-config.js";
|
|
7
|
+
import { settingBoolean } from "./settings.js";
|
|
7
8
|
|
|
8
9
|
function stripAtPrefix(path: string): string {
|
|
9
10
|
return path.startsWith("@") ? path.slice(1) : path;
|
|
@@ -1,23 +1,30 @@
|
|
|
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;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** Last send time per notification key, for the cooldown. An entry older than
|
|
26
|
+
* the cooldown suppresses nothing, so each send drops those; question keys
|
|
27
|
+
* carry a request id, so without the drop the map grows per question. */
|
|
21
28
|
const lastNotificationAt = new Map<string, number>();
|
|
22
29
|
const lastQuestionNotificationAt = new Map<string, number>();
|
|
23
30
|
let tmuxMarkedTarget: string | undefined;
|
|
@@ -65,54 +72,93 @@ function notificationBellMuted(cwd?: string): boolean {
|
|
|
65
72
|
return settingBoolean("notification.muteBellSound", false, cwd);
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
75
|
+
/** The source pane as one tmux query reports it when a notification fires. */
|
|
76
|
+
interface TmuxPaneState {
|
|
77
|
+
paneTty: string;
|
|
78
|
+
windowActive: boolean;
|
|
79
|
+
sessionId: string;
|
|
80
|
+
windowId: string;
|
|
81
|
+
windowName: string;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
/** `#W` is last: a window name may hold a tab, so every field after the fourth is the name. */
|
|
85
|
+
const PANE_STATE_FORMAT = "#{pane_tty}\t#{window_active}\t#{session_id}\t#{window_id}\t#W";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every terminal write joins this chain and runs alone, in queue order. Each
|
|
89
|
+
* write opens its tty non-blocking, so a full terminal fails the write rather
|
|
90
|
+
* than holding it, and the chain always advances.
|
|
91
|
+
*/
|
|
92
|
+
let terminalWrites: Promise<void> = Promise.resolve();
|
|
93
|
+
/** Bumped by `clearTmuxWindowMark`; a notification decided before the bump does not mark. */
|
|
94
|
+
let tmuxMarkGeneration = 0;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs one tmux command under `TMUX_COMMAND_TIMEOUT_MS`. Undefined means no
|
|
98
|
+
* answer: a non-zero exit, a timeout, or a Pi runtime that went stale after
|
|
99
|
+
* session replacement (`pi.exec` then throws). Pi reports a command its
|
|
100
|
+
* timeout killed with `killed` and, when the signal left no exit status,
|
|
101
|
+
* with code 0, so `killed` is read first.
|
|
102
|
+
*/
|
|
103
|
+
async function tmux(pi: QolNotificationExec, args: string[]): Promise<string | undefined> {
|
|
79
104
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
105
|
+
const result = await pi.exec("tmux", args, { timeout: TMUX_COMMAND_TIMEOUT_MS });
|
|
106
|
+
if (result.killed || result.code !== 0) return undefined;
|
|
107
|
+
return result.stdout;
|
|
83
108
|
} catch {
|
|
84
109
|
return undefined;
|
|
85
110
|
}
|
|
86
111
|
}
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Reads the source pane once per notification. The session and window are
|
|
115
|
+
* read fresh each time: tmux `break-pane`, `join-pane` and `move-window` move
|
|
116
|
+
* a pane to another window or session and keep its id and tty.
|
|
117
|
+
*/
|
|
118
|
+
async function sourceTmuxPaneState(pi: QolNotificationExec): Promise<TmuxPaneState | undefined> {
|
|
119
|
+
const pane = process.env.TMUX_PANE;
|
|
120
|
+
if (!pane) return undefined;
|
|
121
|
+
const stdout = await tmux(pi, ["display-message", "-p", "-t", pane, PANE_STATE_FORMAT]);
|
|
122
|
+
const [paneTty = "", windowActive = "", sessionId = "", windowId = "", ...name] = (stdout ?? "").replace(/\r?\n$/, "").split("\t");
|
|
123
|
+
if (!paneTty || !sessionId || !windowId) return undefined;
|
|
124
|
+
return { paneTty, windowActive: windowActive === "1", sessionId, windowId, windowName: name.join("\t") };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function tmuxClientTtys(pi: QolNotificationExec, pane: TmuxPaneState | undefined): Promise<string[]> {
|
|
128
|
+
if (!process.env.TMUX) return [];
|
|
129
|
+
const args = ["list-clients", "-F", "#{client_tty}"];
|
|
130
|
+
if (pane) args.splice(1, 0, "-t", pane.sessionId);
|
|
131
|
+
const output = await tmux(pi, args);
|
|
132
|
+
if (output === undefined) return [];
|
|
133
|
+
return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function queueTerminalWrite(write: () => Promise<void>): Promise<void> {
|
|
137
|
+
const queued = terminalWrites.then(write);
|
|
138
|
+
terminalWrites = queued.catch(() => undefined);
|
|
139
|
+
return queued;
|
|
96
140
|
}
|
|
97
141
|
|
|
98
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Writes to a terminal without waiting on it. A tmux client whose SSH link
|
|
144
|
+
* dropped stays attached with a full tty buffer, and a blocking write to it
|
|
145
|
+
* would hang until the link times out; non-blocking, it fails with `EAGAIN`.
|
|
146
|
+
* `O_NOCTTY` keeps the open from making the tty Pi's controlling terminal.
|
|
147
|
+
*/
|
|
148
|
+
async function writeTty(path: string, output: string): Promise<void> {
|
|
149
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOCTTY);
|
|
99
150
|
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 [];
|
|
151
|
+
await handle.writeFile(output, "utf8");
|
|
152
|
+
} finally {
|
|
153
|
+
await handle.close();
|
|
108
154
|
}
|
|
109
155
|
}
|
|
110
156
|
|
|
111
|
-
function writeRawToPaths(paths: string[], output: string): boolean {
|
|
157
|
+
async function writeRawToPaths(paths: string[], output: string): Promise<boolean> {
|
|
112
158
|
let wrote = false;
|
|
113
159
|
for (const path of paths) {
|
|
114
160
|
try {
|
|
115
|
-
|
|
161
|
+
await writeTty(path, output);
|
|
116
162
|
wrote = true;
|
|
117
163
|
} catch {
|
|
118
164
|
// Try remaining paths.
|
|
@@ -121,10 +167,9 @@ function writeRawToPaths(paths: string[], output: string): boolean {
|
|
|
121
167
|
return wrote;
|
|
122
168
|
}
|
|
123
169
|
|
|
124
|
-
function writeToTerminal(output: string): void {
|
|
125
|
-
const tty = sourcePaneTty();
|
|
170
|
+
async function writeToTerminal(pane: TmuxPaneState | undefined, output: string): Promise<void> {
|
|
126
171
|
try {
|
|
127
|
-
|
|
172
|
+
await writeTty(pane?.paneTty ?? "/dev/tty", output);
|
|
128
173
|
return;
|
|
129
174
|
} catch {
|
|
130
175
|
// Fall through to stdout best-effort.
|
|
@@ -136,38 +181,40 @@ function writeToTerminal(output: string): void {
|
|
|
136
181
|
}
|
|
137
182
|
}
|
|
138
183
|
|
|
139
|
-
function writeTerminalSequence(sequence: string, cwd?: string): void {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
|
|
145
|
-
}
|
|
184
|
+
async function writeTerminalSequence(pi: QolNotificationExec, pane: TmuxPaneState | undefined, sequence: string, cwd?: string): Promise<void> {
|
|
185
|
+
// Inactive tmux windows do not forward arbitrary OSC output to the terminal.
|
|
186
|
+
// Send native terminal notifications straight to attached tmux client TTYs,
|
|
187
|
+
// while the explicit terminal bell still goes through the source pane when unmuted.
|
|
188
|
+
const clientTtys = process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd) ? await tmuxClientTtys(pi, pane) : [];
|
|
146
189
|
const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
|
|
147
|
-
|
|
190
|
+
await queueTerminalWrite(async () => {
|
|
191
|
+
if (await writeRawToPaths(clientTtys, sequence)) return;
|
|
192
|
+
await writeToTerminal(pane, output);
|
|
193
|
+
});
|
|
148
194
|
}
|
|
149
195
|
|
|
150
|
-
function writeTerminalBell(cwd?: string): void {
|
|
196
|
+
function writeTerminalBell(pane: TmuxPaneState | undefined, cwd?: string): Promise<void> {
|
|
151
197
|
// Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
|
|
152
198
|
// This lets tmux set window_bell_flag for the correct source window.
|
|
153
199
|
const sequence = terminalBellSequence(notificationBellMuted(cwd));
|
|
154
|
-
if (sequence)
|
|
200
|
+
if (!sequence) return Promise.resolve();
|
|
201
|
+
return queueTerminalWrite(() => writeToTerminal(pane, sequence));
|
|
155
202
|
}
|
|
156
203
|
|
|
157
|
-
function notifyOSC777(title: string, body: string, cwd?: string): void {
|
|
158
|
-
writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
|
|
204
|
+
function notifyOSC777(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
|
|
205
|
+
return writeTerminalSequence(pi, pane, osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
|
|
159
206
|
}
|
|
160
207
|
|
|
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);
|
|
208
|
+
async function notifyOSC99(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
|
|
209
|
+
await writeTerminalSequence(pi, pane, `\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
|
|
210
|
+
await writeTerminalSequence(pi, pane, `\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
|
|
164
211
|
}
|
|
165
212
|
|
|
166
213
|
function notifyWindows(title: string, body: string): void {
|
|
167
214
|
execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
|
|
168
215
|
}
|
|
169
216
|
|
|
170
|
-
function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
|
|
217
|
+
async function notifyNativeTerminal(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
|
|
171
218
|
const protocol = settingString("notification.oscProtocol", "auto", cwd);
|
|
172
219
|
if (process.env.WT_SESSION) {
|
|
173
220
|
notifyWindows(title, body);
|
|
@@ -175,60 +222,61 @@ function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
|
|
|
175
222
|
}
|
|
176
223
|
if (protocol === "off") return;
|
|
177
224
|
if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
|
|
178
|
-
notifyOSC99(title, body, cwd);
|
|
225
|
+
await notifyOSC99(pi, pane, title, body, cwd);
|
|
179
226
|
return;
|
|
180
227
|
}
|
|
181
|
-
notifyOSC777(title, body, cwd);
|
|
228
|
+
await notifyOSC777(pi, pane, title, body, cwd);
|
|
182
229
|
}
|
|
183
230
|
|
|
184
|
-
function notifyTmux(title: string, body: string, cwd?: string): void {
|
|
231
|
+
function notifyTmux(pi: QolNotificationExec, title: string, body: string, cwd?: string): void {
|
|
185
232
|
if (!process.env.TMUX && !process.env.TMUX_PANE) return;
|
|
186
233
|
const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
|
|
187
234
|
const message = `${title}: ${body}`;
|
|
188
235
|
const args = ["display-message", "-d", String(duration)];
|
|
189
236
|
if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
|
|
190
237
|
args.push(message);
|
|
191
|
-
|
|
238
|
+
void tmux(pi, args);
|
|
192
239
|
}
|
|
193
240
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
tmuxWindowMarkTimer = undefined;
|
|
241
|
+
/** Renames the marked window back, if one is marked. */
|
|
242
|
+
function unmarkTmuxWindow(pi: QolNotificationExec): void {
|
|
197
243
|
const target = tmuxMarkedTarget;
|
|
198
244
|
const original = tmuxOriginalWindowName;
|
|
199
245
|
tmuxMarkedTarget = undefined;
|
|
200
246
|
tmuxOriginalWindowName = undefined;
|
|
201
247
|
if (!target || !original) return;
|
|
202
|
-
|
|
248
|
+
void tmux(pi, ["rename-window", "-t", target, original]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Removes the window mark, and keeps a notification still in flight from setting one. */
|
|
252
|
+
export function clearTmuxWindowMark(pi: QolNotificationExec): void {
|
|
253
|
+
tmuxMarkGeneration += 1;
|
|
254
|
+
if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
|
|
255
|
+
tmuxWindowMarkTimer = undefined;
|
|
256
|
+
unmarkTmuxWindow(pi);
|
|
203
257
|
}
|
|
204
258
|
|
|
205
|
-
function markTmuxWindow(cwd?: string): void {
|
|
259
|
+
function markTmuxWindow(pi: QolNotificationExec, pane: TmuxPaneState | undefined, generation: number, cwd?: string): void {
|
|
206
260
|
if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
|
|
207
|
-
if (!process.env.TMUX || !
|
|
261
|
+
if (!process.env.TMUX || !pane || generation !== tmuxMarkGeneration) return;
|
|
208
262
|
const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
|
|
209
263
|
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
|
-
});
|
|
264
|
+
const target = pane.windowId;
|
|
265
|
+
const current = pane.windowName;
|
|
266
|
+
if (!current) return;
|
|
267
|
+
// The pane moved to another window since the last mark: that window gets its name back.
|
|
268
|
+
if (tmuxMarkedTarget && tmuxMarkedTarget !== target) unmarkTmuxWindow(pi);
|
|
269
|
+
if (!tmuxMarkedTarget) {
|
|
270
|
+
tmuxMarkedTarget = target;
|
|
271
|
+
tmuxOriginalWindowName = current.startsWith(prefix) ? current.slice(prefix.length) : current;
|
|
272
|
+
}
|
|
273
|
+
if (!current.startsWith(prefix)) void tmux(pi, ["rename-window", "-t", target, `${prefix}${current}`]);
|
|
274
|
+
const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
|
|
275
|
+
if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
|
|
276
|
+
if (duration > 0) {
|
|
277
|
+
tmuxWindowMarkTimer = setTimeout(() => clearTmuxWindowMark(pi), duration);
|
|
278
|
+
tmuxWindowMarkTimer.unref?.();
|
|
279
|
+
}
|
|
232
280
|
}
|
|
233
281
|
|
|
234
282
|
function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
|
|
@@ -243,24 +291,51 @@ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolea
|
|
|
243
291
|
}
|
|
244
292
|
}
|
|
245
293
|
|
|
246
|
-
|
|
294
|
+
/** Forget every notification cooldown and question dedup key; they belong to
|
|
295
|
+
* the session that sent them. */
|
|
296
|
+
export function resetQolNotificationCooldowns(): void {
|
|
297
|
+
lastNotificationAt.clear();
|
|
298
|
+
lastQuestionNotificationAt.clear();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The window mark and the tmux message never touch the terminal, so they start
|
|
303
|
+
* beside the terminal writes rather than behind them.
|
|
304
|
+
*/
|
|
305
|
+
async function deliverTerminalNotification(pi: QolNotificationExec, title: string, text: string, markGeneration: number, cwd?: string): Promise<void> {
|
|
306
|
+
const pane = await sourceTmuxPaneState(pi);
|
|
307
|
+
const windowActive = pane?.windowActive ?? false;
|
|
308
|
+
if (!windowActive) markTmuxWindow(pi, pane, markGeneration, cwd);
|
|
309
|
+
if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(pi, title, text, cwd);
|
|
310
|
+
if (settingBoolean("notification.bell", true, cwd) && (!windowActive || settingBoolean("notification.bellWhenActive", false, cwd))) await writeTerminalBell(pane, cwd);
|
|
311
|
+
if (settingBoolean("notification.native", true, cwd)) await notifyNativeTerminal(pi, pane, title, text, cwd);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Decides and records the notification synchronously, then delivers the
|
|
316
|
+
* terminal and tmux channels in the background: the returned promise settles
|
|
317
|
+
* when delivery is done, and no caller on Pi's event path awaits it, so a
|
|
318
|
+
* stalled tmux server or terminal never holds up a turn end.
|
|
319
|
+
*/
|
|
320
|
+
export function sendQolNotification(pi: QolNotificationExec, ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): Promise<void> {
|
|
247
321
|
const cwd = ctx?.cwd;
|
|
248
|
-
if (ctx && !ctx.hasUI) return;
|
|
249
|
-
if (!notificationEnabledFor(kind, cwd)) return;
|
|
322
|
+
if (ctx && !ctx.hasUI) return Promise.resolve();
|
|
323
|
+
if (!notificationEnabledFor(kind, cwd)) return Promise.resolve();
|
|
250
324
|
const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
|
|
251
325
|
const now = Date.now();
|
|
252
326
|
const last = lastNotificationAt.get(key) ?? 0;
|
|
253
|
-
if (cooldownMs > 0 && now - last < cooldownMs) return;
|
|
254
|
-
|
|
327
|
+
if (cooldownMs > 0 && now - last < cooldownMs) return Promise.resolve();
|
|
328
|
+
for (const [storedKey, sentAt] of lastNotificationAt) {
|
|
329
|
+
if (now - sentAt >= cooldownMs) lastNotificationAt.delete(storedKey);
|
|
330
|
+
}
|
|
331
|
+
if (cooldownMs > 0) lastNotificationAt.set(key, now);
|
|
255
332
|
|
|
256
333
|
const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
|
|
257
334
|
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
335
|
if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
|
|
336
|
+
// Every channel already absorbs its own failure; this catch keeps an
|
|
337
|
+
// unexpected throw from becoming an unhandled rejection, which ends Pi.
|
|
338
|
+
return deliverTerminalNotification(pi, title, text, tmuxMarkGeneration, cwd).catch(() => undefined);
|
|
264
339
|
}
|
|
265
340
|
|
|
266
341
|
function questionNotificationTitle(request?: QuestionRequestLike): string {
|
|
@@ -269,7 +344,7 @@ function questionNotificationTitle(request?: QuestionRequestLike): string {
|
|
|
269
344
|
return "Question";
|
|
270
345
|
}
|
|
271
346
|
|
|
272
|
-
export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
|
|
347
|
+
export function notifyQuestionOpened(pi: QolNotificationExec, ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
|
|
273
348
|
const title = questionNotificationTitle(event.request);
|
|
274
349
|
const key = `${keyPrefix}:${event.requestId ?? title}`;
|
|
275
350
|
const now = Date.now();
|
|
@@ -279,5 +354,5 @@ export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: Q
|
|
|
279
354
|
for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
|
|
280
355
|
if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
|
|
281
356
|
}
|
|
282
|
-
sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
|
|
357
|
+
void sendQolNotification(pi, ctx, "question", `Input required: ${title}`, "warning", key);
|
|
283
358
|
}
|