pum-agent 0.1.0-beta.3

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.
@@ -0,0 +1,47 @@
1
+ import type { InlineExtension } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const EXPLANATION_STRENGTHS = ["none", "simple", "detailed"] as const;
4
+ export type ExplanationStrength = (typeof EXPLANATION_STRENGTHS)[number];
5
+
6
+ let currentStrength: ExplanationStrength = "simple";
7
+
8
+ export const EXPLANATION_PROMPTS: Record<Exclude<ExplanationStrength, "none">, string> = {
9
+ simple: `## Explanation strength: simple
10
+
11
+ Use regular assistant output to state briefly what you are doing and why.
12
+ Give concise progress updates before important actions.
13
+ Summarize the result when the work is complete.
14
+ Do not put these explanations only in hidden reasoning.`,
15
+ detailed: `## Explanation strength: detailed
16
+
17
+ Use regular assistant output to explain what you are doing and why.
18
+ Explain the plan before implementation.
19
+ Report important actions, decisions, tradeoffs, and validation as the work proceeds.
20
+ Summarize the result and any remaining concerns when the work is complete.
21
+ Do not put these explanations only in hidden reasoning.
22
+ Do not reveal private chain-of-thought. Give useful rationale summaries instead.`,
23
+ };
24
+
25
+ export function isExplanationStrength(value: unknown): value is ExplanationStrength {
26
+ return EXPLANATION_STRENGTHS.includes(value as ExplanationStrength);
27
+ }
28
+
29
+ export function setExplanationStrength(strength: ExplanationStrength): void {
30
+ currentStrength = strength;
31
+ }
32
+
33
+ export function getExplanationStrength(): ExplanationStrength {
34
+ return currentStrength;
35
+ }
36
+
37
+ export const explanationStrengthExtension: InlineExtension = {
38
+ name: "pum-explanation-strength",
39
+ factory(pi) {
40
+ pi.on("before_agent_start", (event) => {
41
+ if (currentStrength === "none") return;
42
+ return {
43
+ systemPrompt: `${event.systemPrompt}\n\n${EXPLANATION_PROMPTS[currentStrength]}`,
44
+ };
45
+ });
46
+ },
47
+ };
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ /**
5
+ * pi has a FooterDataProvider that does this, but only the read-only *type* is
6
+ * exported from the package root and deep imports are blocked by its exports
7
+ * map, so PUM reads HEAD itself.
8
+ */
9
+ function findGitDir(startDir: string): string | null {
10
+ let dir = resolve(startDir);
11
+ for (;;) {
12
+ const dotGit = join(dir, ".git");
13
+ if (existsSync(dotGit)) {
14
+ if (statSync(dotGit).isDirectory()) return dotGit;
15
+ // In a worktree, .git is a file holding "gitdir: <path>".
16
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
17
+ return match ? resolve(dir, match[1]!.trim()) : null;
18
+ }
19
+ const parent = dirname(dir);
20
+ if (parent === dir) return null;
21
+ dir = parent;
22
+ }
23
+ }
24
+
25
+ export function readBranch(cwd: string): string | null {
26
+ try {
27
+ const gitDir = findGitDir(cwd);
28
+ if (!gitDir) return null;
29
+ const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
30
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
31
+ return ref ? ref[1]! : "detached";
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Calls back when HEAD changes — a checkout, a commit on a new branch. */
38
+ export function watchBranch(cwd: string, onChange: () => void): () => void {
39
+ const gitDir = findGitDir(cwd);
40
+ if (!gitDir) return () => {};
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ try {
43
+ const watcher = watch(join(gitDir, "HEAD"), () => {
44
+ clearTimeout(timer);
45
+ timer = setTimeout(onChange, 100);
46
+ });
47
+ return () => {
48
+ clearTimeout(timer);
49
+ watcher.close();
50
+ };
51
+ } catch {
52
+ return () => clearTimeout(timer);
53
+ }
54
+ }
@@ -0,0 +1,279 @@
1
+ import type { Theme } from "./theme";
2
+
3
+ type HelpGroup = { title: string; controls: [string, string][] };
4
+
5
+ export const HELP_SUMMARY_WIDE = [
6
+ "PUM workflow — prompt or steer · cache prompts · attach images · run managed worktree agents in parallel",
7
+ "switch transcripts · merge successful agents · persist sessions · use Settings and safety checks",
8
+ ] as const;
9
+
10
+ export const HELP_SUMMARY = [
11
+ "Prompt or steer. Cache prompts. Attach images.",
12
+ "Run managed worktree agents in parallel.",
13
+ "Switch transcripts and merge successful agents.",
14
+ "Sessions persist. Settings include safety checks.",
15
+ ] as const;
16
+
17
+ /** Shown when `?` is typed into an empty prompt. Keep this list aligned with app.tsx. */
18
+ export const HELP_GROUPS: HelpGroup[] = [
19
+ {
20
+ title: "Prompt",
21
+ controls: [
22
+ ["Enter", "Send, or steer while working"],
23
+ ["Ctrl/Shift+Enter", "Insert a new line"],
24
+ ["\\ then Enter", "Insert a new line fallback"],
25
+ ["Alt+Enter", "Cache without sending"],
26
+ ["Ctrl+Alt+Enter", "Cache alias"],
27
+ ["Alt+V", "Attach a clipboard image"],
28
+ ["Ctrl+Backspace", "Delete the previous word"],
29
+ ],
30
+ },
31
+ {
32
+ title: "Cache and agents",
33
+ controls: [
34
+ ["Tab", "Open cache, or load selection"],
35
+ ["Shift+↑ / ↓", "Select cached task range"],
36
+ ["Delete", "Remove selected cached task"],
37
+ ["Shift+Tab", "Next agent transcript"],
38
+ ["Ctrl+Shift+Tab", "Previous agent transcript"],
39
+ ["Ctrl+L", "Open agent transcript selector"],
40
+ ],
41
+ },
42
+ {
43
+ title: "History and sessions",
44
+ controls: [
45
+ ["↑ / ↓", "Browse prompt history"],
46
+ ["Ctrl+H", "Open session history"],
47
+ ["pum -r", "Resume the last session"],
48
+ ],
49
+ },
50
+ {
51
+ title: "Commands",
52
+ controls: [
53
+ ["/compress", "Summarize older context"],
54
+ ["/clear", "Start a fresh session"],
55
+ ["/history", "Browse saved sessions"],
56
+ ["/login", "Add or update a provider"],
57
+ ["/worktree", "Create a managed worktree"],
58
+ ["Tab", "Complete a command preview"],
59
+ ],
60
+ },
61
+ {
62
+ title: "Application",
63
+ controls: [
64
+ ["Ctrl+P", "Open Settings"],
65
+ ["/ in Settings", "Focus settings search"],
66
+ ["Esc", "Close; twice to cancel work"],
67
+ ["Ctrl+C", "Press twice to quit"],
68
+ ["?", "Open or close this help"],
69
+ ],
70
+ },
71
+ ];
72
+
73
+ const KEY_WIDTH = 17;
74
+ type HelpLine = { kind: "heading"; text: string } | { kind: "control"; key: string; what: string } | { kind: "blank" };
75
+
76
+ export function helpLines(terminalHeight: number): HelpLine[] {
77
+ const gaps = terminalHeight >= 32;
78
+ return HELP_GROUPS.flatMap((group, groupIndex) => [
79
+ { kind: "heading" as const, text: group.title },
80
+ ...group.controls.map(([key, what]) => ({ kind: "control" as const, key, what })),
81
+ ...(gaps && groupIndex < HELP_GROUPS.length - 1 ? [{ kind: "blank" as const }] : []),
82
+ ]);
83
+ }
84
+
85
+ type HelpLayout = {
86
+ twoColumns: boolean;
87
+ popupHeight: number;
88
+ summaryLines: readonly string[];
89
+ summaryHeight: number;
90
+ topGap: number;
91
+ contentHeight: number;
92
+ bottomGap: number;
93
+ footerHeight: number;
94
+ };
95
+
96
+ const POPUP_FRAME_ROWS = 4;
97
+
98
+ export function helpLayout(terminalWidth: number, terminalHeight: number): HelpLayout {
99
+ const twoColumns = terminalWidth >= 82;
100
+ const categorySpacing = terminalHeight >= 32;
101
+ const desiredHeight = twoColumns ? (categorySpacing ? 30 : 28) : 39;
102
+ const popupHeight = Math.max(1, Math.min(terminalHeight, desiredHeight));
103
+ const innerHeight = Math.max(0, popupHeight - POPUP_FRAME_ROWS);
104
+ const allSummaryLines = twoColumns
105
+ ? HELP_SUMMARY_WIDE
106
+ : ["PUM workflow", ...HELP_SUMMARY];
107
+ const footerHeight = innerHeight >= 1 ? 1 : 0;
108
+ const minimumContentHeight = innerHeight >= 2 ? 1 : 0;
109
+ const summaryHeight = Math.min(
110
+ allSummaryLines.length,
111
+ Math.max(0, innerHeight - footerHeight - minimumContentHeight),
112
+ );
113
+ const fullSummaryFits = summaryHeight === allSummaryLines.length;
114
+ const gapCapacity = fullSummaryFits
115
+ ? Math.max(0, innerHeight - summaryHeight - footerHeight - minimumContentHeight)
116
+ : 0;
117
+ // Keep the footer separate first when only one compact-layout gap fits.
118
+ const bottomGap = gapCapacity >= 1 ? 1 : 0;
119
+ const topGap = gapCapacity >= 2 ? 1 : 0;
120
+ const contentHeight = Math.max(
121
+ 0,
122
+ innerHeight - summaryHeight - topGap - bottomGap - footerHeight,
123
+ );
124
+
125
+ return {
126
+ twoColumns,
127
+ popupHeight,
128
+ summaryLines: allSummaryLines.slice(0, summaryHeight),
129
+ summaryHeight,
130
+ topGap,
131
+ contentHeight,
132
+ bottomGap,
133
+ footerHeight,
134
+ };
135
+ }
136
+
137
+ export function helpPageSize(terminalHeight: number): number {
138
+ return Math.max(1, helpLayout(0, terminalHeight).contentHeight);
139
+ }
140
+
141
+ export function maxHelpScrollOffset(terminalHeight: number): number {
142
+ const lines = helpLines(terminalHeight);
143
+ const raw = Math.max(0, lines.length - helpPageSize(terminalHeight));
144
+ const lastHeading = lines.findLastIndex((line) => line.kind === "heading");
145
+ return lastHeading < 0 ? raw : Math.min(raw, lastHeading);
146
+ }
147
+
148
+ function HelpLineRow({ line, theme }: { line: HelpLine; theme: Theme }) {
149
+ if (line.kind === "blank") return <box style={{ height: 1, flexShrink: 0 }} />;
150
+ if (line.kind === "heading") {
151
+ return <text content={line.text} fg={theme.dim} bg={theme.popupBg} />;
152
+ }
153
+ return (
154
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
155
+ <box style={{ width: KEY_WIDTH, flexShrink: 0 }}>
156
+ <text content={line.key} fg={theme.accent} bg={theme.popupBg} wrapMode="none" />
157
+ </box>
158
+ <text
159
+ content={line.what}
160
+ fg={theme.fg}
161
+ bg={theme.popupBg}
162
+ wrapMode="none"
163
+ style={{ flexGrow: 1, minWidth: 0 }}
164
+ />
165
+ </box>
166
+ );
167
+ }
168
+
169
+ function HelpColumn({ groups, theme, spaced }: { groups: HelpGroup[]; theme: Theme; spaced: boolean }) {
170
+ return (
171
+ <box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0 }}>
172
+ {groups.map((group, groupIndex) => (
173
+ <box key={group.title} style={{ flexDirection: "column", flexShrink: 0, marginBottom: spaced && groupIndex < groups.length - 1 ? 1 : 0 }}>
174
+ <text content={group.title} fg={theme.dim} bg={theme.popupBg} />
175
+ {group.controls.map(([key, what], index) => (
176
+ <box key={`${key}:${index}`} style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
177
+ <box style={{ width: KEY_WIDTH, flexShrink: 0 }}>
178
+ <text content={key} fg={theme.accent} bg={theme.popupBg} wrapMode="none" />
179
+ </box>
180
+ <text
181
+ content={what}
182
+ fg={theme.fg}
183
+ bg={theme.popupBg}
184
+ wrapMode="none"
185
+ style={{ flexGrow: 1, minWidth: 0 }}
186
+ />
187
+ </box>
188
+ ))}
189
+ </box>
190
+ ))}
191
+ </box>
192
+ );
193
+ }
194
+
195
+ export function HelpPopup({
196
+ theme,
197
+ terminalWidth,
198
+ terminalHeight,
199
+ scrollOffset,
200
+ }: {
201
+ theme: Theme;
202
+ terminalWidth: number;
203
+ terminalHeight: number;
204
+ scrollOffset: number;
205
+ }) {
206
+ const layout = helpLayout(terminalWidth, terminalHeight);
207
+ const margin = terminalWidth < 3
208
+ ? 0
209
+ : terminalWidth < 64 ? 1 : Math.max(2, Math.floor(terminalWidth * 0.08));
210
+ const popupWidth = Math.max(1, terminalWidth - margin * 2);
211
+ const split = 3;
212
+ const lines = helpLines(terminalHeight);
213
+ const spaced = terminalHeight >= 32;
214
+
215
+ return (
216
+ <box
217
+ title=" Controls "
218
+ style={{
219
+ position: "absolute",
220
+ top: Math.max(0, Math.floor((terminalHeight - layout.popupHeight) / 2)),
221
+ left: margin,
222
+ width: popupWidth,
223
+ height: layout.popupHeight,
224
+ zIndex: 100,
225
+ border: true,
226
+ borderColor: theme.border,
227
+ backgroundColor: theme.popupBg,
228
+ flexDirection: "column",
229
+ padding: 1,
230
+ }}
231
+ >
232
+ <box style={{ height: layout.summaryHeight, flexShrink: 0, flexDirection: "column" }}>
233
+ {layout.summaryLines.map((line, index) => (
234
+ <text
235
+ key={line}
236
+ content={line}
237
+ fg={layout.twoColumns || index === 0 ? theme.accent : theme.dim}
238
+ bg={theme.popupBg}
239
+ wrapMode="none"
240
+ />
241
+ ))}
242
+ </box>
243
+ {layout.topGap ? <box style={{ height: 1, flexShrink: 0 }} /> : null}
244
+ <box
245
+ style={{
246
+ flexDirection: "row",
247
+ height: layout.contentHeight,
248
+ flexShrink: 0,
249
+ overflow: "hidden",
250
+ }}
251
+ >
252
+ {layout.twoColumns ? (
253
+ <>
254
+ <HelpColumn groups={HELP_GROUPS.slice(0, split)} theme={theme} spaced={spaced} />
255
+ <box style={{ width: 2, flexShrink: 0 }} />
256
+ <HelpColumn groups={HELP_GROUPS.slice(split)} theme={theme} spaced={spaced} />
257
+ </>
258
+ ) : (
259
+ <box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0 }}>
260
+ {lines.slice(scrollOffset, scrollOffset + helpPageSize(terminalHeight)).map((line, index) => (
261
+ <HelpLineRow key={`${scrollOffset + index}:${line.kind}`} line={line} theme={theme} />
262
+ ))}
263
+ </box>
264
+ )}
265
+ </box>
266
+ {layout.bottomGap ? <box style={{ height: 1, flexShrink: 0 }} /> : null}
267
+ {layout.footerHeight ? (
268
+ <box style={{ height: 1, flexShrink: 0 }}>
269
+ <text
270
+ content={layout.twoColumns ? "esc or ? close" : "↑↓ scroll esc or ? close"}
271
+ fg={theme.dim}
272
+ bg={theme.popupBg}
273
+ wrapMode="none"
274
+ />
275
+ </box>
276
+ ) : null}
277
+ </box>
278
+ );
279
+ }
package/src/history.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { AGENT_DIR } from "./config";
4
+ import { projectStorageKey } from "./platform";
5
+
6
+ /** One history list per working directory, so projects do not bleed together. */
7
+ type HistoryFile = Record<string, string[]>;
8
+
9
+ const HISTORY_PATH = join(AGENT_DIR, "history.json");
10
+ const MAX_ENTRIES = 500;
11
+
12
+ function readFile(): HistoryFile {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(HISTORY_PATH, "utf8"));
15
+ return parsed && typeof parsed === "object" ? parsed : {};
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function loadHistory(cwd: string): string[] {
22
+ const file = readFile();
23
+ const entries = file[projectStorageKey(cwd)] ?? file[cwd];
24
+ return Array.isArray(entries) ? entries.filter((e) => typeof e === "string") : [];
25
+ }
26
+
27
+ /** Appends unless it repeats the previous entry, and returns the new list. */
28
+ export function appendHistory(cwd: string, prompt: string): string[] {
29
+ const file = readFile();
30
+ const key = projectStorageKey(cwd);
31
+ const list = Array.isArray(file[key]) ? file[key]! : loadHistory(cwd);
32
+ if (list[list.length - 1] !== prompt) list.push(prompt);
33
+ const trimmed = list.slice(-MAX_ENTRIES);
34
+ file[key] = trimmed;
35
+ if (key !== cwd) delete file[cwd];
36
+ try {
37
+ writeFileSync(HISTORY_PATH, JSON.stringify(file, null, 2));
38
+ } catch {
39
+ // history is a convenience; never break a turn over it
40
+ }
41
+ return trimmed;
42
+ }
43
+
44
+ /** Remove every exact occurrence of a prompt from this directory's history. */
45
+ export function removeHistory(cwd: string, prompt: string): string[] {
46
+ const file = readFile();
47
+ const key = projectStorageKey(cwd);
48
+ const next = loadHistory(cwd).filter((entry) => entry !== prompt);
49
+ file[key] = next;
50
+ if (key !== cwd) delete file[cwd];
51
+ try {
52
+ writeFileSync(HISTORY_PATH, JSON.stringify(file, null, 2));
53
+ } catch {
54
+ // history is a convenience; never break input handling over it
55
+ }
56
+ return next;
57
+ }
@@ -0,0 +1,204 @@
1
+ import type { ImageContent } from "@earendil-works/pi-ai";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
8
+
9
+ const IMAGE_TYPES = [
10
+ ["image/png", "png"],
11
+ ["image/jpeg", "jpg"],
12
+ ["image/webp", "webp"],
13
+ ["image/gif", "gif"],
14
+ ["image/bmp", "bmp"],
15
+ ["image/tiff", "tiff"],
16
+ ] as const;
17
+
18
+ export type PendingImage = {
19
+ id: number;
20
+ marker: string;
21
+ path: string;
22
+ mimeType: string;
23
+ start: number;
24
+ end: number;
25
+ };
26
+
27
+ let imageDir: string | null = null;
28
+ let fileSequence = 0;
29
+
30
+ function ensureImageDir(): string {
31
+ imageDir ??= mkdtempSync(join(tmpdir(), "pum-images-"));
32
+ return imageDir;
33
+ }
34
+
35
+ type CommandRunner = (command: string, args: string[]) => Promise<Buffer>;
36
+ type NativeClipboard = {
37
+ hasImage(): boolean;
38
+ getImageBinary(): Promise<Array<number>>;
39
+ };
40
+
41
+ export type ClipboardBackend = "windows" | "wayland" | "x11";
42
+
43
+ export function clipboardBackend(
44
+ platform: NodeJS.Platform = process.platform,
45
+ env: NodeJS.ProcessEnv = process.env,
46
+ ): ClipboardBackend | null {
47
+ if (platform === "win32") return "windows";
48
+ if (platform === "linux" && env.WAYLAND_DISPLAY) return "wayland";
49
+ if (platform === "linux" && env.DISPLAY) return "x11";
50
+ return null;
51
+ }
52
+
53
+ function run(command: string, args: string[]): Promise<Buffer> {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(command, args, {
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ windowsHide: true,
58
+ });
59
+ const stdout: Buffer[] = [];
60
+ const stderr: Buffer[] = [];
61
+ let size = 0;
62
+
63
+ child.stdout.on("data", (chunk: Buffer) => {
64
+ size += chunk.length;
65
+ if (size > MAX_IMAGE_BYTES) {
66
+ child.kill();
67
+ reject(new Error("Clipboard image is larger than 25 MB"));
68
+ return;
69
+ }
70
+ stdout.push(chunk);
71
+ });
72
+ child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
73
+ child.on("error", reject);
74
+ child.on("close", (code) => {
75
+ if (code === 0) resolve(Buffer.concat(stdout));
76
+ else reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `${command} failed`));
77
+ });
78
+ });
79
+ }
80
+
81
+ const WINDOWS_CLIPBOARD_SCRIPT = [
82
+ "Add-Type -AssemblyName System.Windows.Forms",
83
+ "Add-Type -AssemblyName System.Drawing",
84
+ "$image = [System.Windows.Forms.Clipboard]::GetImage()",
85
+ "if ($null -eq $image) { [Console]::Error.Write('Clipboard does not contain an image'); exit 2 }",
86
+ "$stream = New-Object System.IO.MemoryStream",
87
+ "try {",
88
+ " $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)",
89
+ " $bytes = $stream.ToArray()",
90
+ " [Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)",
91
+ "} finally {",
92
+ " $stream.Dispose()",
93
+ " $image.Dispose()",
94
+ "}",
95
+ ].join("\n");
96
+
97
+ async function loadNativeClipboard(): Promise<NativeClipboard | null> {
98
+ try {
99
+ return await import("@mariozechner/clipboard");
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function readWindowsClipboard(
106
+ runner: CommandRunner,
107
+ nativeClipboard: NativeClipboard | null | undefined,
108
+ ): Promise<{ data: Buffer; mimeType: string; ext: string }> {
109
+ const clipboard = nativeClipboard === undefined ? await loadNativeClipboard() : nativeClipboard;
110
+ if (clipboard?.hasImage()) {
111
+ const bytes = await clipboard.getImageBinary();
112
+ if (bytes.length > 0) {
113
+ return { data: Buffer.from(bytes), mimeType: "image/png", ext: "png" };
114
+ }
115
+ }
116
+
117
+ const data = await runner("powershell.exe", [
118
+ "-NoLogo",
119
+ "-NoProfile",
120
+ "-NonInteractive",
121
+ "-STA",
122
+ "-Command",
123
+ WINDOWS_CLIPBOARD_SCRIPT,
124
+ ]);
125
+ if (data.length === 0) throw new Error("Clipboard image is empty");
126
+ return { data, mimeType: "image/png", ext: "png" };
127
+ }
128
+
129
+ async function readWaylandClipboard(
130
+ runner: CommandRunner,
131
+ ): Promise<{ data: Buffer; mimeType: string; ext: string }> {
132
+ const offered = (await runner("wl-paste", ["--list-types"]))
133
+ .toString("utf8")
134
+ .split(/\r?\n/)
135
+ .map((type) => type.trim().toLowerCase());
136
+ const imageType = IMAGE_TYPES.find(([mimeType]) => offered.includes(mimeType));
137
+ if (!imageType) throw new Error("Clipboard does not contain an image");
138
+ const [mimeType, ext] = imageType;
139
+ const data = await runner("wl-paste", ["--no-newline", "--type", mimeType]);
140
+ if (data.length === 0) throw new Error("Clipboard image is empty");
141
+ return { data, mimeType, ext };
142
+ }
143
+
144
+ async function readX11Clipboard(
145
+ runner: CommandRunner,
146
+ ): Promise<{ data: Buffer; mimeType: string; ext: string }> {
147
+ const offered = (await runner("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]))
148
+ .toString("utf8")
149
+ .split(/\r?\n/)
150
+ .map((type) => type.trim().toLowerCase());
151
+ const imageType = IMAGE_TYPES.find(([mimeType]) => offered.includes(mimeType));
152
+ if (!imageType) throw new Error("Clipboard does not contain an image");
153
+ const [mimeType, ext] = imageType;
154
+ const data = await runner("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
155
+ if (data.length === 0) throw new Error("Clipboard image is empty");
156
+ return { data, mimeType, ext };
157
+ }
158
+
159
+ export async function captureClipboardImage(options: {
160
+ platform?: NodeJS.Platform;
161
+ env?: NodeJS.ProcessEnv;
162
+ runner?: CommandRunner;
163
+ nativeClipboard?: NativeClipboard | null;
164
+ } = {}): Promise<{ path: string; mimeType: string }> {
165
+ const runner = options.runner ?? run;
166
+ const backend = clipboardBackend(options.platform, options.env);
167
+ let image: { data: Buffer; mimeType: string; ext: string };
168
+ if (backend === "windows") {
169
+ image = await readWindowsClipboard(runner, options.nativeClipboard);
170
+ }
171
+ else if (backend === "wayland") image = await readWaylandClipboard(runner);
172
+ else if (backend === "x11") image = await readX11Clipboard(runner);
173
+ else throw new Error("No supported graphical clipboard is available");
174
+
175
+ const path = join(ensureImageDir(), `image-${++fileSequence}.${image.ext}`);
176
+ writeFileSync(path, image.data);
177
+ return { path, mimeType: image.mimeType };
178
+ }
179
+
180
+ export function imageContent(image: PendingImage): ImageContent {
181
+ return {
182
+ type: "image",
183
+ data: readFileSync(image.path).toString("base64"),
184
+ mimeType: image.mimeType,
185
+ };
186
+ }
187
+
188
+ export function removePendingImage(image: PendingImage): void {
189
+ try {
190
+ unlinkSync(image.path);
191
+ } catch {
192
+ // The file can already be gone during shutdown or failed-send cleanup.
193
+ }
194
+ }
195
+
196
+ export function cleanupPendingImages(): void {
197
+ if (!imageDir) return;
198
+ try {
199
+ rmSync(imageDir, { recursive: true, force: true });
200
+ } catch {
201
+ // Temporary image cleanup must not break shutdown.
202
+ }
203
+ imageDir = null;
204
+ }