@vanillagreen/pi-qol 1.1.1 → 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 +2 -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 +130 -4380
- package/package.json +11 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { CustomEditor, Theme, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type AutocompleteSuggestions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { stripAnsi } from "./ansi.js";
|
|
4
|
+
import { readCavemanBridge } from "./bridges.js";
|
|
5
|
+
import { STATUS_KEY } from "./constants.js";
|
|
6
|
+
import { statusText, styleImageChips } from "./images.js";
|
|
7
|
+
import { newlineFallbackKey, settingBoolean } from "./settings.js";
|
|
8
|
+
|
|
9
|
+
function styleAutocompleteHintItem(item: AutocompleteItem, theme: Theme): AutocompleteItem {
|
|
10
|
+
const label = stripAnsi(item.label || item.value);
|
|
11
|
+
const styled: AutocompleteItem = { ...item, label: theme.fg("accent", label) };
|
|
12
|
+
if (typeof item.description === "string" && item.description.length > 0) {
|
|
13
|
+
styled.description = theme.fg("text", stripAnsi(item.description));
|
|
14
|
+
}
|
|
15
|
+
return styled;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function styleSlashAutocompleteHints(suggestions: AutocompleteSuggestions | null, theme: Theme): AutocompleteSuggestions | null {
|
|
19
|
+
if (!suggestions || !suggestions.prefix.startsWith("/")) return suggestions;
|
|
20
|
+
return { ...suggestions, items: suggestions.items.map((item) => styleAutocompleteHintItem(item, theme)) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installAutocompleteHintStyling(ctx: ExtensionContext): void {
|
|
24
|
+
if (!ctx.hasUI) return;
|
|
25
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
26
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
27
|
+
return styleSlashAutocompleteHints(await current.getSuggestions(lines, cursorLine, cursorCol, options), ctx.ui.theme);
|
|
28
|
+
},
|
|
29
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
30
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
31
|
+
},
|
|
32
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
33
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const QOL_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
|
|
39
|
+
{ value: "status", label: "status", description: "Show QOL status and current settings" },
|
|
40
|
+
{ value: "rename", label: "rename", description: "Generate a session name from the first user message" },
|
|
41
|
+
{ value: "notify-test", label: "notify-test", description: "Send a test QOL notification" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const QOL_RENAME_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
|
|
45
|
+
{ value: "rename full", label: "rename full", description: "Generate a session name from the full conversation" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function getQolArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
49
|
+
const query = prefix.trimStart().toLowerCase();
|
|
50
|
+
const items = query.startsWith("rename ") ? QOL_RENAME_ARGUMENT_COMPLETIONS : QOL_ARGUMENT_COMPLETIONS;
|
|
51
|
+
const filtered = items.filter((item) => item.value.toLowerCase().startsWith(query));
|
|
52
|
+
return filtered.length > 0 ? filtered : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SESSION_SEARCH_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
|
|
56
|
+
{ value: "refresh", label: "refresh", description: "Refresh the session search index" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function getSessionSearchArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
60
|
+
const query = prefix.trimStart().toLowerCase();
|
|
61
|
+
const filtered = SESSION_SEARCH_ARGUMENT_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(query) || (item.label ?? item.value).toLowerCase().startsWith(query));
|
|
62
|
+
return filtered.length > 0 ? filtered : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleQolEditorInput(editor: CustomEditor, ctx: ExtensionContext, data: string): void {
|
|
66
|
+
if (matchesKey(data, "alt+c")) {
|
|
67
|
+
const caveman = readCavemanBridge();
|
|
68
|
+
if (caveman?.cycleMode) {
|
|
69
|
+
const next = caveman.cycleMode(ctx.cwd);
|
|
70
|
+
if (typeof next === "string") {
|
|
71
|
+
try { ctx.ui.notify(next === "off" ? "Caveman off." : `Caveman ${next} active.`, "info"); } catch { /* notify is best-effort */ }
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const fallback = newlineFallbackKey(ctx.cwd);
|
|
77
|
+
const newlineEnabled = settingBoolean("newlineOnShiftEnter", true, ctx.cwd);
|
|
78
|
+
const isShiftEnter = matchesKey(data, "shift+enter") || matchesKey(data, "shift+return");
|
|
79
|
+
const isFallback = fallback !== "none" && matchesKey(data, fallback);
|
|
80
|
+
CustomEditor.prototype.handleInput.call(editor, newlineEnabled && (isShiftEnter || isFallback) ? "\n" : data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Push the latest attachment-count status only when it actually changes.
|
|
84
|
+
/// Called from render() so we follow the rainbow-editor pattern (pull from
|
|
85
|
+
/// render, don't push from every keystroke) and avoid the per-keystroke
|
|
86
|
+
/// setStatus side effect that previously contributed to resize/input races.
|
|
87
|
+
function syncQolEditorStatus(
|
|
88
|
+
ctx: ExtensionContext,
|
|
89
|
+
text: string,
|
|
90
|
+
cache: { last?: string },
|
|
91
|
+
): void {
|
|
92
|
+
const next = statusText(ctx, text);
|
|
93
|
+
if (next === cache.last) return;
|
|
94
|
+
cache.last = next;
|
|
95
|
+
try {
|
|
96
|
+
ctx.ui.setStatus(STATUS_KEY, next);
|
|
97
|
+
} catch {
|
|
98
|
+
// best-effort visual; don't let a status push break render
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class QolEditor extends CustomEditor {
|
|
103
|
+
private readonly statusCache: { last?: string } = {};
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
tui: TUI,
|
|
107
|
+
editorTheme: EditorTheme,
|
|
108
|
+
keybindings: KeybindingsManager,
|
|
109
|
+
private readonly ctx: ExtensionContext,
|
|
110
|
+
) {
|
|
111
|
+
super(tui, editorTheme, keybindings);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
handleInput(data: string): void {
|
|
115
|
+
handleQolEditorInput(this, this.ctx, data);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
render(width: number): string[] {
|
|
119
|
+
syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
|
|
120
|
+
return super.render(width).map((line) => truncateToWidth(styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme), width, ""));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isEditorBorderLine(line: string): boolean {
|
|
125
|
+
const visible = stripAnsi(line).trim();
|
|
126
|
+
return visible.length > 0 && /^[─━╭╮╰╯┌┐└┘]+$/.test(visible);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class QolCompactPromptEditor extends CustomEditor {
|
|
130
|
+
private readonly statusCache: { last?: string } = {};
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
tui: TUI,
|
|
134
|
+
editorTheme: EditorTheme,
|
|
135
|
+
keybindings: KeybindingsManager,
|
|
136
|
+
private readonly inputBottomPaddingLines: number,
|
|
137
|
+
private readonly ctx: ExtensionContext,
|
|
138
|
+
) {
|
|
139
|
+
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
handleInput(data: string): void {
|
|
143
|
+
handleQolEditorInput(this, this.ctx, data);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
render(width: number): string[] {
|
|
147
|
+
syncQolEditorStatus(this.ctx, this.getText(), this.statusCache);
|
|
148
|
+
const prompt = this.borderColor("π");
|
|
149
|
+
const prefix = `${prompt} `;
|
|
150
|
+
const prefixWidth = visibleWidth("π ");
|
|
151
|
+
const continuationPrefix = " ".repeat(prefixWidth);
|
|
152
|
+
const innerWidth = Math.max(1, width - prefixWidth);
|
|
153
|
+
const rendered = super.render(innerWidth);
|
|
154
|
+
|
|
155
|
+
const inputLines: string[] = [];
|
|
156
|
+
let completionLines: string[] = [];
|
|
157
|
+
for (let index = 1; index < rendered.length; index++) {
|
|
158
|
+
const line = rendered[index] ?? "";
|
|
159
|
+
if (isEditorBorderLine(line)) {
|
|
160
|
+
completionLines = rendered.slice(index + 1);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
inputLines.push(line);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const lines = (inputLines.length > 0 ? inputLines : [""]).map((line, index) => {
|
|
167
|
+
const linePrefix = index === 0 ? prefix : continuationPrefix;
|
|
168
|
+
const content = styleImageChips(line, this.ctx.cwd, this.ctx.ui.theme);
|
|
169
|
+
return truncateToWidth(linePrefix + content, width, "");
|
|
170
|
+
});
|
|
171
|
+
for (let index = 0; index < this.inputBottomPaddingLines; index++) lines.push("");
|
|
172
|
+
for (const line of completionLines) lines.push(truncateToWidth(`${this.ctx.ui.theme.fg("dim", continuationPrefix)}${line}`, width, ""));
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function currentEditorText(ctx: ExtensionContext): string {
|
|
178
|
+
try {
|
|
179
|
+
return ctx.ui.getEditorText?.() ?? "";
|
|
180
|
+
} catch {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -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
|
+
}
|