feat: Bun 1.3.14

- Raised the Bun minimum version to >=1.3.14 across package metadata, install scripts, and changelog notes.
- Removed the Photon native image pipeline and added SIXEL-based `sixel` support in pi-natives.
- Migrated coding-agent image handling and resizing to `Bun.Image`, including updated tests and a JPEG quality bump to 80.
- Added HTTP/2 fetch bootstrap with HTTPS-only fallback and updated Bun build flags for autoload suppression/`--keep-names`.
This commit is contained in:
can1357
2026-05-13 13:16:43 +02:00
parent 1087e7cdb9
commit af07faec11
41 changed files with 266 additions and 428 deletions
+6
View File
@@ -1,13 +1,19 @@
# Changelog
## [Unreleased]
### Added
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://<id>`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
- Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**.
### Changed
- Raised the image downscaling default JPEG quality from 75 to 80 in `resizeImage` output generation
- Changed image resize metadata notes from coordinate-scale hints to a simple `Image resized from <original> to <displayed>` message and hide the note when the resized dimensions are unchanged
- Removed `utils/image-convert.ts` and its `convertToPng` helper; callers now inline `new Bun.Image(bytes).png().toBase64()` from [`Bun.Image`](https://bun.com/docs/runtime/image) (Bun 1.3.14+).
- Changed image decode/resize/encode in `utils/image-resize.ts` from the native `PhotonImage` binding to [`Bun.Image`](https://bun.com/docs/runtime/image). Same PNG/JPEG/WebP quality+dimension ladder, but pipelines run off-thread on Bun's statically-linked codecs with no native-addon round-trip. Bumped the minimum Bun runtime requirement to **1.3.14**.
- Changed `search` pagination in multi-file scopes so `skip` now skips entire files and pages results in groups of up to 20 files, with output guiding the next `skip` value via `Showing files X-Y of N`
- Changed multi-file search result selection to cap each file at 20 matches and round-robin across files, so one noisy file no longer suppresses visibility of hits in other files and truncation now reports per-file limits
- Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly
+1 -1
View File
@@ -74,7 +74,7 @@
"@types/bun": "catalog:"
},
"engines": {
"bun": ">=1.3.7"
"bun": ">=1.3.14"
},
"files": [
"src",
@@ -33,6 +33,11 @@ async function main(): Promise<void> {
"bun",
"build",
"--compile",
"--no-compile-autoload-bunfig",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-tsconfig",
"--no-compile-autoload-package-json",
"--keep-names",
"--define",
'process.env.PI_COMPILED="true"',
"--external",
+8
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env bun
import { installH2Fetch } from "@oh-my-pi/pi-ai";
import { APP_NAME, MIN_BUN_VERSION, procmgr, VERSION } from "@oh-my-pi/pi-utils";
// Activate HTTP/2 for all `fetch()` calls (provider streams, OAuth, model
// discovery, web tools). Bun's HTTP/2 client is gated on a startup flag we
// can't toggle from JS, so we patch globalThis.fetch to pass
// `protocol: "http2"` per request, with transparent HTTP/1.1 fallback on
// `HTTP2Unsupported`. See @oh-my-pi/pi-ai/utils/h2-fetch for details.
installH2Fetch();
// Strip macOS malloc-stack-logging env vars before any subprocess is spawned.
// Otherwise every child bun process (subagents, plugin installs, ptree spawns,
// etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr.
@@ -4,7 +4,6 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { resolveImageOptions } from "../../tools/render-utils";
import { convertToPng } from "../../utils/image-convert";
/**
* Component that renders a complete assistant message
@@ -76,14 +75,15 @@ export class AssistantMessageComponent extends Container {
const key = `${toolCallId}:${index}`;
if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
this.#kittyConversionsInFlight.add(key);
convertToPng(image.data, image.mimeType)
.then(converted => {
new Bun.Image(Buffer.from(image.data, "base64"))
.png()
.toBase64()
.then(data => {
this.#kittyConversionsInFlight.delete(key);
if (!converted) return;
this.#convertedKittyImages.set(key, {
type: "image",
data: converted.data,
mimeType: converted.mimeType,
data,
mimeType: "image/png",
});
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
@@ -32,7 +32,6 @@ import {
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
import { toolRenderers } from "../../tools/renderers";
import { renderStatusLine } from "../../tui";
import { convertToPng } from "../../utils/image-convert";
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { renderDiff } from "./diff";
@@ -295,13 +294,13 @@ export class ToolExecutionComponent extends Container {
// Convert async - catch errors from processing
const index = i;
convertToPng(img.data, img.mimeType)
.then(converted => {
if (converted) {
this.#convertedImages.set(index, converted);
this.#updateDisplay();
this.#ui.requestRender();
}
new Bun.Image(Buffer.from(img.data, "base64"))
.png()
.toBase64()
.then(data => {
this.#convertedImages.set(index, { data, mimeType: "image/png" });
this.#updateDisplay();
this.#ui.requestRender();
})
.catch(() => {
// Ignore conversion failures - display will use original image format
@@ -1,27 +0,0 @@
import { ImageFormat, PhotonImage } from "@oh-my-pi/pi-natives";
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
*/
export async function convertToPng(
base64Data: string,
mimeType: string,
): Promise<{ data: string; mimeType: string } | null> {
// Already PNG, no conversion needed
if (mimeType === "image/png") {
return { data: base64Data, mimeType };
}
try {
const image = await PhotonImage.parse(new Uint8Array(Buffer.from(base64Data, "base64")));
const pngBuffer = await image.encode(ImageFormat.PNG, 100);
return {
data: Buffer.from(pngBuffer).toBase64(),
mimeType: "image/png",
};
} catch {
// Conversion failed
return null;
}
}
@@ -2,7 +2,6 @@ import * as fs from "node:fs/promises";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils";
import { resolveReadPath } from "../tools/path-utils";
import { convertToPng } from "./image-convert";
import { formatDimensionNote, resizeImage } from "./image-resize";
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
@@ -42,8 +41,13 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise<Im
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
return image;
}
const converted = await convertToPng(image.data, image.mimeType);
return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
try {
const bytes = Buffer.from(image.data, "base64");
const data = await new Bun.Image(bytes).png().toBase64();
return { type: "image", data, mimeType: "image/png" };
} catch {
return null;
}
}
export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
+32 -44
View File
@@ -1,11 +1,10 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 1568
maxHeight?: number; // Default: 1568
maxBytes?: number; // Default: 500KB
jpegQuality?: number; // Default: 75
maxWidth?: number;
maxHeight?: number;
maxBytes?: number;
jpegQuality?: number;
}
export interface ResizedImage {
@@ -24,12 +23,12 @@ export interface ResizedImage {
const DEFAULT_MAX_BYTES = 500 * 1024;
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
// 1568px — Anthropic downscales anything larger; OpenAI tiles at 768px;
// sending bigger pixels wastes bandwidth the model never sees.
// Anthropic's "internal recommended size" — Claude internally caps images at
// 1568px on the longest edge before vision processing.
maxWidth: 1568,
maxHeight: 1568,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 75,
jpegQuality: 80,
};
/** Pick the smallest of N encoded buffers. */
@@ -48,43 +47,34 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
/**
* Resize and recompress an image to fit within the specified max dimensions and file size.
*
* Defaults target Anthropic's internal 1568px downscale threshold and produce small
* lossy JPEG output suitable for tool-call payloads (~100–500KB typical).
*
* Strategy:
* 1. Fast path — if input already fits dimensions AND is at <=25% of byte budget,
* return as-is. Avoids re-encoding tiny icons/diagrams.
* 2. Resize to maxWidth/maxHeight, encode both PNG and JPEG at default quality,
* pick whichever is smaller. PNG wins for line art / few-color UI; JPEG wins
* for photographic content.
* 3. If still too large, JPEG-only quality ladder (PNG quality is a no-op).
* 4. If still too large, progressively reduce dimensions and retry the JPEG ladder.
* 5. Last resort: ship the smallest variant produced.
* 1. Probe metadata. If already within all limits, return original.
* 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
* 3. If still too large, walk a lossy JPEG/WebP quality ladder.
* 4. If still too large, walk a dimension-scale ladder × quality ladder.
* 5. If still too large, return the smallest variant produced.
*
* On any decode failure, returns the original bytes unchanged with wasResized=false.
* Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
* off the JS thread when the terminal (`.bytes()`) is awaited.
*/
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBuffer = Buffer.from(img.data, "base64");
try {
const image = await PhotonImage.parse(inputBuffer);
const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
const sourceMime = img.mimeType ?? `image/${format}`;
const originalWidth = image.width;
const originalHeight = image.height;
const format = img.mimeType?.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND size)
const originalSize = inputBuffer.length;
// Fast path: skip if already within dimensions AND well under budget.
// Fast path: already within dimensions AND well under budget.
// Threshold is 1/4 of budget — if already that compact, don't re-encode.
// Avoids wasted work on tiny icons/diagrams while ensuring larger PNGs
// still get JPEG-compressed.
const originalSize = inputBuffer.length;
const comfortableSize = opts.maxBytes / 4;
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= comfortableSize) {
return {
buffer: inputBuffer,
mimeType: img.mimeType ?? `image/${format}`,
mimeType: sourceMime,
originalWidth,
originalHeight,
width: originalWidth,
@@ -117,14 +107,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
height: number,
quality: number,
): Promise<{ buffer: Uint8Array; mimeType: string }> {
const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
resized.encode(ImageFormat.PNG, quality),
resized.encode(ImageFormat.JPEG, quality),
resized.encode(ImageFormat.WEBP, quality),
new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
]);
return pickSmallest(
{ buffer: pngBuffer, mimeType: "image/png" },
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -140,10 +127,9 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
height: number,
quality: number,
): Promise<{ buffer: Uint8Array; mimeType: string }> {
const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
const [jpegBuffer, webpBuffer] = await Promise.all([
resized.encode(ImageFormat.JPEG, quality),
resized.encode(ImageFormat.WEBP, quality),
new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
]);
return pickSmallest(
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -159,7 +145,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
let finalWidth = targetWidth;
let finalHeight = targetHeight;
// First attempt: resize to target, try both PNG and JPEG, pick smaller
// First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
if (best.buffer.length <= opts.maxBytes) {
@@ -264,9 +250,11 @@ export function formatDimensionNote(result: ResizedImage): string | undefined {
if (!result.wasResized) {
return undefined;
}
const scale = result.originalWidth / result.width;
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
result.height
}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
if (!result.originalWidth || !result.originalHeight || !result.width || !result.height) {
return undefined;
}
if (result.width === result.originalWidth && result.height === result.originalHeight) {
return undefined;
}
return `Image resized from ${result.originalWidth}x${result.originalHeight} to ${result.width}x${result.height}.`;
}
@@ -1,20 +1,43 @@
import { afterEach, describe, expect, test, vi } from "bun:test";
import * as imageConvert from "../src/utils/image-convert";
import { describe, expect, test } from "bun:test";
import { ensureSupportedImageInput } from "../src/utils/image-loading";
// 1x1 red PNG (69 bytes). Bun.Image sniffs format from bytes, so we can pass
// this with a non-supported MIME type and the conversion path runs over the
// real native pipeline.
const RED_1X1_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
describe("ensureSupportedImageInput", () => {
afterEach(() => {
vi.restoreAllMocks();
test("passes supported mime types through unchanged", async () => {
const input = { type: "image" as const, data: RED_1X1_PNG_BASE64, mimeType: "image/png" };
const result = await ensureSupportedImageInput(input);
expect(result).toEqual(input);
});
test("converts unsupported image input to png", async () => {
const convertToPngSpy = vi
.spyOn(imageConvert, "convertToPng")
.mockResolvedValue({ data: "pngdata", mimeType: "image/png" });
const result = await ensureSupportedImageInput({
type: "image",
data: RED_1X1_PNG_BASE64,
mimeType: "image/bmp",
});
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
// PNG re-encode of a 1x1 image must yield a valid (non-empty) PNG signature
// (`89 50 4E 47`) when decoded from base64.
const bytes = Buffer.from(result!.data, "base64");
expect(bytes.length).toBeGreaterThan(0);
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50);
expect(bytes[2]).toBe(0x4e);
expect(bytes[3]).toBe(0x47);
});
const result = await ensureSupportedImageInput({ type: "image", data: "bmpdata", mimeType: "image/bmp" });
expect(convertToPngSpy).toHaveBeenCalledWith("bmpdata", "image/bmp");
expect(result).toEqual({ type: "image", data: "pngdata", mimeType: "image/png" });
test("returns null when input bytes are not a decodable image", async () => {
const result = await ensureSupportedImageInput({
type: "image",
data: Buffer.from("not an image").toString("base64"),
mimeType: "image/bmp",
});
expect(result).toBeNull();
});
});
@@ -1,17 +1,15 @@
import { describe, expect, it } from "bun:test";
import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
import { resizeImage } from "../../src/utils/image-resize";
// 1x1 red PNG (69 bytes) — used as a Photon seed to synthesize larger fixtures
// 1x1 red PNG (69 bytes) — used as a Bun.Image seed to synthesize larger fixtures
// without checking binary blobs into the repo.
const RED_1X1_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
async function makeRedPng(width: number, height: number): Promise<string> {
const seed = await PhotonImage.parse(new Uint8Array(Buffer.from(RED_1X1_PNG_BASE64, "base64")));
const upscaled = await seed.resize(width, height, SamplingFilter.Nearest);
const bytes = await upscaled.encode(ImageFormat.PNG, 100);
return Buffer.from(bytes).toBase64();
const seed = Buffer.from(RED_1X1_PNG_BASE64, "base64");
const upscaled = await new Bun.Image(seed).resize(width, height, { filter: "nearest" }).png().bytes();
return Buffer.from(upscaled).toBase64();
}
describe("resizeImage defaults", () => {