pi-ui-extend 0.1.5 → 0.1.8
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/dist/app/app.d.ts +2 -0
- package/dist/app/app.js +18 -0
- package/dist/app/extension-ui-controller.js +1 -0
- package/dist/app/icons.d.ts +2 -0
- package/dist/app/icons.js +4 -0
- package/dist/app/mouse-controller.d.ts +4 -1
- package/dist/app/mouse-controller.js +12 -0
- package/dist/app/render-controller.js +1 -0
- package/dist/app/session-lifecycle-controller.js +3 -3
- package/dist/app/status-line-renderer.d.ts +5 -1
- package/dist/app/status-line-renderer.js +23 -2
- package/dist/app/terminal-bell-sound-controller.d.ts +11 -0
- package/dist/app/terminal-bell-sound-controller.js +58 -0
- package/dist/app/types.d.ts +10 -0
- package/extensions/terminal-bell/index.ts +43 -13
- package/external/pi-tools-suite/README.md +4 -50
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +0 -1
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +8 -1
- package/external/pi-tools-suite/src/index.ts +0 -1
- package/external/pi-tools-suite/src/lsp/_shared/config.ts +38 -13
- package/external/pi-tools-suite/src/lsp/_shared/paths.ts +11 -1
- package/external/pi-tools-suite/src/lsp/async.ts +6 -1
- package/external/pi-tools-suite/src/lsp/child-process.ts +16 -2
- package/external/pi-tools-suite/src/lsp/client.ts +183 -4
- package/external/pi-tools-suite/src/lsp/manager.ts +44 -5
- package/external/pi-tools-suite/src/lsp/markdown-diagnostics.ts +157 -0
- package/package.json +1 -1
- package/external/pi-tools-suite/src/terminal-bell/index.ts +0 -309
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import type { Diagnostic } from "vscode-languageserver-protocol";
|
|
4
|
+
|
|
5
|
+
interface LineOffsets {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
readonly starts: number[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function lineOffsets(text: string): LineOffsets {
|
|
11
|
+
const starts = [0];
|
|
12
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
13
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
14
|
+
}
|
|
15
|
+
return { text, starts };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function positionAt(offsets: LineOffsets, offset: number): { line: number; character: number } {
|
|
19
|
+
let low = 0;
|
|
20
|
+
let high = offsets.starts.length - 1;
|
|
21
|
+
while (low <= high) {
|
|
22
|
+
const mid = Math.floor((low + high) / 2);
|
|
23
|
+
if (offsets.starts[mid] <= offset) low = mid + 1;
|
|
24
|
+
else high = mid - 1;
|
|
25
|
+
}
|
|
26
|
+
const line = Math.max(0, high);
|
|
27
|
+
return { line, character: Math.max(0, offset - offsets.starts[line]) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function diagnostic(offsets: LineOffsets, start: number, end: number, message: string, code: string, severity: Diagnostic["severity"] = 1): Diagnostic {
|
|
31
|
+
return {
|
|
32
|
+
severity,
|
|
33
|
+
source: "pi-markdown",
|
|
34
|
+
code,
|
|
35
|
+
message,
|
|
36
|
+
range: {
|
|
37
|
+
start: positionAt(offsets, start),
|
|
38
|
+
end: positionAt(offsets, Math.max(end, start + 1)),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addMarkdownLinkDiagnostics(file: string, text: string, offsets: LineOffsets, out: Diagnostic[]): void {
|
|
44
|
+
const definitions = new Map<string, Array<{ start: number; end: number }>>();
|
|
45
|
+
const usedReferences = new Set<string>();
|
|
46
|
+
|
|
47
|
+
for (const match of text.matchAll(/^\s{0,3}\[([^\]\r\n]+)\]:\s*(\S+)/gm)) {
|
|
48
|
+
const ref = match[1].trim().toLocaleLowerCase();
|
|
49
|
+
const start = (match.index ?? 0) + match[0].indexOf(match[1]);
|
|
50
|
+
const end = start + match[1].length;
|
|
51
|
+
const existing = definitions.get(ref) ?? [];
|
|
52
|
+
existing.push({ start, end });
|
|
53
|
+
definitions.set(ref, existing);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [ref, locations] of definitions) {
|
|
57
|
+
if (locations.length <= 1) continue;
|
|
58
|
+
for (const location of locations) {
|
|
59
|
+
out.push(diagnostic(offsets, location.start, location.end, `Duplicate link definition: '${ref}'`, "link.duplicate-definition", 2));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const match of text.matchAll(/(?<!!)(?:\[[^\]\r\n]+\]\[([^\]\r\n]*)\]|\[([^\]\r\n]+)\]\[\])/g)) {
|
|
64
|
+
const full = match[0];
|
|
65
|
+
const explicit = match[1];
|
|
66
|
+
const collapsed = match[2];
|
|
67
|
+
const ref = (explicit === "" ? collapsed : explicit)?.trim();
|
|
68
|
+
if (!ref) continue;
|
|
69
|
+
const normalized = ref.toLocaleLowerCase();
|
|
70
|
+
usedReferences.add(normalized);
|
|
71
|
+
if (definitions.has(normalized)) continue;
|
|
72
|
+
const start = (match.index ?? 0) + full.lastIndexOf(ref);
|
|
73
|
+
out.push(diagnostic(offsets, start, start + ref.length, `No link definition found: '${ref}'`, "link.no-such-reference"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [ref, locations] of definitions) {
|
|
77
|
+
if (usedReferences.has(ref)) continue;
|
|
78
|
+
for (const location of locations) {
|
|
79
|
+
out.push(diagnostic(offsets, location.start, location.end, "Link definition is unused", "link.unused-definition", 4));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fileLinkPattern = /(?<!!)\[[^\]\r\n]+\]\(([^)\s]+)(?:\s+[^)]*)?\)/g;
|
|
84
|
+
for (const match of text.matchAll(fileLinkPattern)) {
|
|
85
|
+
const href = match[1];
|
|
86
|
+
if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#")) continue;
|
|
87
|
+
const [targetPath] = href.split("#", 1);
|
|
88
|
+
if (!targetPath || targetPath.startsWith("mailto:")) continue;
|
|
89
|
+
const decoded = decodeURIComponent(targetPath);
|
|
90
|
+
const absolute = path.resolve(path.dirname(file), decoded);
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.existsSync(absolute)) {
|
|
93
|
+
const start = (match.index ?? 0) + match[0].indexOf(href);
|
|
94
|
+
out.push(diagnostic(offsets, start, start + href.length, `File does not exist: '${href}'`, "link.no-such-file"));
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore malformed/unsupported local paths.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mermaidStarters = /^(?:---|graph\s+(?:TB|BT|RL|LR|TD)|flowchart\s+(?:TB|BT|RL|LR|TD)|sequenceDiagram|classDiagram(?:-v2)?|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie(?:\s+title\b)?|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|sankey-beta|xyChart-beta|block-beta|packet-beta|architecture-beta)\b/i;
|
|
103
|
+
|
|
104
|
+
function mermaidBlocks(text: string): Array<{ content: string; startOffset: number; fenceStart: number; fenceEnd: number }> {
|
|
105
|
+
const blocks: Array<{ content: string; startOffset: number; fenceStart: number; fenceEnd: number }> = [];
|
|
106
|
+
const fencePattern = /^(```|~~~)\s*(?:mermaid|mmd)\b[^\r\n]*\r?\n([\s\S]*?)^\1\s*$/gim;
|
|
107
|
+
for (const match of text.matchAll(fencePattern)) {
|
|
108
|
+
const fenceStart = match.index ?? 0;
|
|
109
|
+
const contentStart = fenceStart + match[0].indexOf(match[2]);
|
|
110
|
+
blocks.push({ content: match[2], startOffset: contentStart, fenceStart, fenceEnd: fenceStart + match[0].length });
|
|
111
|
+
}
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function addMermaidDiagnostics(file: string, text: string, offsets: LineOffsets, out: Diagnostic[]): void {
|
|
116
|
+
const extension = path.extname(file).toLocaleLowerCase();
|
|
117
|
+
const blocks = [".mmd", ".mermaid"].includes(extension)
|
|
118
|
+
? [{ content: text, startOffset: 0, fenceStart: 0, fenceEnd: text.length }]
|
|
119
|
+
: mermaidBlocks(text);
|
|
120
|
+
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
const lines = block.content.split(/\r?\n/);
|
|
123
|
+
const firstIndex = lines.findIndex((line) => {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
return trimmed.length > 0 && !trimmed.startsWith("%%");
|
|
126
|
+
});
|
|
127
|
+
if (firstIndex === -1) {
|
|
128
|
+
out.push(diagnostic(offsets, block.fenceStart, block.fenceEnd, "Mermaid diagram is empty", "mermaid.empty"));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const beforeFirst = lines.slice(0, firstIndex).join("\n");
|
|
133
|
+
const firstOffset = block.startOffset + (beforeFirst ? beforeFirst.length + 1 : 0) + (lines[firstIndex].match(/^\s*/)?.[0].length ?? 0);
|
|
134
|
+
const firstLine = lines[firstIndex].trim();
|
|
135
|
+
if (!mermaidStarters.test(firstLine)) {
|
|
136
|
+
out.push(diagnostic(offsets, firstOffset, firstOffset + firstLine.length, "Mermaid diagram should start with a supported diagram type such as 'flowchart TD'", "mermaid.missing-diagram-type"));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let runningOffset = block.startOffset;
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const arrow = /\b[A-Za-z0-9_]+\s*->\s*[A-Za-z0-9_]+\b/.exec(line);
|
|
143
|
+
if (arrow) {
|
|
144
|
+
out.push(diagnostic(offsets, runningOffset + arrow.index, runningOffset + arrow.index + arrow[0].length, "Mermaid flowchart arrows use '-->' or another Mermaid arrow form, not '->'", "mermaid.invalid-arrow"));
|
|
145
|
+
}
|
|
146
|
+
runningOffset += line.length + 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function localMarkdownDiagnostics(file: string, text: string): Diagnostic[] {
|
|
152
|
+
const offsets = lineOffsets(text);
|
|
153
|
+
const diagnostics: Diagnostic[] = [];
|
|
154
|
+
addMarkdownLinkDiagnostics(file, text, offsets, diagnostics);
|
|
155
|
+
addMermaidDiagnostics(file, text, offsets, diagnostics);
|
|
156
|
+
return diagnostics;
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { delimiter, isAbsolute, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
const BELL = "\x07";
|
|
7
|
-
const DEFAULT_IDLE_DELAY_MS = 250;
|
|
8
|
-
const IDLE_RETRY_DELAY_MS = 100;
|
|
9
|
-
const MAX_IDLE_RETRIES = 40;
|
|
10
|
-
const SUBAGENTS_LIVE_COUNT_EVENT = "pi-tools-suite:async-subagents:live-count";
|
|
11
|
-
const DEFAULT_NOTIFICATION_TITLE = "Pi";
|
|
12
|
-
const DEFAULT_NOTIFICATION_MESSAGE = "Session stopped";
|
|
13
|
-
const DEFAULT_ASK_USER_NOTIFICATION_MESSAGE = "Waiting for your answer";
|
|
14
|
-
const DEFAULT_MAC_SOUND = "Glass";
|
|
15
|
-
|
|
16
|
-
const TERM_PROGRAM_BUNDLE_IDS: Record<string, string> = {
|
|
17
|
-
Apple_Terminal: "com.apple.Terminal",
|
|
18
|
-
iTerm: "com.googlecode.iterm2",
|
|
19
|
-
"iTerm.app": "com.googlecode.iterm2",
|
|
20
|
-
WezTerm: "com.github.wez.wezterm",
|
|
21
|
-
WarpTerminal: "dev.warp.Warp-Stable",
|
|
22
|
-
ghostty: "com.mitchellh.ghostty",
|
|
23
|
-
Ghostty: "com.mitchellh.ghostty",
|
|
24
|
-
kitty: "net.kovidgoyal.kitty",
|
|
25
|
-
Alacritty: "org.alacritty",
|
|
26
|
-
vscode: "com.microsoft.VSCode",
|
|
27
|
-
"vscode-insiders": "com.microsoft.VSCodeInsiders",
|
|
28
|
-
zed: "dev.zed.Zed",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
type Timer = ReturnType<typeof setTimeout>;
|
|
32
|
-
|
|
33
|
-
type SubagentsLiveCountEvent = {
|
|
34
|
-
count?: unknown;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
function parseDelayMs(value: string | undefined): number {
|
|
38
|
-
if (value === undefined || value.trim() === "") return DEFAULT_IDLE_DELAY_MS;
|
|
39
|
-
const parsed = Number(value);
|
|
40
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_IDLE_DELAY_MS;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function isTruthyEnv(value: string | undefined): boolean {
|
|
44
|
-
if (value === undefined) return false;
|
|
45
|
-
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function extensionDisabled(): boolean {
|
|
49
|
-
return isTruthyEnv(process.env.HEADLESS) || isTruthyEnv(process.env.PI_TERMINAL_BELL_DISABLED);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function canRingTerminal(): boolean {
|
|
53
|
-
if (process.env.PI_TERMINAL_BELL === "0") return false;
|
|
54
|
-
if (process.env.PI_TERMINAL_BELL_FORCE === "1") return true;
|
|
55
|
-
return Boolean(process.stdout.isTTY || process.stderr.isTTY);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function writeBell(): void {
|
|
59
|
-
const stream = process.stdout.isTTY || !process.stderr.isTTY ? process.stdout : process.stderr;
|
|
60
|
-
stream.write(BELL);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function soundEnabled(ctx: ExtensionContext): boolean {
|
|
64
|
-
if (process.env.PI_TERMINAL_BELL_SOUND === "0") return false;
|
|
65
|
-
if (process.env.PI_TERMINAL_BELL_SOUND === "1") return true;
|
|
66
|
-
return ctx.hasUI === true;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function notificationsEnabled(ctx: ExtensionContext): boolean {
|
|
70
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY === "0") return false;
|
|
71
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY === "1") return true;
|
|
72
|
-
return ctx.hasUI === true;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function appleScriptString(value: string): string {
|
|
76
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function shellSingleQuote(value: string): string {
|
|
80
|
-
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function spawnDetached(command: string, args: string[]): void {
|
|
84
|
-
try {
|
|
85
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
86
|
-
child.on("error", () => {});
|
|
87
|
-
child.unref();
|
|
88
|
-
} catch {
|
|
89
|
-
// Best-effort user attention signal. Missing notification backends should not
|
|
90
|
-
// affect the agent loop or suppress the terminal bell.
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function findExecutable(command: string): string | undefined {
|
|
95
|
-
if (command.includes("/")) return existsSync(command) ? command : undefined;
|
|
96
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
97
|
-
if (!dir) continue;
|
|
98
|
-
const candidate = join(dir, command);
|
|
99
|
-
if (existsSync(candidate)) return candidate;
|
|
100
|
-
}
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function resolveMacSoundPath(sound: string): string {
|
|
105
|
-
if (isAbsolute(sound)) return sound;
|
|
106
|
-
const fileName = sound.endsWith(".aiff") ? sound : `${sound}.aiff`;
|
|
107
|
-
return `/System/Library/Sounds/${fileName}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function detectMacActivationBundleId(): string | undefined {
|
|
111
|
-
const explicit = process.env.PI_TERMINAL_BELL_NOTIFY_ACTIVATE;
|
|
112
|
-
if (explicit === "0" || explicit === "false") return undefined;
|
|
113
|
-
if (explicit && explicit.trim() !== "") return explicit.trim();
|
|
114
|
-
|
|
115
|
-
// GUI apps that launch shells on macOS commonly export their own bundle id.
|
|
116
|
-
// This catches Terminal.app, iTerm2, Zed's terminal, VS Code terminals, etc.
|
|
117
|
-
const inheritedBundleId = process.env.__CFBundleIdentifier;
|
|
118
|
-
if (inheritedBundleId && inheritedBundleId.trim() !== "") return inheritedBundleId.trim();
|
|
119
|
-
|
|
120
|
-
const termProgram = process.env.TERM_PROGRAM;
|
|
121
|
-
if (!termProgram) return undefined;
|
|
122
|
-
return TERM_PROGRAM_BUNDLE_IDS[termProgram];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function playAttentionSound(ctx: ExtensionContext): void {
|
|
126
|
-
if (!soundEnabled(ctx)) return;
|
|
127
|
-
if (process.platform !== "darwin") return;
|
|
128
|
-
const sound = process.env.PI_TERMINAL_BELL_SOUND && process.env.PI_TERMINAL_BELL_SOUND !== "1"
|
|
129
|
-
? process.env.PI_TERMINAL_BELL_SOUND
|
|
130
|
-
: DEFAULT_MAC_SOUND;
|
|
131
|
-
const soundPath = resolveMacSoundPath(sound);
|
|
132
|
-
if (!existsSync(soundPath)) return;
|
|
133
|
-
spawnDetached("/usr/bin/afplay", [soundPath]);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function notifySessionStopped(
|
|
137
|
-
ctx: ExtensionContext,
|
|
138
|
-
macActivationBundleId: string | undefined,
|
|
139
|
-
message = process.env.PI_TERMINAL_BELL_NOTIFY_MESSAGE ?? DEFAULT_NOTIFICATION_MESSAGE,
|
|
140
|
-
): void {
|
|
141
|
-
if (!notificationsEnabled(ctx)) return;
|
|
142
|
-
const title = process.env.PI_TERMINAL_BELL_NOTIFY_TITLE ?? DEFAULT_NOTIFICATION_TITLE;
|
|
143
|
-
|
|
144
|
-
if (process.platform === "darwin") {
|
|
145
|
-
const terminalNotifier = findExecutable(process.env.PI_TERMINAL_BELL_NOTIFIER ?? "terminal-notifier");
|
|
146
|
-
if (terminalNotifier) {
|
|
147
|
-
const args = ["-title", title, "-message", message];
|
|
148
|
-
const activate = macActivationBundleId;
|
|
149
|
-
if (activate) {
|
|
150
|
-
args.push("-activate", activate);
|
|
151
|
-
args.push("-execute", `/usr/bin/open -b ${shellSingleQuote(activate)}`);
|
|
152
|
-
|
|
153
|
-
// Do not pass -sender by default. On recent macOS versions it can make the
|
|
154
|
-
// notification look like it came from the target app, but then clicking the
|
|
155
|
-
// “Show” button may be handled as that app's own notification instead of
|
|
156
|
-
// terminal-notifier's -activate/-execute action.
|
|
157
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY_SENDER === "1") {
|
|
158
|
-
args.push("-sender", activate);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
spawnDetached(terminalNotifier, args);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Bare osascript notifications are sent by Script Editor/osascript on macOS;
|
|
166
|
-
// clicking them can open Script Editor's file picker. Keep that backend opt-in
|
|
167
|
-
// and prefer terminal-notifier for clickable system notifications.
|
|
168
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY_OSASCRIPT !== "1") return;
|
|
169
|
-
spawnDetached("/usr/bin/osascript", [
|
|
170
|
-
"-e",
|
|
171
|
-
`display notification ${appleScriptString(message)} with title ${appleScriptString(title)}`,
|
|
172
|
-
]);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (process.platform === "linux") {
|
|
177
|
-
spawnDetached("notify-send", [title, message]);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
|
|
182
|
-
|
|
183
|
-
function isAskUserToolName(toolName: string): boolean {
|
|
184
|
-
return ASK_USER_TOOL_NAMES.has(toolName);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isSubagentsWaitTool(toolName: string, args: unknown): boolean {
|
|
188
|
-
if (toolName === "async_subagents_wait") return true;
|
|
189
|
-
if (toolName !== "subagents") return false;
|
|
190
|
-
if (!args || typeof args !== "object") return false;
|
|
191
|
-
const action = (args as { action?: unknown }).action;
|
|
192
|
-
return typeof action === "string" && action.trim().toLowerCase() === "wait";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function normalizeLiveCount(event: SubagentsLiveCountEvent): number | undefined {
|
|
196
|
-
if (typeof event.count !== "number" || !Number.isFinite(event.count)) return undefined;
|
|
197
|
-
return Math.max(0, Math.floor(event.count));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export default function terminalBell(pi: ExtensionAPI) {
|
|
201
|
-
if (extensionDisabled()) return;
|
|
202
|
-
|
|
203
|
-
let timer: Timer | undefined;
|
|
204
|
-
let lastCtx: ExtensionContext | undefined;
|
|
205
|
-
let deferredUntilSubagentsFinish = false;
|
|
206
|
-
let liveSubagentCount = 0;
|
|
207
|
-
const activeSubagentWaitToolCallIds = new Set<string>();
|
|
208
|
-
const notifiedAskUserToolCallIds = new Set<string>();
|
|
209
|
-
const idleDelayMs = parseDelayMs(process.env.PI_TERMINAL_BELL_DELAY_MS);
|
|
210
|
-
const macActivationBundleId = process.platform === "darwin" ? detectMacActivationBundleId() : undefined;
|
|
211
|
-
|
|
212
|
-
function clearTimer(): void {
|
|
213
|
-
if (!timer) return;
|
|
214
|
-
clearTimeout(timer);
|
|
215
|
-
timer = undefined;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function hasSubagentWork(): boolean {
|
|
219
|
-
return liveSubagentCount > 0 || activeSubagentWaitToolCallIds.size > 0;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function notifyAttention(ctx: ExtensionContext, message?: string): void {
|
|
223
|
-
if (canRingTerminal()) writeBell();
|
|
224
|
-
playAttentionSound(ctx);
|
|
225
|
-
notifySessionStopped(ctx, macActivationBundleId, message);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function attemptBell(ctx: ExtensionContext, attempt: number): void {
|
|
229
|
-
timer = undefined;
|
|
230
|
-
|
|
231
|
-
if (!ctx.isIdle()) {
|
|
232
|
-
if (attempt < MAX_IDLE_RETRIES) scheduleBell(ctx, IDLE_RETRY_DELAY_MS, attempt + 1);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (ctx.hasPendingMessages()) return;
|
|
237
|
-
|
|
238
|
-
if (hasSubagentWork()) {
|
|
239
|
-
deferredUntilSubagentsFinish = true;
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
deferredUntilSubagentsFinish = false;
|
|
244
|
-
notifyAttention(ctx);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function scheduleBell(ctx: ExtensionContext, delayMs = idleDelayMs, attempt = 0): void {
|
|
248
|
-
lastCtx = ctx;
|
|
249
|
-
clearTimer();
|
|
250
|
-
timer = setTimeout(() => attemptBell(ctx, attempt), delayMs);
|
|
251
|
-
timer.unref?.();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function notifyAskUserWaiting(toolCallId: string, ctx: ExtensionContext): void {
|
|
255
|
-
if (notifiedAskUserToolCallIds.has(toolCallId)) return;
|
|
256
|
-
notifiedAskUserToolCallIds.add(toolCallId);
|
|
257
|
-
notifyAttention(ctx, process.env.PI_TERMINAL_BELL_ASK_USER_NOTIFY_MESSAGE ?? DEFAULT_ASK_USER_NOTIFICATION_MESSAGE);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
pi.events.on(SUBAGENTS_LIVE_COUNT_EVENT, (data: unknown) => {
|
|
261
|
-
const event = data && typeof data === "object" ? data as SubagentsLiveCountEvent : {};
|
|
262
|
-
const count = normalizeLiveCount(event);
|
|
263
|
-
if (count === undefined) return;
|
|
264
|
-
liveSubagentCount = count;
|
|
265
|
-
if (count === 0 && deferredUntilSubagentsFinish && lastCtx) {
|
|
266
|
-
scheduleBell(lastCtx);
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
pi.on("agent_start", async () => {
|
|
271
|
-
clearTimer();
|
|
272
|
-
deferredUntilSubagentsFinish = false;
|
|
273
|
-
activeSubagentWaitToolCallIds.clear();
|
|
274
|
-
notifiedAskUserToolCallIds.clear();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
pi.on("tool_execution_start", async (event, ctx) => {
|
|
278
|
-
if (isSubagentsWaitTool(event.toolName, event.args)) {
|
|
279
|
-
activeSubagentWaitToolCallIds.add(event.toolCallId);
|
|
280
|
-
}
|
|
281
|
-
if (isAskUserToolName(event.toolName)) {
|
|
282
|
-
notifyAskUserWaiting(event.toolCallId, ctx);
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
287
|
-
if (isAskUserToolName(event.toolName)) {
|
|
288
|
-
notifyAskUserWaiting(event.toolCallId, ctx);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
pi.on("tool_execution_end", async (event) => {
|
|
293
|
-
activeSubagentWaitToolCallIds.delete(event.toolCallId);
|
|
294
|
-
notifiedAskUserToolCallIds.delete(event.toolCallId);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
298
|
-
scheduleBell(ctx);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
pi.on("session_shutdown", async () => {
|
|
302
|
-
clearTimer();
|
|
303
|
-
lastCtx = undefined;
|
|
304
|
-
deferredUntilSubagentsFinish = false;
|
|
305
|
-
liveSubagentCount = 0;
|
|
306
|
-
activeSubagentWaitToolCallIds.clear();
|
|
307
|
-
notifiedAskUserToolCallIds.clear();
|
|
308
|
-
});
|
|
309
|
-
}
|