chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -26,23 +26,45 @@
* SOFTWARE.
*/
function ansiRegex({ onlyFirst = false }: { onlyFirst?: boolean } = {}): RegExp {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`;
// OSC sequences: ESC ] ... ST
const OSC_START = "\\u001B\\]";
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :), then final byte
const CSI_START = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?";
const CSI_FINAL = "[\\dA-PR-TZcf-nq-uy=><~]";
const pattern = `${osc}|${csi}`;
// Complete sequences. OSC is non-greedy until the first ST.
const ansiRegex = new RegExp(`(?:${OSC_START}[\\s\\S]*?${ST})|${CSI_START}${CSI_FINAL}`, "g");
return new RegExp(pattern, onlyFirst ? undefined : "g");
// Unfinished sequence at the end of the text: OSC without its ST (a trailing ESC may start ESC\),
// or CSI without its final byte.
const unfinishedAnsiAtEndRegex = new RegExp(
`(?:${OSC_START}(?:[^\\u0007\\u009C\\u001B]|\\u001B(?!\\\\))*|${CSI_START})$`,
);
// Longest unfinished sequence held back while streaming. Longer ones are processed as-is.
const MAX_PENDING_ANSI_LENGTH = 256;
/**
* Split streamed text into a part that is safe to pass to stripAnsi now and a trailing
* unfinished escape sequence that should be prepended to the next chunk.
*/
export function splitIncompleteAnsiSuffix(value: string): { complete: string; pending: string } {
if (!value.includes("\u001B") && !value.includes("\u009B")) {
return { complete: value, pending: "" };
}
const windowStart = Math.max(0, value.length - MAX_PENDING_ANSI_LENGTH);
const match = unfinishedAnsiAtEndRegex.exec(value.slice(windowStart));
if (!match) {
return { complete: value, pending: "" };
}
const splitAt = windowStart + match.index;
return { complete: value.slice(0, splitAt), pending: value.slice(splitAt) };
}
const regex = ansiRegex();
export function stripAnsi(value: string): string {
if (typeof value !== "string") {
throw new TypeError(`Expected a \`string\`, got \`${typeof value}\``);
@@ -56,5 +78,5 @@ export function stripAnsi(value: string): string {
// Even though the regex is global, we don't need to reset the `.lastIndex`
// because unlike `.exec()` and `.test()`, `.replace()` does it automatically
// and doing it manually has a performance penalty.
return value.replace(regex, "");
return value.replace(ansiRegex, "");
}

View File

@@ -0,0 +1,44 @@
import { spawn } from "node:child_process";
/** Undefined means the command failed; an empty buffer is a successful result. */
export function runClipboardCommand(
command: string,
args: readonly string[],
options?: { input?: string; timeoutMs?: number; maxBufferBytes?: number },
): Promise<Buffer | undefined> {
return new Promise((resolve) => {
// Clipboard writers can daemonize. Do not give them output pipes to retain.
const child = spawn(command, args, {
stdio: ["pipe", options?.input === undefined ? "pipe" : "ignore", "ignore"],
windowsHide: true,
});
const chunks: Buffer[] = [];
let length = 0;
let settled = false;
const finish = (result: Buffer | undefined): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const abort = (): void => {
child.kill("SIGKILL");
child.stdout?.destroy();
child.stdin?.destroy();
finish(undefined);
};
const timer = setTimeout(abort, options?.timeoutMs ?? 3000);
child.on("error", () => finish(undefined));
child.on("close", (code) => {
if (!settled) finish(code === 0 ? Buffer.concat(chunks, length) : undefined);
});
child.stdout?.on("data", (chunk: Buffer) => {
if (settled) return;
length += chunk.length;
if (length > (options?.maxBufferBytes ?? 50 * 1024 * 1024)) abort();
else chunks.push(chunk);
});
child.stdin?.on("error", () => {}); // A writer may exit before consuming all input.
child.stdin?.end(options?.input);
});
}

View File

