diff --git a/cli/src/components/blocks/image-block.tsx b/cli/src/components/blocks/image-block.tsx index 6aada062ed..e8c5a02f38 100644 --- a/cli/src/components/blocks/image-block.tsx +++ b/cli/src/components/blocks/image-block.tsx @@ -37,6 +37,7 @@ export const ImageBlock = memo(({ block, availableWidth }: ImageBlockProps) => { width: displaySize.width, height: displaySize.height, filename, + mediaType, }) }, [image, filename, displaySize]) diff --git a/cli/src/components/image-card.tsx b/cli/src/components/image-card.tsx index 01cf547eb8..3d31972da0 100644 --- a/cli/src/components/image-card.tsx +++ b/cli/src/components/image-card.tsx @@ -81,6 +81,7 @@ export const ImageCard = ({ width: INLINE_IMAGE_WIDTH, height: INLINE_IMAGE_HEIGHT, filename: image.filename, + mediaType: image.processedImage?.mediaType, }) if (!cancelled) { setThumbnailSequence(sequence) diff --git a/cli/src/types/env.ts b/cli/src/types/env.ts index 606548fd8f..eff11a90c6 100644 --- a/cli/src/types/env.ts +++ b/cli/src/types/env.ts @@ -33,6 +33,7 @@ export type CliEnv = BaseEnv & { // Terminal-specific KITTY_WINDOW_ID?: string + KONSOLE_VERSION?: string SIXEL_SUPPORT?: string ZED_NODE_ENV?: string ZED_TERM?: string diff --git a/cli/src/utils/__tests__/image-pipeline-integrity.test.ts b/cli/src/utils/__tests__/image-pipeline-integrity.test.ts new file mode 100644 index 0000000000..e85fcb0636 --- /dev/null +++ b/cli/src/utils/__tests__/image-pipeline-integrity.test.ts @@ -0,0 +1,214 @@ +import { mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' + +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { Jimp } from 'jimp' + +import { setProjectRoot } from '../../project-files' +import { processImageFile } from '../image-handler' +import { MAX_IMAGE_BASE64_SIZE } from '@codebuff/common/constants/images' + +// Mock the logger to prevent analytics initialization errors in tests +mock.module('../logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }, +})) + +let TEST_DIR: string + +beforeEach(async () => { + TEST_DIR = mkdtempSync(path.join(os.tmpdir(), 'cli-image-integrity-')) + mkdirSync(path.join(TEST_DIR, 'debug'), { recursive: true }) + setProjectRoot(TEST_DIR) +}) + +afterEach(() => { + try { + rmSync(TEST_DIR, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } +}) + +/** Fill an image with high-entropy content so its PNG encoding is large + * enough to exercise the compression path. */ +function addNoise(image: InstanceType, amount = 0.9): void { + image.scan(0, 0, image.bitmap.width, image.bitmap.height, (x, y, idx) => { + if (Math.random() < amount) { + image.bitmap.data[idx] = Math.floor(Math.random() * 256) + image.bitmap.data[idx + 1] = Math.floor(Math.random() * 256) + image.bitmap.data[idx + 2] = Math.floor(Math.random() * 256) + // Leave alpha as-is + } + }) +} + +describe('image pipeline integrity', () => { + test('small PNG passes through byte-identical and decodes', async () => { + const image = new Jimp({ width: 200, height: 100, color: 0x1e90ffff }) + // Draw a readable "UI-like" pattern: white bar + dark text-like stripes + image.scan(0, 0, 200, 100, (x, y, idx) => { + if (x >= 20 && x < 180 && y >= 30 && y < 70) { + image.bitmap.data[idx] = 0xff + image.bitmap.data[idx + 1] = 0xff + image.bitmap.data[idx + 2] = 0xff + } + }) + const filePath = path.join(TEST_DIR, 'small-200x100.png') as `${string}.${string}` + await image.write(filePath) + const originalBytes = readFileSync(filePath) + + const result = await processImageFile('small-200x100.png', TEST_DIR) + + expect(result.success).toBe(true) + expect(result.imagePart).toBeDefined() + expect(result.wasCompressed).toBe(false) + + const part = result.imagePart! + expect(part.width).toBe(200) + expect(part.height).toBe(100) + expect(part.mediaType).toBe('image/png') + + // The passthrough path must not mutate the payload. + const decoded = Buffer.from(part.image, 'base64') + expect(decoded.equals(originalBytes)).toBe(true) + + // And it must re-decode as a valid PNG with the right dimensions. + const reread = await Jimp.read(decoded) + expect(reread.bitmap.width).toBe(200) + expect(reread.bitmap.height).toBe(100) + }) + + test('large noisy image is compressed to a valid JPEG that still decodes', async () => { + // 1600x1600 noise produces a PNG far above MAX_IMAGE_BASE64_SIZE. + const image = new Jimp({ width: 1600, height: 1600, color: 0x000000ff }) + addNoise(image) + const filePath = path.join(TEST_DIR, 'big-noisy.png') as `${string}.${string}` + await image.write(filePath) + + const originalBase64 = readFileSync(filePath).toString('base64') + expect(originalBase64.length).toBeGreaterThan(MAX_IMAGE_BASE64_SIZE) + + const result = await processImageFile('big-noisy.png', TEST_DIR) + + expect(result.success).toBe(true) + expect(result.wasCompressed).toBe(true) + const part = result.imagePart! + expect(part.mediaType).toBe('image/jpeg') + expect(part.image.length).toBeLessThanOrEqual(MAX_IMAGE_BASE64_SIZE) + expect(part.width).toBeDefined() + expect(part.height).toBeDefined() + + // The compressed payload must decode as a real JPEG. + const decoded = Buffer.from(part.image, 'base64') + expect(decoded[0]).toBe(0xff) + expect(decoded[1]).toBe(0xd8) + + const reread = await Jimp.read(decoded) + // Aspect ratio preserved (1600x1600 → square at reduced dimension). + expect(reread.bitmap.width).toBe(reread.bitmap.height) + expect(reread.bitmap.width).toBe(part.width!) + expect(reread.bitmap.height).toBe(part.height!) + // Resized to the largest dimension that fit the budget. + expect(reread.bitmap.width).toBeGreaterThan(0) + expect(reread.bitmap.width).toBeLessThanOrEqual(1600) + + // Content must survive: average luminance should stay high (noise). + let sum = 0 + let count = 0 + reread.scan(0, 0, reread.bitmap.width, reread.bitmap.height, (_x, _y, idx) => { + sum += reread.bitmap.data[idx] + count++ + }) + const avg = sum / count + expect(avg).toBeGreaterThan(30) + expect(avg).toBeLessThan(235) + }) + + test('landscape compression preserves aspect ratio', async () => { + const image = new Jimp({ width: 2400, height: 1200, color: 0x00ff00ff }) + addNoise(image, 0.7) + const filePath = path.join(TEST_DIR, 'wide-2400x1200.png') as `${string}.${string}` + await image.write(filePath) + + const result = await processImageFile('wide-2400x1200.png', TEST_DIR) + expect(result.success).toBe(true) + expect(result.wasCompressed).toBe(true) + + const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64')) + expect(reread.bitmap.width / reread.bitmap.height).toBeCloseTo(2, 1) + }) + + test('portrait compression preserves aspect ratio', async () => { + const image = new Jimp({ width: 1200, height: 2400, color: 0xff0000ff }) + addNoise(image, 0.7) + const filePath = path.join(TEST_DIR, 'tall-1200x2400.png') as `${string}.${string}` + await image.write(filePath) + + const result = await processImageFile('tall-1200x2400.png', TEST_DIR) + expect(result.success).toBe(true) + expect(result.wasCompressed).toBe(true) + + const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64')) + expect(reread.bitmap.height / reread.bitmap.width).toBeCloseTo(2, 1) + }) + + test('transparent PNG is compressed without crashing and keeps dimensions', async () => { + const image = new Jimp({ width: 800, height: 600, color: 0x00000000 }) + // Transparent background with a small opaque shape — common for pasted + // UI elements; JPEG re-encode must still produce a decodable image. + image.scan(0, 0, 800, 600, (x, y, idx) => { + if (x > 100 && x < 300 && y > 100 && y < 300) { + image.bitmap.data[idx] = 0x00 + image.bitmap.data[idx + 1] = 0x80 + image.bitmap.data[idx + 2] = 0xff + image.bitmap.data[idx + 3] = 0xff + } + }) + const filePath = path.join(TEST_DIR, 'alpha-800x600.png') as `${string}.${string}` + await image.write(filePath) + + const result = await processImageFile('alpha-800x600.png', TEST_DIR) + expect(result.success).toBe(true) + if (result.wasCompressed) { + const part = result.imagePart! + const reread = await Jimp.read(Buffer.from(part.image, 'base64')) + expect(reread.bitmap.width).toBe(part.width!) + expect(reread.bitmap.height).toBe(part.height!) + } + }) + + test('jpeg input is accepted and re-encodes validly when compressed', async () => { + const image = new Jimp({ width: 1400, height: 900, color: 0x808080ff }) + addNoise(image, 0.8) + const filePath = path.join(TEST_DIR, 'photo-1400x900.jpg') as `${string}.${string}` + await image.write(filePath) + + const result = await processImageFile('photo-1400x900.jpg', TEST_DIR) + expect(result.success).toBe(true) + const part = result.imagePart! + expect(part.mediaType).toBe('image/jpeg') + + const reread = await Jimp.read(Buffer.from(part.image, 'base64')) + expect(reread.bitmap.width).toBe(part.width!) + expect(reread.bitmap.height).toBe(part.height!) + }) + + test('oversized file is rejected with a clear error, not corrupted', async () => { + // MAX_IMAGE_FILE_SIZE is 10MB; write a file that exceeds it and confirm + // we get a descriptive error instead of a silent failure. + const bigPath = path.join(TEST_DIR, 'huge.png') + const chunk = Buffer.alloc(1024 * 1024, 0x89) + writeFileSync(bigPath, Buffer.concat(Array(11).fill(chunk))) + + const result = await processImageFile('huge.png', TEST_DIR) + expect(result.success).toBe(false) + expect(result.error).toContain('too large') + }) +}) diff --git a/cli/src/utils/__tests__/terminal-images.test.ts b/cli/src/utils/__tests__/terminal-images.test.ts new file mode 100644 index 0000000000..942f27e279 --- /dev/null +++ b/cli/src/utils/__tests__/terminal-images.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect, beforeEach } from 'bun:test' + +import { + detectTerminalImageSupport, + getKittyFormat, + renderInlineImage, + resetTerminalImageSupportCache, +} from '../terminal-images' + +/** Detect kitty support and keep it cached so renderInlineImage uses it. */ +const useKitty = () => { + resetTerminalImageSupportCache() + expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe( + 'kitty', + ) +} + +/** Detect iTerm2 support and keep it cached so renderInlineImage uses it. */ +const useITerm2 = () => { + resetTerminalImageSupportCache() + expect(detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any)).toBe( + 'iterm2', + ) +} + +describe('detectTerminalImageSupport', () => { + beforeEach(() => { + resetTerminalImageSupportCache() + }) + + test('detects iTerm2', () => { + expect( + detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any), + ).toBe('iterm2') + }) + + test('detects kitty by TERM', () => { + expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe( + 'kitty', + ) + }) + + test('detects kitty by KITTY_WINDOW_ID', () => { + expect( + detectTerminalImageSupport({ KITTY_WINDOW_ID: '1' } as any), + ).toBe('kitty') + }) + + test('detects WezTerm', () => { + expect( + detectTerminalImageSupport({ TERM_PROGRAM: 'WezTerm' } as any), + ).toBe('kitty') + }) + + test('detects Ghostty', () => { + expect( + detectTerminalImageSupport({ TERM_PROGRAM: 'Ghostty' } as any), + ).toBe('kitty') + }) + + test('detects Warp', () => { + expect( + detectTerminalImageSupport({ TERM_PROGRAM: 'WarpTerminal' } as any), + ).toBe('kitty') + }) + + test('detects Konsole', () => { + expect( + detectTerminalImageSupport({ KONSOLE_VERSION: '230604' } as any), + ).toBe('kitty') + }) + + test('unknown terminal (e.g. Windows Terminal) falls back to none', () => { + expect( + detectTerminalImageSupport({ + TERM: 'xterm-256color', + TERM_PROGRAM: 'Windows Terminal', + WT_SESSION: 'abc', + } as any), + ).toBe('none') + }) +}) + +describe('getKittyFormat', () => { + test('maps PNG to 100', () => { + expect(getKittyFormat('image/png')).toBe(100) + }) + + test('maps JPEG to 102', () => { + expect(getKittyFormat('image/jpeg')).toBe(102) + }) + + test('maps WebP to 103', () => { + expect(getKittyFormat('image/webp')).toBe(103) + }) + + test('maps GIF to 104', () => { + expect(getKittyFormat('image/gif')).toBe(104) + }) + + test('defaults unknown to PNG', () => { + expect(getKittyFormat(undefined)).toBe(100) + expect(getKittyFormat('application/pdf')).toBe(100) + }) +}) + +describe('generateKittyImageSequence (via renderInlineImage)', () => { + beforeEach(() => { + resetTerminalImageSupportCache() + }) + + test('single chunk carries m=0 to close the transmission', () => { + useKitty() + const seq = renderInlineImage('aGVsbG8=', { + width: 4, + height: 3, + mediaType: 'image/png', + }) + expect(seq).toContain('a=T') + expect(seq).toContain('f=100') + expect(seq).toContain('t=d') + expect(seq).toContain('c=4') + expect(seq).toContain('r=3') + expect(seq).toContain('m=0') + expect(seq).toEndWith('aGVsbG8=\x1b\\') + }) + + test('multi-chunk: full control only on first chunk; m=1 middle; m=0 last', () => { + useKitty() + // 9000 base64 chars → 3 chunks (4096 + 4096 + 808) + const seq = renderInlineImage('A'.repeat(9000), { + width: 10, + height: 5, + mediaType: 'image/jpeg', + }) + expect(seq).toContain('f=102') + expect(seq).toContain('c=10') + + const parts = seq!.split('\x1b\\').filter(Boolean) + expect(parts).toHaveLength(3) + + // First chunk: full control data + m=1 + expect(parts[0]).toContain('a=T') + expect(parts[0]).toContain('f=102') + expect(parts[0]).toContain('m=1') + // Middle chunk: m only — no a=, no f=, no c=, no r= + expect(parts[1]).toMatch(/^\x1b_Gm=1;A{4096}$/) + // Last chunk: m=0 + expect(parts[2]).toMatch(/^\x1b_Gm=0;A{808}$/) + }) + + test('subsequent chunks never repeat a=T / f= / c= (kitty spec)', () => { + useKitty() + // 9000 chars → 3 chunks, so index 1 is a true middle chunk. + const seq = renderInlineImage('B'.repeat(9000), { + mediaType: 'image/png', + }) + const middle = seq!.split('\x1b\\')[1] + expect(middle).not.toContain('a=T') + expect(middle).not.toContain('f=') + expect(middle).not.toContain('c=') + expect(middle).toMatch(/^\x1b_Gm=1;/) + }) +}) + +describe('generateITerm2ImageSequence (via renderInlineImage)', () => { + beforeEach(() => { + resetTerminalImageSupportCache() + }) + + test('size param is the decoded byte length, not the base64 length', () => { + useITerm2() + const seq = renderInlineImage('aGVsbG8=', { filename: 'x.png' }) + // 'hello' → 5 decoded bytes; base64 'aGVsbG8=' → 8 chars + expect(seq).toContain('size=5') + expect(seq).not.toContain('size=8') + expect(seq).toContain('inline=1') + expect(seq).toContain('name=eC5wbmc=') + }) + + test('returns null when the terminal does not support inline images', () => { + // Prime the cache with an explicit 'none' so the assertion doesn't depend + // on whatever terminal this test happens to run inside. + resetTerminalImageSupportCache() + expect( + detectTerminalImageSupport({ TERM: 'xterm-256color' } as any), + ).toBe('none') + const seq = renderInlineImage('aGVsbG8=', {}) + expect(seq).toBeNull() + }) +}) diff --git a/cli/src/utils/clipboard-image.ts b/cli/src/utils/clipboard-image.ts index 73c71b849d..b35103d385 100644 --- a/cli/src/utils/clipboard-image.ts +++ b/cli/src/utils/clipboard-image.ts @@ -209,6 +209,47 @@ function readImageLinux(): ClipboardImageResult { } } +/** + * Spawn a PowerShell command, preferring Windows PowerShell (powershell.exe) + * and falling back to pwsh (PowerShell 7) when it is not on PATH — e.g. + * minimal installs where only pwsh is present. Clipboard reads fail silently + * (returning false/null) when the shell can't be spawned at all, so this + * keeps the whole paste flow working on more Windows setups. + * + * stdout/stderr are normalized to strings so callers don't have to handle + * the string | Buffer union Bun's spawnSync can return. + */ +function spawnPowerShell( + args: string[], + opts: { encoding?: 'utf-8'; timeout?: number; maxBuffer?: number } = {}, +): { + status: number | null + stdout: string + stderr: string + error?: Error | undefined +} { + const { encoding, timeout = 10000, maxBuffer } = opts + const common: Parameters[2] = { + encoding, + timeout, + ...(maxBuffer !== undefined ? { maxBuffer } : {}), + } + const trySpawn = (cmd: string) => spawnSync(cmd, args, common) + let result = trySpawn('powershell') + if (result.error && !result.stdout && !result.stderr) { + // powershell.exe missing (ENOENT) — try pwsh before giving up. + result = trySpawn('pwsh') + } + const toStr = (value: string | Buffer | null | undefined): string => + typeof value === 'string' ? value : value ? value.toString('utf-8') : '' + return { + status: result.status, + stdout: toStr(result.stdout), + stderr: toStr(result.stderr), + error: result.error, + } +} + /** * Check if clipboard contains an image (Windows) */ @@ -218,12 +259,12 @@ function hasImageWindows(): boolean { Add-Type -AssemblyName System.Windows.Forms if ([System.Windows.Forms.Clipboard]::ContainsImage()) { Write-Output "true" } else { Write-Output "false" } ` - const result = spawnSync('powershell', ['-STA', '-Command', script], { + const result = spawnPowerShell(['-STA', '-Command', script], { encoding: 'utf-8', timeout: 5000, }) - return result.stdout?.trim() === 'true' + return result.stdout.trim() === 'true' } catch { return false } @@ -249,12 +290,12 @@ function readImageWindows(): ClipboardImageResult { } ` - const result = spawnSync('powershell', ['-STA', '-Command', script], { + const result = spawnPowerShell(['-STA', '-Command', script], { encoding: 'utf-8', timeout: 10000, }) - if (result.stdout?.trim() === 'success' && existsSync(imagePath)) { + if (result.stdout.trim() === 'success' && existsSync(imagePath)) { return { success: true, imagePath, filename } } @@ -459,9 +500,9 @@ function readClipboardFilePathWindows(): string | null { Write-Output $files[0] } ` - const result = spawnSync('powershell', ['-STA', '-Command', script], { + const result = spawnPowerShell(['-STA', '-Command', script], { encoding: 'utf-8', - timeout: 1000, + timeout: 5000, }) if (result.status === 0 && result.stdout) { @@ -560,14 +601,22 @@ export function readClipboardImageFilePath(): string | null { export function readClipboardText(): string | null { try { const platform = process.platform - let result: ReturnType + let result: { + status: number | null + stdout: string | Buffer | null + stderr: string | Buffer | null + error?: Error | undefined + } switch (platform) { case 'darwin': result = spawnSync('pbpaste', [], { encoding: 'utf-8', timeout: 1000 }) break case 'win32': - result = spawnSync('powershell', ['-Command', 'Get-Clipboard'], { encoding: 'utf-8', timeout: 1000 }) + result = spawnPowerShell(['-Command', 'Get-Clipboard'], { + encoding: 'utf-8', + timeout: 1000, + }) break case 'linux': result = spawnSync('xclip', ['-selection', 'clipboard', '-o'], { encoding: 'utf-8', timeout: 1000 }) diff --git a/cli/src/utils/terminal-images.ts b/cli/src/utils/terminal-images.ts index cb6dc37492..0e88cf8790 100644 --- a/cli/src/utils/terminal-images.ts +++ b/cli/src/utils/terminal-images.ts @@ -1,6 +1,9 @@ /** * Terminal image rendering utilities * Supports iTerm2 inline images protocol and Kitty graphics protocol + * + * Kitty protocol reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ + * iTerm2 protocol reference: https://iterm2.com/documentation-images.html */ import { getCliEnv } from './env' @@ -12,7 +15,20 @@ export type TerminalImageProtocol = 'iterm2' | 'kitty' | 'sixel' | 'none' let cachedProtocol: TerminalImageProtocol | null = null /** - * Detect which image protocol the terminal supports + * Clear the cached detection result. Tests change the env between assertions, + * so they reset the cache; the CLI itself only ever detects once. + */ +export function resetTerminalImageSupportCache(): void { + cachedProtocol = null +} + +/** + * Detect which image protocol the terminal supports. + * + * Kitty's own spec lists these terminals as graphics-protocol compatible: + * kitty itself, Ghostty, Konsole, Warp, WezTerm, iTerm2, xterm.js, st, wayst. + * Detection is env-var based (cheap, synchronous); terminals that don't set a + * recognizable variable fall back to 'none' and render a metadata card. */ export function detectTerminalImageSupport( env: CliEnv = getCliEnv(), @@ -27,7 +43,7 @@ export function detectTerminalImageSupport( return cachedProtocol } - // Check for Kitty + // Check for kitty proper (TERM or the kitty-specific env var it exports) if ( env.TERM === 'xterm-kitty' || env.KITTY_WINDOW_ID !== undefined @@ -36,7 +52,24 @@ export function detectTerminalImageSupport( return cachedProtocol } - // Check for Sixel support (less common) + // WezTerm ships a full kitty-graphics implementation (since 2022) and + // exports TERM_PROGRAM. Ghostty, Warp and Konsole likewise implement the + // kitty protocol and identify themselves via TERM_PROGRAM / KONSOLE_VERSION. + // TERM_PROGRAM casing varies by terminal (Ghostty exports lowercase + // 'ghostty'), so compare case-insensitively. + const termProgram = (env.TERM_PROGRAM ?? '').toLowerCase() + if ( + termProgram === 'wezterm' || + termProgram === 'ghostty' || + termProgram === 'warpterminal' || + env.KONSOLE_VERSION !== undefined + ) { + cachedProtocol = 'kitty' + return cachedProtocol + } + + // Check for Sixel support (less common; Windows Terminal and some Linux + // terminals). Honored via env override since it can't be sniffed reliably. if ( env.TERM?.includes('sixel') || env.SIXEL_SUPPORT === 'true' @@ -56,6 +89,33 @@ export function supportsInlineImages(): boolean { return detectTerminalImageSupport() !== 'none' } +/** Map a media type to the iTerm2/kitty-friendly display name. */ +function normalizeMediaType(mediaType?: string): string { + if (!mediaType) return 'image/png' + return mediaType.startsWith('image/') ? mediaType : `image/${mediaType}` +} + +/** + * Kitty graphics format ids: 100 = PNG, 101 = PNG with alpha, 102 = JPEG, + * 103 = WebP, 104 = GIF. Anything unknown falls back to PNG (100); the + * terminal will fail to decode a mismatched payload, so this must match the + * actual bytes being sent. + */ +export function getKittyFormat(mediaType?: string): number { + switch (normalizeMediaType(mediaType)) { + case 'image/jpeg': + case 'image/jpg': + return 102 + case 'image/webp': + return 103 + case 'image/gif': + return 104 + case 'image/png': + default: + return 100 + } +} + /** * Generate iTerm2 inline image escape sequence * @param base64Data - Base64 encoded image data @@ -102,8 +162,17 @@ function generateITerm2ImageSequence( params.push(`name=${Buffer.from(name).toString('base64')}`) } - // Add size parameter (required) - params.push(`size=${base64Data.length}`) + // The size parameter is the byte length of the DECODED image data, not the + // base64-encoded length. iTerm2 uses it to size its backing store, so an + // inflated value (4/3x) can break rendering of larger images. Base64 padding + // ('=') does not encode bytes, so it must be subtracted. + const padding = base64Data.endsWith('==') + ? 2 + : base64Data.endsWith('=') + ? 1 + : 0 + const decodedSize = Math.floor((base64Data.length * 3) / 4) - padding + params.push(`size=${decodedSize}`) const paramString = params.join(';') @@ -113,7 +182,16 @@ function generateITerm2ImageSequence( } /** - * Generate Kitty graphics protocol escape sequence + * Generate Kitty graphics protocol escape sequence. + * + * Spec-compliant chunked transmission: + * - only the FIRST chunk carries the full control data (a, f, t, c, r, ...) + * - subsequent chunks carry ONLY `m=<0|1>` (and optionally `q`) + * - every chunk except the last has `m=1`; the LAST chunk has `m=0` + * (a missing m=0 leaves the transmission open, so the terminal never + * renders the image or renders a fragment) + * - non-final chunk payloads must be a multiple of 4 bytes of base64 + * * @param base64Data - Base64 encoded image data * @param options - Display options */ @@ -123,14 +201,19 @@ function generateKittyImageSequence( width?: number // cells height?: number // cells id?: number + mediaType?: string } = {}, ): string { - const { width, height, id } = options + const { width, height, id, mediaType } = options - // Build key-value pairs for the control data + // Build key-value pairs for the control data (first chunk only) const kvPairs: string[] = [ 'a=T', // action: transmit and display - 'f=100', // format: PNG (100) - let Kitty auto-detect + // Format must match the payload bytes. JPEG (102) is what image-handler + // produces after compression; kitty/WezTerm/Ghostty decode it natively. + // Terminals that only implement the spec's mandatory RGB/RGBA/PNG set + // won't render non-PNG payloads — the metadata-card fallback covers them. + `f=${getKittyFormat(mediaType)}`, 't=d', // transmission: direct (data follows) ] @@ -148,22 +231,21 @@ function generateKittyImageSequence( const controlData = kvPairs.join(',') - // Kitty requires chunked transmission for large images - // For simplicity, we'll send in one chunk if small enough + // Chunk size in base64 characters; 4096 is a multiple of 4 so every + // non-final chunk meets the spec's multiple-of-4 requirement. const CHUNK_SIZE = 4096 - if (base64Data.length <= CHUNK_SIZE) { - // Single chunk: ESC _ G ; ESC \ - return `\x1b_G${controlData};${base64Data}\x1b\\` - } - - // Multi-chunk transmission const chunks: string[] = [] for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) { const chunk = base64Data.slice(i, i + CHUNK_SIZE) const isLast = i + CHUNK_SIZE >= base64Data.length - const chunkControl = isLast ? controlData : `${controlData},m=1` // m=1 means more chunks coming - chunks.push(`\x1b_G${chunkControl};${chunk}\x1b\\`) + + // First chunk: full control data + m. Subsequent chunks: m only, so the + // terminal continues the same transmission instead of starting a new + // image (repeating a=T on every chunk fragments the image). + const control = i === 0 ? `${controlData},m=${isLast ? 0 : 1}` : `m=${isLast ? 0 : 1}` + + chunks.push(`\x1b_G${control};${chunk}\x1b\\`) } return chunks.join('') @@ -181,6 +263,7 @@ export function renderInlineImage( width?: number height?: number filename?: string + mediaType?: string } = {}, ): string | null { const protocol = detectTerminalImageSupport() @@ -197,6 +280,7 @@ export function renderInlineImage( return generateKittyImageSequence(base64Data, { width: options.width, height: options.height, + mediaType: options.mediaType, }) case 'sixel': diff --git a/test/setup-scm-loader.ts b/test/setup-scm-loader.ts new file mode 100644 index 0000000000..887dcb32b6 --- /dev/null +++ b/test/setup-scm-loader.ts @@ -0,0 +1,7 @@ +// Public-mirror stub for the repo-root SCM loader preload referenced by +// cli/bunfig.toml. The private repo uses this to configure the tree-sitter +// query (.scm) loader shared by @codebuff/code-map; the public mirror keeps +// the query files bundled with the package itself, so nothing needs wiring +// here. Without this file, `bun test` in the cli package fails at preload +// resolution, so it exists to make the public mirror self-testable. +export {}