@vanillagreen/pi-qol 1.2.0 → 1.3.0
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 +1 -0
- package/extensions/qol/agent-end.ts +48 -0
- package/extensions/qol/agent-statusline.ts +52 -0
- package/extensions/qol/ansi.ts +115 -0
- package/extensions/qol/bridges.ts +78 -0
- package/extensions/qol/compaction.ts +264 -0
- package/extensions/qol/constants.ts +107 -0
- package/extensions/qol/context-usage.ts +492 -0
- package/extensions/qol/editor.ts +183 -0
- package/extensions/qol/handoff.ts +109 -0
- package/extensions/qol/images.ts +123 -0
- package/extensions/qol/notifications.ts +269 -0
- package/extensions/qol/pending-queue.ts +79 -0
- package/extensions/qol/permission-gate.ts +139 -0
- package/extensions/qol/session-rename.ts +323 -0
- package/extensions/qol/session-search/cache.ts +273 -0
- package/extensions/qol/session-search/component.ts +937 -0
- package/extensions/qol/session-search/context.ts +152 -0
- package/extensions/qol/session-search/index.ts +171 -0
- package/extensions/qol/session-search/search.ts +288 -0
- package/extensions/qol/session-search/types.ts +96 -0
- package/extensions/qol/settings.ts +74 -0
- package/extensions/qol/status-message.ts +41 -0
- package/extensions/qol/statusline.ts +219 -0
- package/extensions/qol/thinking-timer.ts +97 -0
- package/extensions/qol/util.ts +7 -0
- package/extensions/qol.ts +93 -4408
- package/package.json +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { complete, type Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { acquireVstackModalLock } from "./bridges.js";
|
|
5
|
+
import { serializeMessagesForSummary } from "./compaction.js";
|
|
6
|
+
import { HANDOFF_SYSTEM_PROMPT } from "./constants.js";
|
|
7
|
+
import { settingBoolean } from "./settings.js";
|
|
8
|
+
|
|
9
|
+
export async function runHandoff(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
10
|
+
if (!ctx.hasUI) {
|
|
11
|
+
ctx.ui.notify("handoff requires interactive mode", "error");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!ctx.model) {
|
|
16
|
+
ctx.ui.notify("No model selected", "error");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const goal = args.trim();
|
|
21
|
+
if (!goal) {
|
|
22
|
+
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const built = (ctx.sessionManager as any).buildSessionContext?.();
|
|
27
|
+
const messages = Array.isArray(built?.messages) ? built.messages as AgentMessage[] : [];
|
|
28
|
+
|
|
29
|
+
if (messages.length === 0) {
|
|
30
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const conversationText = serializeMessagesForSummary(messages);
|
|
35
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
36
|
+
|
|
37
|
+
const releaseModalLock = acquireVstackModalLock();
|
|
38
|
+
let result: string | null = null;
|
|
39
|
+
try {
|
|
40
|
+
result = await ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
|
|
41
|
+
const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
|
|
42
|
+
loader.onAbort = () => done(null);
|
|
43
|
+
|
|
44
|
+
const doGenerate = async () => {
|
|
45
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
|
|
46
|
+
if (!auth.ok || !auth.apiKey) {
|
|
47
|
+
throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const userMessage: Message = {
|
|
51
|
+
role: "user",
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text",
|
|
55
|
+
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const response = await complete(
|
|
62
|
+
ctx.model!,
|
|
63
|
+
{ systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
|
|
64
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (response.stopReason === "aborted") return null;
|
|
68
|
+
return response.content
|
|
69
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
70
|
+
.map((content) => content.text)
|
|
71
|
+
.join("\n");
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
doGenerate()
|
|
75
|
+
.then(done)
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
console.error("Handoff generation failed:", error);
|
|
78
|
+
done(null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return loader;
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
releaseModalLock();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (result === null) {
|
|
88
|
+
ctx.ui.notify("Cancelled", "info");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const prompt = settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? await ctx.ui.editor("Edit handoff prompt", result) : result;
|
|
93
|
+
if (prompt === undefined) {
|
|
94
|
+
ctx.ui.notify("Cancelled", "info");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const newSessionResult = await ctx.newSession({
|
|
99
|
+
parentSession: currentSessionFile,
|
|
100
|
+
withSession: async (replacementCtx: any) => {
|
|
101
|
+
replacementCtx.ui.setEditorText(prompt);
|
|
102
|
+
replacementCtx.ui.notify("Handoff ready. Submit when ready.", "info");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (newSessionResult.cancelled) {
|
|
107
|
+
ctx.ui.notify("New session cancelled", "info");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
|
|
5
|
+
import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js";
|
|
6
|
+
import { expandHome, settingBoolean } from "./settings.js";
|
|
7
|
+
|
|
8
|
+
function stripAtPrefix(path: string): string {
|
|
9
|
+
return path.startsWith("@") ? path.slice(1) : path;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveMaybeImagePath(path: string, cwd: string): string | undefined {
|
|
13
|
+
const clean = stripAtPrefix(path);
|
|
14
|
+
const expanded = expandHome(clean);
|
|
15
|
+
const resolved = expanded.startsWith("/") ? expanded : resolve(cwd, expanded);
|
|
16
|
+
const lower = resolved.toLowerCase();
|
|
17
|
+
const dot = lower.lastIndexOf(".");
|
|
18
|
+
if (dot < 0 || !IMAGE_EXTENSIONS.has(lower.slice(dot))) return undefined;
|
|
19
|
+
if (!existsSync(resolved)) return undefined;
|
|
20
|
+
return resolved;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function imagePathLabels(text: string, cwd: string): string[] {
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
for (const match of text.matchAll(IMAGE_PATH_PATTERN)) {
|
|
26
|
+
const resolved = resolveMaybeImagePath(match[2] ?? "", cwd);
|
|
27
|
+
if (resolved) seen.add(`Image ${basename(resolved)}`);
|
|
28
|
+
}
|
|
29
|
+
return [...seen].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mimeTypeForPath(path: string): string {
|
|
33
|
+
const lower = path.toLowerCase();
|
|
34
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
35
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
36
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
37
|
+
if (lower.endsWith(".bmp")) return "image/bmp";
|
|
38
|
+
if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return "image/tiff";
|
|
39
|
+
if (lower.endsWith(".heic")) return "image/heic";
|
|
40
|
+
if (lower.endsWith(".heif")) return "image/heif";
|
|
41
|
+
return "image/png";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function imageContentForPath(path: string): { type: "image"; data: string; mimeType: string } | undefined {
|
|
45
|
+
try {
|
|
46
|
+
return { data: readFileSync(path).toString("base64"), mimeType: mimeTypeForPath(path), type: "image" };
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function attachmentLabels(text: string, cwd = process.cwd()): string[] {
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
for (const match of text.matchAll(/\[Image\s+#(\d+)\]/gi)) {
|
|
55
|
+
seen.add(`Image #${match[1]}`);
|
|
56
|
+
}
|
|
57
|
+
for (const label of imagePathLabels(text, cwd)) seen.add(label);
|
|
58
|
+
return [...seen].sort((a, b) => Number(a.replace(/\D/g, "")) - Number(b.replace(/\D/g, "")) || a.localeCompare(b));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function chip(label: string, theme?: Theme): string {
|
|
62
|
+
const text = ` ${label} `;
|
|
63
|
+
if (theme) return theme.fg("accent", theme.inverse(text));
|
|
64
|
+
return `[${label}]`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function imageChipReplacements(visibleText: string, cwd: string, theme?: Theme): VisibleReplacement[] {
|
|
68
|
+
const replacements: VisibleReplacement[] = [];
|
|
69
|
+
|
|
70
|
+
for (const match of visibleText.matchAll(/\[Image\s+#(\d+)\]/gi)) {
|
|
71
|
+
const start = match.index ?? 0;
|
|
72
|
+
replacements.push({
|
|
73
|
+
start,
|
|
74
|
+
end: start + match[0].length,
|
|
75
|
+
text: chip(`Image #${match[1]}`, theme),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let imageIndex = 0;
|
|
80
|
+
for (const match of visibleText.matchAll(IMAGE_PATH_PATTERN)) {
|
|
81
|
+
const prefix = match[1] ?? "";
|
|
82
|
+
const rawPath = match[2] ?? "";
|
|
83
|
+
const resolved = resolveMaybeImagePath(rawPath, cwd);
|
|
84
|
+
if (!resolved) continue;
|
|
85
|
+
|
|
86
|
+
imageIndex += 1;
|
|
87
|
+
const start = (match.index ?? 0) + prefix.length;
|
|
88
|
+
replacements.push({
|
|
89
|
+
start,
|
|
90
|
+
end: start + rawPath.length,
|
|
91
|
+
text: chip(`Image ${imageIndex}`, theme),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return replacements;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function styleImageChips(line: string, cwd: string, theme?: Theme): string {
|
|
99
|
+
if (!settingBoolean("showImageChips", true, cwd)) return line;
|
|
100
|
+
const map = buildVisibleMap(line);
|
|
101
|
+
const replacements = imageChipReplacements(map.text, cwd, theme);
|
|
102
|
+
return replacements.length === 0 ? line : applyVisibleReplacements(line, map, replacements);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function statusText(ctx: ExtensionContext, text: string): string | undefined {
|
|
106
|
+
if (!settingBoolean("showAttachmentCountInStatus", true, ctx.cwd)) return undefined;
|
|
107
|
+
const count = attachmentLabels(text, ctx.cwd).length;
|
|
108
|
+
return count > 0 ? `images:${count}` : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Resolve every image-path-shaped substring in submitted text to an
|
|
112
|
+
/// absolute file path. Used by the `input` event to replace the previous
|
|
113
|
+
/// approach of mutating the editor buffer to `[Image #N]` aliases via a
|
|
114
|
+
/// 250ms polling timer (which raced with resize/input handling).
|
|
115
|
+
export function resolveSubmittedImagePaths(text: string, cwd: string): string[] {
|
|
116
|
+
const paths = new Set<string>();
|
|
117
|
+
for (const match of text.matchAll(IMAGE_PATH_PATTERN)) {
|
|
118
|
+
const resolved = resolveMaybeImagePath(match[2] ?? "", cwd);
|
|
119
|
+
if (resolved) paths.add(resolved);
|
|
120
|
+
}
|
|
121
|
+
return [...paths];
|
|
122
|
+
}
|
|
123
|
+
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_NOTIFICATION_BODY_MAX_CHARS,
|
|
6
|
+
DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
|
|
7
|
+
DEFAULT_NOTIFICATION_TITLE,
|
|
8
|
+
DEFAULT_TMUX_MESSAGE_DURATION_MS,
|
|
9
|
+
QUESTION_NOTIFY_DEDUP_MS,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { settingBoolean, settingNumber, settingString } from "./settings.js";
|
|
12
|
+
import type { QuestionOpenedEventLike, QuestionRequestLike } from "./bridges.js";
|
|
13
|
+
|
|
14
|
+
export type QolNotificationKind = "ready" | "direction" | "question" | "task-complete" | "critical" | "test";
|
|
15
|
+
export type QolNotificationLevel = "info" | "warning" | "error";
|
|
16
|
+
|
|
17
|
+
export interface QolNotificationService {
|
|
18
|
+
notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lastNotificationAt = new Map<string, number>();
|
|
22
|
+
const lastQuestionNotificationAt = new Map<string, number>();
|
|
23
|
+
let tmuxMarkedTarget: string | undefined;
|
|
24
|
+
let tmuxOriginalWindowName: string | undefined;
|
|
25
|
+
let tmuxWindowMarkTimer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
|
|
27
|
+
export function sanitizeNotificationPart(input: string, maxChars = DEFAULT_NOTIFICATION_BODY_MAX_CHARS): string {
|
|
28
|
+
const cleaned = input
|
|
29
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
return cleaned.length > maxChars ? `${cleaned.slice(0, Math.max(0, maxChars - 1))}…` : cleaned;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function windowsToastScript(title: string, body: string): string {
|
|
36
|
+
const escapedTitle = title.replace(/'/g, "''");
|
|
37
|
+
const escapedBody = body.replace(/'/g, "''");
|
|
38
|
+
const type = "Windows.UI.Notifications";
|
|
39
|
+
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
|
|
40
|
+
const template = `[${type}.ToastTemplateType]::ToastText02`;
|
|
41
|
+
const toast = `[${type}.ToastNotification]::new($xml)`;
|
|
42
|
+
return [
|
|
43
|
+
`${mgr} > $null`,
|
|
44
|
+
`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
|
|
45
|
+
`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${escapedTitle}')) > $null`,
|
|
46
|
+
`$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode('${escapedBody}')) > $null`,
|
|
47
|
+
`[${type}.ToastNotificationManager]::CreateToastNotifier('${escapedTitle}').Show(${toast})`,
|
|
48
|
+
].join("; ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tmuxPassthrough(sequence: string): string {
|
|
52
|
+
return `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sourcePaneTty(): string | undefined {
|
|
56
|
+
try {
|
|
57
|
+
if (!process.env.TMUX_PANE) return undefined;
|
|
58
|
+
const tty = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{pane_tty}"], { encoding: "utf8" }).trim();
|
|
59
|
+
return tty || undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sourceTmuxSession(): string | undefined {
|
|
66
|
+
try {
|
|
67
|
+
if (!process.env.TMUX_PANE) return undefined;
|
|
68
|
+
const session = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}"], { encoding: "utf8" }).trim();
|
|
69
|
+
return session || undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sourceTmuxWindowActive(): boolean {
|
|
76
|
+
try {
|
|
77
|
+
if (!process.env.TMUX_PANE) return false;
|
|
78
|
+
const active = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{window_active}"], { encoding: "utf8" }).trim();
|
|
79
|
+
return active === "1";
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tmuxClientTtys(): string[] {
|
|
86
|
+
try {
|
|
87
|
+
if (!process.env.TMUX) return [];
|
|
88
|
+
const session = sourceTmuxSession();
|
|
89
|
+
const args = ["list-clients", "-F", "#{client_tty}"];
|
|
90
|
+
if (session) args.splice(1, 0, "-t", session);
|
|
91
|
+
const output = execFileSync("tmux", args, { encoding: "utf8" });
|
|
92
|
+
return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeRawToPaths(paths: string[], output: string): boolean {
|
|
99
|
+
let wrote = false;
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
try {
|
|
102
|
+
writeFileSync(path, output, "utf8");
|
|
103
|
+
wrote = true;
|
|
104
|
+
} catch {
|
|
105
|
+
// Try remaining paths.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return wrote;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeToTerminal(output: string): void {
|
|
112
|
+
const tty = sourcePaneTty();
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(tty ?? "/dev/tty", output, "utf8");
|
|
115
|
+
return;
|
|
116
|
+
} catch {
|
|
117
|
+
// Fall through to stdout best-effort.
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
if (process.stdout.isTTY) process.stdout.write(output);
|
|
121
|
+
} catch {
|
|
122
|
+
// Notification best-effort only.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeTerminalSequence(sequence: string, cwd?: string): void {
|
|
127
|
+
if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
|
|
128
|
+
// Inactive tmux windows do not forward arbitrary OSC output to the terminal.
|
|
129
|
+
// Send native terminal notifications straight to attached tmux client TTYs,
|
|
130
|
+
// while BEL still goes through the source pane so tmux marks the right tab.
|
|
131
|
+
if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
|
|
132
|
+
}
|
|
133
|
+
const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
|
|
134
|
+
writeToTerminal(output);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeTerminalBell(): void {
|
|
138
|
+
// Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
|
|
139
|
+
// This lets tmux set window_bell_flag for the correct source window.
|
|
140
|
+
writeToTerminal("\x07");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function notifyOSC777(title: string, body: string, cwd?: string): void {
|
|
144
|
+
writeTerminalSequence(`\x1b]777;notify;${title};${body}\x07`, cwd);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function notifyOSC99(title: string, body: string, cwd?: string): void {
|
|
148
|
+
writeTerminalSequence(`\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
|
|
149
|
+
writeTerminalSequence(`\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function notifyWindows(title: string, body: string): void {
|
|
153
|
+
execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
|
|
157
|
+
const protocol = settingString("notification.oscProtocol", "auto", cwd);
|
|
158
|
+
if (process.env.WT_SESSION) {
|
|
159
|
+
notifyWindows(title, body);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (protocol === "off") return;
|
|
163
|
+
if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
|
|
164
|
+
notifyOSC99(title, body, cwd);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
notifyOSC777(title, body, cwd);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function notifyTmux(title: string, body: string, cwd?: string): void {
|
|
171
|
+
if (!process.env.TMUX && !process.env.TMUX_PANE) return;
|
|
172
|
+
const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
|
|
173
|
+
const message = `${title}: ${body}`;
|
|
174
|
+
const args = ["display-message", "-d", String(duration)];
|
|
175
|
+
if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
|
|
176
|
+
args.push(message);
|
|
177
|
+
execFile("tmux", args, () => undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function clearTmuxWindowMark(): void {
|
|
181
|
+
if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
|
|
182
|
+
tmuxWindowMarkTimer = undefined;
|
|
183
|
+
const target = tmuxMarkedTarget;
|
|
184
|
+
const original = tmuxOriginalWindowName;
|
|
185
|
+
tmuxMarkedTarget = undefined;
|
|
186
|
+
tmuxOriginalWindowName = undefined;
|
|
187
|
+
if (!target || !original) return;
|
|
188
|
+
execFile("tmux", ["rename-window", "-t", target, original], () => undefined);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function markTmuxWindow(cwd?: string): void {
|
|
192
|
+
if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
|
|
193
|
+
if (!process.env.TMUX || !process.env.TMUX_PANE) return;
|
|
194
|
+
const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
|
|
195
|
+
const prefix = `${mark} `;
|
|
196
|
+
execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}:#{window_index}"], (targetError, targetStdout) => {
|
|
197
|
+
if (targetError) return;
|
|
198
|
+
const target = targetStdout.trim();
|
|
199
|
+
if (!target) return;
|
|
200
|
+
execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE!, "#W"], (nameError, nameStdout) => {
|
|
201
|
+
if (nameError) return;
|
|
202
|
+
const current = nameStdout.replace(/\r?\n$/, "");
|
|
203
|
+
if (!current) return;
|
|
204
|
+
const original = current.startsWith(prefix) ? current.slice(prefix.length) : current;
|
|
205
|
+
if (!tmuxMarkedTarget || tmuxMarkedTarget !== target) {
|
|
206
|
+
tmuxMarkedTarget = target;
|
|
207
|
+
tmuxOriginalWindowName = original;
|
|
208
|
+
}
|
|
209
|
+
if (!current.startsWith(prefix)) execFile("tmux", ["rename-window", "-t", target, `${prefix}${current}`], () => undefined);
|
|
210
|
+
const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
|
|
211
|
+
if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
|
|
212
|
+
if (duration > 0) {
|
|
213
|
+
tmuxWindowMarkTimer = setTimeout(clearTmuxWindowMark, duration);
|
|
214
|
+
tmuxWindowMarkTimer.unref?.();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
|
|
221
|
+
if (!settingBoolean("notification.enabled", true, cwd)) return false;
|
|
222
|
+
switch (kind) {
|
|
223
|
+
case "ready": return settingBoolean("notification.onAgentReady", true, cwd);
|
|
224
|
+
case "direction": return settingBoolean("notification.onDirectionNeeded", true, cwd);
|
|
225
|
+
case "question": return settingBoolean("notification.onQuestion", true, cwd);
|
|
226
|
+
case "task-complete": return settingBoolean("notification.onTaskComplete", true, cwd);
|
|
227
|
+
case "critical": return settingBoolean("notification.onCritical", true, cwd);
|
|
228
|
+
case "test": return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function sendQolNotification(ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): void {
|
|
233
|
+
const cwd = ctx?.cwd;
|
|
234
|
+
if (ctx && !ctx.hasUI) return;
|
|
235
|
+
if (!notificationEnabledFor(kind, cwd)) return;
|
|
236
|
+
const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const last = lastNotificationAt.get(key) ?? 0;
|
|
239
|
+
if (cooldownMs > 0 && now - last < cooldownMs) return;
|
|
240
|
+
lastNotificationAt.set(key, now);
|
|
241
|
+
|
|
242
|
+
const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
|
|
243
|
+
const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
|
|
244
|
+
const tmuxWindowActive = sourceTmuxWindowActive();
|
|
245
|
+
if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell();
|
|
246
|
+
if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
|
|
247
|
+
if (!tmuxWindowActive) markTmuxWindow(cwd);
|
|
248
|
+
if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
|
|
249
|
+
if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function questionNotificationTitle(request?: QuestionRequestLike): string {
|
|
253
|
+
if (typeof request?.header === "string" && request.header.trim()) return request.header.trim();
|
|
254
|
+
if (typeof request?.question === "string" && request.question.trim()) return request.question.trim();
|
|
255
|
+
return "Question";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
|
|
259
|
+
const title = questionNotificationTitle(event.request);
|
|
260
|
+
const key = `${keyPrefix}:${event.requestId ?? title}`;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
const last = lastQuestionNotificationAt.get(key) ?? 0;
|
|
263
|
+
if (now - last < QUESTION_NOTIFY_DEDUP_MS) return;
|
|
264
|
+
lastQuestionNotificationAt.set(key, now);
|
|
265
|
+
for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
|
|
266
|
+
if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
|
|
267
|
+
}
|
|
268
|
+
sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
|
|
269
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Theme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ansiGreen, stripAnsi } from "./ansi.js";
|
|
4
|
+
import { PENDING_QUEUE_THEME_PATCH_SYMBOL, STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL } from "./constants.js";
|
|
5
|
+
import { settingBoolean } from "./settings.js";
|
|
6
|
+
|
|
7
|
+
interface PendingQueueThemePatch {
|
|
8
|
+
originalFg: unknown;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isPendingQueuePreviewText(text: string): boolean {
|
|
13
|
+
const plain = stripAnsi(text);
|
|
14
|
+
return plain.startsWith("Steering: ") || plain.startsWith("Follow-up: ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isPendingQueueHintText(text: string): boolean {
|
|
18
|
+
const plain = stripAnsi(text);
|
|
19
|
+
return plain.startsWith("↳ ") && plain.includes("queued messages");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pendingQueuePreviewLine(text: string): string {
|
|
23
|
+
return ansiGreen(`┃ ${text}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isQueuedMessageStatusText(text: string): boolean {
|
|
27
|
+
const plain = stripAnsi(text);
|
|
28
|
+
return /^Restored \d+ queued messages? to editor$/.test(plain) || plain === "No queued messages to restore";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function installStatusTextAlignmentPatch(): void {
|
|
32
|
+
const proto = Text.prototype as unknown as Record<PropertyKey, any>;
|
|
33
|
+
if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL]) return;
|
|
34
|
+
const originalRender = proto.render;
|
|
35
|
+
if (typeof originalRender !== "function") return;
|
|
36
|
+
proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = true;
|
|
37
|
+
proto.render = function patchedQolStatusTextRender(this: any, width: number): string[] {
|
|
38
|
+
const text = typeof this?.text === "string" ? this.text : "";
|
|
39
|
+
if (!isQueuedMessageStatusText(text)) return originalRender.call(this, width);
|
|
40
|
+
const originalPaddingX = this.paddingX;
|
|
41
|
+
try {
|
|
42
|
+
this.paddingX = 0;
|
|
43
|
+
this.invalidate?.();
|
|
44
|
+
return originalRender.call(this, width);
|
|
45
|
+
} finally {
|
|
46
|
+
this.paddingX = originalPaddingX;
|
|
47
|
+
this.invalidate?.();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function installPendingQueueThemePatch(ctx: ExtensionContext): void {
|
|
53
|
+
if (!ctx.hasUI) return;
|
|
54
|
+
const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
|
|
55
|
+
const existing = proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] as PendingQueueThemePatch | undefined;
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.cwd = ctx.cwd;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const originalFg = proto.fg;
|
|
61
|
+
if (typeof originalFg !== "function") return;
|
|
62
|
+
const patch: PendingQueueThemePatch = { originalFg, cwd: ctx.cwd };
|
|
63
|
+
proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] = patch;
|
|
64
|
+
proto.fg = function patchedQolFg(this: Theme, token: string, text: string): string {
|
|
65
|
+
if (token === "dim" && typeof text === "string" && settingBoolean("pendingQueue.asciiGreen", true, patch.cwd)) {
|
|
66
|
+
if (isPendingQueuePreviewText(text)) return pendingQueuePreviewLine(text);
|
|
67
|
+
if (isPendingQueueHintText(text)) return (patch.originalFg as (this: Theme, token: string, text: string) => string).call(this, token, ` ${text}`);
|
|
68
|
+
}
|
|
69
|
+
return (patch.originalFg as (this: Theme, token: string, text: string) => string).call(this, token, text);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function restorePendingQueueThemePatch(_ctx: ExtensionContext): void {
|
|
74
|
+
const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
|
|
75
|
+
const patch = proto[PENDING_QUEUE_THEME_PATCH_SYMBOL] as PendingQueueThemePatch | undefined;
|
|
76
|
+
if (!patch) return;
|
|
77
|
+
proto.fg = patch.originalFg;
|
|
78
|
+
delete proto[PENDING_QUEUE_THEME_PATCH_SYMBOL];
|
|
79
|
+
}
|