@@ -1,11 +1,13 @@
import { spawnSync } from "child_process";
import { getNativeClipboard } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { clipboard } from "./clipboard-native.ts";
import { runClipboardCommand } from "./clipboard-command.ts";
import { detectSupportedImageMimeType } from "./mime.ts";
import { loadPhoton } from "./photon.ts";
import { isWSL } from "./wsl.ts";
export type ClipboardImage = {
bytes: Uint8Array;
@@ -15,9 +17,7 @@ export type ClipboardImage = {
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
const DEFAULT_LIST_TIMEOUT_MS = 1000;
const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
@@ -86,42 +86,13 @@ async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
}
}
function runCommand(
command: string,
args: string[],
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
): { stdout: Buffer; ok: boolean } {
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
// Undefined means the backend failed; null means it has no image. An empty
// Wayland clipboard must not fall through to stale X11 clipboard contents.
async function readClipboardImageViaWlPaste(): Promise<ClipboardImage | null | undefined> {
const list = await runClipboardCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (list === undefined) return undefined;
const result = spawnSync(command, args, {
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
env: options?.env,
});
if (result.error) {
return { ok: false, stdout: Buffer.alloc(0) };
}
if (result.status !== 0) {
return { ok: false, stdout: Buffer.alloc(0) };
}
const stdout = Buffer.isBuffer(result.stdout)
? result.stdout
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
return { ok: true, stdout };
}
function readClipboardImageViaWlPaste(): ClipboardImage | null {
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!list.ok) {
return null;
}
const types = list.stdout
const types = list
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
@@ -132,25 +103,11 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null {
return null;
}
const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (!data.ok || data.stdout.length === 0) {
return null;
}
const data = await runClipboardCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (data === undefined) return undefined;
if (data.length === 0) return null;
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
return { bytes: data, mimeType: baseMimeType(selectedType) };
}
/**
@@ -158,16 +115,18 @@ function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
* directly, so we use it as a fallback.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
async function readClipboardImageViaPowerShell(): Promise<ClipboardImage | null> {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!winPathResult.ok) {
const winPathResult = await runClipboardCommand("wslpath", ["-w", tmpFile], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (winPathResult === undefined) {
return null;
}
const winPath = winPathResult.stdout.toString("utf-8").trim();
const winPath = winPathResult.toString("utf-8").trim();
if (!winPath) {
return null;
}
@@ -181,14 +140,14 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join("; ");
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
});
if (!result.ok) {
if (result === undefined) {
return null;
}
const output = result.stdout.toString("utf-8").trim();
const output = result.toString("utf-8").trim();
if (output !== "ok") {
return null;
}
@@ -210,45 +169,32 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
}
}
function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
async function readClipboardImageViaXclip(): Promise<ClipboardImage | null | undefined> {
const targets = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
let candidateTypes: string[] = [];
if (targets.ok) {
candidateTypes = targets.stdout
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
}
if (targets === undefined) return undefined;
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES];
const candidateTypes = targets
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
const preferred = selectPreferredImageMimeType(candidateTypes);
if (!preferred) return null;
for (const mimeType of tryTypes) {
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
if (data.ok && data.stdout.length > 0) {
return { bytes: data.stdout, mimeType: baseMimeType(mimeType) };
}
}
return null;
const data = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", preferred, "-o"]);
if (data === undefined) return undefined;
if (data.length === 0) return null;
return { bytes: data, mimeType: baseMimeType(preferred) };
}
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
if (!clipboard || !clipboard.hasImage()) {
return null;
}
const imageData = await clipboard.getImageBinary();
if (!imageData || imageData.length === 0) {
return null;
}
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
return { bytes, mimeType: "image/png" };
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null | undefined> {
const bytes = await getNativeClipboard()?.getImage();
if (bytes === undefined) return undefined;
if (!bytes?.length) return null;
return { bytes, mimeType: detectSupportedImageMimeType(bytes) ?? "application/octet-stream" };
}
export async function readClipboardImage(options?: {
@@ -262,23 +208,17 @@ export async function readClipboardImage(options?: {
return null;
}
let image: ClipboardImage | null = null;
let image: ClipboardImage | null | undefined;
if (platform === "linux") {
const wsl = isWSL(env);
const wayland = isWaylandSession(env);
if (wayland || wsl) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (!image && wsl) {
image = readClipboardImageViaPowerShell();
}
if (!image && !wayland) {
image = (await readClipboardImageViaNativeClipboard()) ?? readClipboardImageViaXclip();
if (isWaylandSession(env) || wsl) {
image = await readClipboardImageViaWlPaste();
}
if (image === undefined) image = await readClipboardImageViaXclip();
// Preserve Linux's empty/unavailable distinction if Windows has no image.
if (!image && wsl) image = (await readClipboardImageViaPowerShell()) ?? image;
if (image === undefined) image = await readClipboardImageViaNativeClipboard();
} else {
image = await readClipboardImageViaNativeClipboard();
}
@@ -287,7 +227,7 @@ export async function readClipboardImage(options?: {
return null;
}
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
// Convert unsupported formats (e.g., Windows DIB data wrapped as BMP) to PNG
if (!isSupportedImageMimeType(image.mimeType)) {
const pngBytes = await convertToPng(image.bytes);
if (!pngBytes) {

View File

@@ -1,33 +0,0 @@
import { createRequire } from "module";
import { dirname, join } from "path";
import { pathToFileURL } from "url";
export type ClipboardModule = {
getText: () => Promise<string>;
setText: (text: string) => Promise<void>;
hasImage: () => boolean;
getImageBinary: () => Promise<Array<number>>;
};
type ClipboardRequire = (id: string) => unknown;
const moduleRequire = createRequire(import.meta.url);
const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href);
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
export function loadClipboardNative(
requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire],
): ClipboardModule | null {
for (const requireClipboard of requires) {
try {
return requireClipboard("@mariozechner/clipboard") as ClipboardModule;
} catch {
// Try the next resolution root.
}
}
return null;
}
const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null;
export { clipboard };

View File

@@ -1,26 +1,20 @@
import { type ExecFileSyncOptionsWithStringEncoding, execFileSync, execSync, spawn } from "child_process";
import { platform } from "os";
import { isWaylandSession } from "./clipboard-image.ts";
import { clipboard } from "./clipboard-native.ts";
type NativeClipboardExecOptions = {
input: string;
timeout: number;
stdio: ["pipe", "ignore", "ignore"];
};
function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
try {
execSync("xclip -selection clipboard", options);
} catch {
execSync("xsel --clipboard --input", options);
}
}
import { randomUUID } from "node:crypto";
import { unlinkSync, writeFileSync } from "node:fs";
import { platform, tmpdir } from "node:os";
import { join } from "node:path";
import { getNativeClipboard } from "@earendil-works/pi-tui";
import { runClipboardCommand } from "./clipboard-command.ts";
import { isWSL } from "./wsl.ts";
const MAX_OSC52_ENCODED_LENGTH = 100_000;
function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
/**
* Remote sessions also emit OSC 52 so the client terminal receives the copy. PI_CLIPBOARD_OSC52=1
* covers remote clients that leave no SSH markers, such as multiplexer attaches (#10114, #10835)
* or IDE daemons (#10826).
*/
function isRemoteSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION || env.PI_CLIPBOARD_OSC52 === "1");
}
function emitOsc52(text: string): boolean {
@@ -32,144 +26,125 @@ function emitOsc52(text: string): boolean {
return true;
}
type ClipboardReadResult = { ok: true; text: string | null } | { ok: false };
const READ_CLIPBOARD_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
timeout: 5000,
};
function readWaylandClipboardText(): ClipboardReadResult {
/**
* WSL without WSLg has no Linux display, so the Windows clipboard is written through
* interop. PowerShell reads the text from a file because `clip.exe` and PowerShell stdin
* decode piped bytes with the console code page, which mangles non-ASCII UTF-8.
*/
async function copyViaWindowsClipboard(text: string): Promise<boolean> {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.txt`);
try {
const text = execFileSync("wl-paste", ["--no-newline", "--type", "text"], READ_CLIPBOARD_OPTIONS);
return { ok: true, text: text || null };
writeFileSync(tmpFile, text, { encoding: "utf8", mode: 0o600 });
const winPath = (await runClipboardCommand("wslpath", ["-w", tmpFile], { timeoutMs: 1000 }))
?.toString("utf8")
.trim();
if (!winPath) return false;
const script = `Set-Clipboard -Value ([System.IO.File]::ReadAllText('${winPath.replaceAll("'", "''")}', [System.Text.Encoding]::UTF8))`;
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", script], {
timeoutMs: 5000,
});
return result !== undefined;
} catch {
return { ok: false };
return false;
} finally {
try {
unlinkSync(tmpFile);
} catch {
// The file may not have been created.
}
}
}
/** Read plain text from the system clipboard. */
export async function readClipboardText(): Promise<string | null> {
if (platform() === "linux" && isWaylandSession() && process.env.WAYLAND_DISPLAY) {
const result = readWaylandClipboardText();
if (result.ok) {
return result.text;
const commands: [string, string[]][] = [];
// Termux reports platform "android", not "linux" (#10391).
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
if (platform() === "linux") {
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
if (process.env.DISPLAY) {
commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
}
}
if (!clipboard) {
return null;
for (const [command, args] of commands) {
const bytes = await runClipboardCommand(command, args, { timeoutMs: 5000 });
if (bytes !== undefined) return bytes.toString("utf8") || null;
}
try {
const text = await clipboard.getText();
return text || null;
return (await getNativeClipboard()?.getText()) || null;
} catch {
return null;
}
}
/** Read file paths, such as Finder file copies, from the native clipboard. */
export async function readClipboardFilePaths(): Promise<string[] | null> {
const paths = await getNativeClipboard()?.getFilePaths?.();
return paths?.length ? paths : null;
}
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
const p = platform();
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
//
// On Linux, skip the native addon. The underlying `clipboard-rs` crate is
// X11-only and does not retain selection ownership after `set_text`
// resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even
// some X11 sessions the call resolves successfully without populating the
// clipboard. The platform tools below (wl-copy, xclip, xsel) properly
// daemonize and keep ownership.
try {
if (clipboard && p !== "linux") {
await clipboard.setText(text);
copied = true;
}
} catch {
// Fall through to platform-specific clipboard tools.
}
const remote = isRemoteSession();
if (copied && !remote) {
return;
}
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {
const env = process.env;
let copied = false;
// Direct writes precede OSC 52 so the terminal cannot race the native writer.
// Linux tools retain clipboard selection ownership after this call returns.
if (p !== "linux") {
try {
if (p === "darwin") {
execSync("pbcopy", options);
const clipboard = getNativeClipboard();
if (clipboard?.setText) {
await clipboard.setText(text);
copied = true;
} else if (p === "win32") {
execSync("clip", options);
copied = true;
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
copied = true;
} catch {
// Fall back to Wayland or X11 tools.
}
}
if (!copied) {
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
// Await the exit code and only claim success on a clean exit, so a
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
const wlCopyExit = await new Promise<number>((resolve) => {
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.on("error", () => resolve(1));
proc.on("close", (code) => resolve(code ?? 1));
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
});
if (wlCopyExit === 0) {
copied = true;
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
}
} catch {
// Fall through to OSC 52 fallback.
// Try platform commands next.
}
}
if (remote || !copied) {
const osc52Copied = emitOsc52(text);
copied = copied || osc52Copied;
}
if (!copied) {
throw new Error("Failed to copy to clipboard");
const commands: [string, string[]][] = [];
if (p === "darwin") commands.push(["pbcopy", []]);
else if (p === "win32") commands.push(["clip", []]);
else {
if (env.TERMUX_VERSION) commands.push(["termux-clipboard-set", []]);
if (env.WAYLAND_DISPLAY) commands.push(["wl-copy", []]);
if (env.DISPLAY) {
commands.push(["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]);
}
}
for (const [command, args] of commands) {
if ((await runClipboardCommand(command, args, { input: text, timeoutMs: 5000 })) !== undefined) {
copied = true;
break;
}
}
}
let osc52Emitted = false;
if (!copied && p === "linux" && isWSL(env)) {
// Windows Terminal supports OSC 52; prefer it over the slower PowerShell round trip.
if (env.WT_SESSION) osc52Emitted = emitOsc52(text);
copied = osc52Emitted || (await copyViaWindowsClipboard(text));
}
// OSC 52 cannot be verified, so a desktop session with a display reports the failure
// instead (#9618). Without a display the terminal is the only clipboard route (containers,
// WSL without WSLg), and remote sessions always emit it to reach the client clipboard.
const headless = p === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && !env.TERMUX_VERSION;
let oversized = false;
if (!osc52Emitted && (isRemoteSession(env) || (!copied && headless))) {
if (emitOsc52(text)) copied = true;
else oversized = true;
}
if (copied) return;
if (oversized) throw new Error("Clipboard unavailable: text exceeds the OSC 52 size limit");
if (env.TERMUX_VERSION) {
throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package");
}
if (p === "linux") {
if (env.WAYLAND_DISPLAY) {
throw new Error("Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access");
}
if (env.DISPLAY) {
throw new Error("Clipboard unavailable: install `xclip` or `xsel`, or check X11 access");
}
}
throw new Error("Clipboard unavailable");
}

View File

@@ -1,13 +1,10 @@
import { getCapabilities, type ImageTranscoder, setImageTranscoder } from "@earendil-works/pi-tui";
import { applyExifOrientation } from "./exif-orientation.ts";
import { loadPhoton } from "./photon.ts";
export async function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
const photon = await loadPhoton();
if (!photon) {
// Photon not available, can't convert
return null;
}
type Photon = NonNullable<Awaited<ReturnType<typeof loadPhoton>>>;
function encodePng(photon: Photon, bytes: Uint8Array): Uint8Array | null {
try {
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
const image = applyExifOrientation(photon, rawImage, bytes);
@@ -23,6 +20,15 @@ export async function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Ar
}
}
export async function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
const photon = await loadPhoton();
if (!photon) {
// Photon not available, can't convert
return null;
}
return encodePng(photon, bytes);
}
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
@@ -47,3 +53,37 @@ export async function convertToPng(
mimeType: "image/png",
};
}
/**
* Load photon and return a synchronous PNG transcoder for pi-tui's Kitty image rendering.
* Returns undefined if photon cannot be loaded.
*/
export async function loadPngTranscoder(): Promise<ImageTranscoder | undefined> {
const photon = await loadPhoton();
if (!photon) return undefined;
return (base64Data) => {
const pngBytes = encodePng(photon, new Uint8Array(Buffer.from(base64Data, "base64")));
return pngBytes ? Buffer.from(pngBytes).toString("base64") : null;
};
}
let pngTranscoderLoad: Promise<boolean> | undefined;
let pngTranscoderRegistered = false;
/**
* On Kitty-protocol terminals, register photon as pi-tui's image transcoder so non-PNG images render.
* Loads photon once. `onRegistered` runs after registration so callers can re-render images that
* showed text fallbacks; it is not called if the transcoder was already registered or cannot load.
*/
export function ensurePngTranscoder(onRegistered: () => void): void {
if (pngTranscoderRegistered || getCapabilities().images !== "kitty") return;
pngTranscoderLoad ??= loadPngTranscoder().then((transcoder) => {
if (!transcoder) return false;
setImageTranscoder(transcoder);
pngTranscoderRegistered = true;
return true;
});
void pngTranscoderLoad.then((registered) => {
if (registered) onRegistered();
});
}

View File

@@ -18,6 +18,16 @@ export interface ResizedImage {
wasResized: boolean;
}
/**
* Tag on image resize worker replies. Node can post its own messages on the worker channel
* (for example `{ "watch:require": [...] }` under `node --watch`), so replies must be identifiable.
*/
export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE = "pi:image-resize-response";
export type ResizeImageWorkerResponse =
| { type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE; result: ResizedImage | null }
| { type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE; error: string };
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;

View File

@@ -1,5 +1,10 @@
import { parentPort } from "node:worker_threads";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
import {
IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
type ImageResizeOptions,
type ResizeImageWorkerResponse,
resizeImageInProcess,
} from "./image-resize-core.ts";
interface ResizeImageWorkerRequest {
inputBytes: Uint8Array;
@@ -7,11 +12,6 @@ interface ResizeImageWorkerRequest {
options?: ImageResizeOptions;
}
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
@@ -30,10 +30,11 @@ port.once("message", (message: unknown) => {
throw new Error("Invalid image resize worker request");
}
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
const response: ResizeImageWorkerResponse = { result };
const response: ResizeImageWorkerResponse = { type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, result };
port.postMessage(response);
} catch (error) {
const response: ResizeImageWorkerResponse = {
type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
error: error instanceof Error ? error.message : String(error),
};
port.postMessage(response);

View File

@@ -1,13 +1,14 @@
import { Worker } from "node:worker_threads";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
import {
IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
type ImageResizeOptions,
type ResizedImage,
type ResizeImageWorkerResponse,
resizeImageInProcess,
} from "./image-resize-core.ts";
export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
// Transfer detaches the buffer, so transfer a worker-owned copy and leave the
// caller's bytes intact.
@@ -15,7 +16,9 @@ function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
}
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
return value !== null && typeof value === "object";
return (
value !== null && typeof value === "object" && "type" in value && value.type === IMAGE_RESIZE_WORKER_RESPONSE_TYPE
);
}
function createResizeWorker(workerSpecifier: string | URL): Worker {
@@ -44,16 +47,12 @@ async function resizeImageInWorker(
reject(error);
};
worker.once("message", (message: unknown) => {
if (!isResizeImageWorkerResponse(message)) {
fail(new Error("Invalid image resize worker response"));
return;
}
if (message.error) {
fail(new Error(message.error));
return;
}
settle(message.result ?? null);
worker.on("message", (message: unknown) => {
// Ignore messages Node posts on the worker channel itself, such as
// `{ "watch:require": [...] }` under `node --watch` (nodejs/node#65044).
if (!isResizeImageWorkerResponse(message)) return;
if ("error" in message) fail(new Error(message.error));
else settle(message.result);
});
worker.once("error", fail);
worker.once("exit", (code) => {

View File

@@ -10,7 +10,7 @@ export function detectSupportedImageMimeType(buffer: Uint8Array): string | null
if (startsWith(buffer, PNG_SIGNATURE)) {
return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
}
if (startsWithAscii(buffer, 0, "GIF")) {
if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) {
return "image/gif";
}
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {

View File

@@ -0,0 +1,34 @@
/**
* Output files: files pi writes so the model can use output it was not shown in full, such as the
* full text of truncated tool output, binary MCP resources, and images shown by codemode scripts.
* Every output file is created here, so where they are stored can change in one place. Today they
* go to the OS temp directory.
*/
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Output can carry private data, so only the user may read the files. */
const OUTPUT_FILE_MODE = 0o600;
/** A new, unused path: `<dir>/<prefix>-<random hex><extension>`. `extension` includes the dot. */
function createOutputFilePath(prefix: string, extension: string): string {
return join(tmpdir(), `${prefix}-${randomBytes(8).toString("hex")}${extension}`);
}
/** Write `data` to a new output file and return its path. */
export async function writeOutputFile(prefix: string, extension: string, data: string | Uint8Array): Promise<string> {
const path = createOutputFilePath(prefix, extension);
// `wx` never follows a link someone else placed at the path.
await writeFile(path, data, { mode: OUTPUT_FILE_MODE, flag: "wx" });
return path;
}
/** Open a new output file for streamed output. */
export function createOutputFileStream(prefix: string, extension: string): { path: string; stream: WriteStream } {
const path = createOutputFilePath(prefix, extension);
return { path, stream: createWriteStream(path, { mode: OUTPUT_FILE_MODE, flags: "wx" }) };
}

View File

@@ -43,9 +43,9 @@ export function getFileRevision(path: string): string | undefined {
}
/**
* Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a remote URL protocol. Bare names, relative paths, and file: URLs
* are considered local.
* Returns true if the value is NOT a package source (npm:, git:, etc.),
* a built-in extension (builtin:), or a remote URL protocol. Bare names,
* relative paths, and file: URLs are considered local.
*/
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
@@ -56,7 +56,8 @@ export function isLocalPath(value: string): boolean {
trimmed.startsWith("github:") ||
trimmed.startsWith("http:") ||
trimmed.startsWith("https:") ||
trimmed.startsWith("ssh:")
trimmed.startsWith("ssh:") ||
trimmed.startsWith("builtin:")
) {
return false;
}

View File

@@ -153,40 +153,11 @@ export function getShellEnv(): NodeJS.ProcessEnv {
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
* - Characters with undefined code points
* - Unicode interlinear annotation characters U+FFF9..U+FFFB (crash string-width due to a bug)
*/
export function sanitizeBinaryOutput(str: string): string {
// Use Array.from to properly iterate over code points (not code units)
// This handles surrogate pairs correctly and catches edge cases where
// codePointAt() might return undefined
return Array.from(str)
.filter((char) => {
// Filter out characters that cause string-width to crash
// This includes:
// - Unicode format characters
// - Lone surrogates (already filtered by Array.from)
// - Control chars except \t \n \r
// - Characters with undefined code points
const code = char.codePointAt(0);
// Skip if code point is undefined (edge case with invalid strings)
if (code === undefined) return false;
// Allow tab, newline, carriage return
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
if (code <= 0x1f) return false;
// Filter out Unicode format characters
if (code >= 0xfff9 && code <= 0xfffb) return false;
return true;
})
.join("");
// All removed characters are single UTF-16 code units, so surrogate pairs are never split.
return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "");
}
/**

View File

@@ -8,6 +8,7 @@ import go from "highlight.js/lib/languages/go.js";
import groovy from "highlight.js/lib/languages/groovy.js";
import java from "highlight.js/lib/languages/java.js";
import javascript from "highlight.js/lib/languages/javascript.js";
import json from "highlight.js/lib/languages/json.js";
import kotlin from "highlight.js/lib/languages/kotlin.js";
import lua from "highlight.js/lib/languages/lua.js";
import nix from "highlight.js/lib/languages/nix.js";
@@ -26,6 +27,7 @@ const eagerLanguages = {
java,
go,
javascript,
json,
cpp,
typescript,
php,
@@ -153,7 +155,12 @@ export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}):
return;
}
const formatter = getActiveFormatter(scopes, theme);
output += formatter ? formatter(textBuffer) : textBuffer;
output += formatter
? textBuffer
.split("\n")
.map((line) => (line ? formatter(line) : line))
.join("\n")
: textBuffer;
textBuffer = "";
};

View File

@@ -1,4 +1,4 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { ImageContent, ModelImageResizeOptions, TextContent } from "@earendil-works/pi-ai";
import { processImage } from "./image-process.ts";
export type ToolResultContent = TextContent | ImageContent;
@@ -6,6 +6,8 @@ export type ToolResultContent = TextContent | ImageContent;
export interface NormalizeToolResultImagesOptions {
/** Whether oversized images are resized to inline provider limits. Default: true */
autoResizeImages?: boolean;
/** Model-specific resize profile. Uses the conservative built-in defaults when omitted. */
resizeOptions?: ModelImageResizeOptions;
}
/**
@@ -37,7 +39,10 @@ export async function normalizeToolResultImages(
continue;
}
const processed = await processImage(Buffer.from(block.data, "base64"), block.mimeType, { autoResizeImages });
const processed = await processImage(Buffer.from(block.data, "base64"), block.mimeType, {
autoResizeImages,
resizeOptions: options?.resizeOptions,
});
if (!processed.ok) {
// Unlike `read`, keep the original block. The tool already produced this image and the
// failure may just be an unavailable image backend, so passing it through preserves the

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
/** Windows Subsystem for Linux, where Windows executables are reachable through interop. */
export function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}

View File

@@ -0,0 +1,72 @@
import { writeFile } from "node:fs/promises";
import { crc32, deflateRawSync } from "node:zlib";
interface ZipEntry {
name: string;
data: string | Uint8Array;
}
function dosDateTime(date: Date): { time: number; day: number } {
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
day: ((Math.max(1980, date.getFullYear()) - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
/** Create the small, classic ZIP archives used by bug reports. */
function createZipArchive(entries: readonly ZipEntry[]): Buffer {
const files: Buffer[] = [];
const directory: Buffer[] = [];
const { time, day } = dosDateTime(new Date());
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name);
const data = typeof entry.data === "string" ? Buffer.from(entry.data) : Buffer.from(entry.data);
const compressed = deflateRawSync(data);
const checksum = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
files.push(local, name, compressed);
directory.push(central, name);
offset += local.length + name.length + compressed.length;
}
const centralDirectory = Buffer.concat(directory);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...files, centralDirectory, end]);
}
export function writeZipArchive(filePath: string, entries: readonly ZipEntry[]): Promise<void> {
return writeFile(filePath, createZipArchive(entries));
}