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:
@@ -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, "");
|
||||
}
|
||||
|
||||
44
third_party/pi/packages/coding-agent/src/utils/clipboard-command.ts
vendored
Normal file
44
third_party/pi/packages/coding-agent/src/utils/clipboard-command.ts
vendored
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
34
third_party/pi/packages/coding-agent/src/utils/output-files.ts
vendored
Normal file
34
third_party/pi/packages/coding-agent/src/utils/output-files.ts
vendored
Normal 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" }) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = "";
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
third_party/pi/packages/coding-agent/src/utils/wsl.ts
vendored
Normal file
15
third_party/pi/packages/coding-agent/src/utils/wsl.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
72
third_party/pi/packages/coding-agent/src/utils/zip.ts
vendored
Normal file
72
third_party/pi/packages/coding-agent/src/utils/zip.ts
vendored
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user