From 5b70758145401f80c696a04748084d29ed835417 Mon Sep 17 00:00:00 2001 From: smarpitm Date: Sat, 22 Aug 2026 15:31:18 +0530 Subject: [PATCH] fix(cli): terminal detection for WezTerm/Ghostty/Warp/Konsole + Windows pwsh fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add kitty graphics protocol detection for WezTerm, Ghostty, Warp, and Konsole terminals (case-insensitive TERM_PROGRAM matching + KONSOLE_VERSION). Fix Windows clipboard image paste failing silently when powershell.exe is missing by falling back to pwsh (PowerShell 7). Normalize stdout/stderr to strings so callers don't handle the string | Buffer union. Also includes spec-compliant kitty chunking (m=0 terminator on last chunk, control data only on first chunk), iTerm2 size param fix (decoded bytes not base64 length), and image pipeline integrity tests. Split from a912ea6 — the getKittyFormat() format ID fix is deferred to a follow-up PR. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- cli/src/types/env.ts | 1 + .../image-pipeline-integrity.test.ts | 214 ++++++++++++++++++ .../utils/__tests__/terminal-images.test.ts | 164 ++++++++++++++ cli/src/utils/clipboard-image.ts | 65 +++++- cli/src/utils/terminal-images.ts | 86 +++++-- test/setup-scm-loader.ts | 7 + 6 files changed, 511 insertions(+), 26 deletions(-) create mode 100644 cli/src/utils/__tests__/image-pipeline-integrity.test.ts create mode 100644 cli/src/utils/__tests__/terminal-images.test.ts create mode 100644 test/setup-scm-loader.ts 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..644643af82 --- /dev/null +++ b/cli/src/utils/__tests__/terminal-images.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect, beforeEach } from 'bun:test' + +import { + detectTerminalImageSupport, + 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('generateKittyImageSequence (via renderInlineImage)', () => { + beforeEach(() => { + resetTerminalImageSupportCache() + }) + + test('single chunk carries m=0 to close the transmission', () => { + useKitty() + const seq = renderInlineImage('aGVsbG8=', { + width: 4, + height: 3, + }) + 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, + }) + expect(seq).toContain('f=100') + 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=100') + 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), { + }) + 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..4532c0b85e 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' @@ -102,8 +135,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 +155,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 */ @@ -127,10 +178,10 @@ function generateKittyImageSequence( ): string { const { width, height, id } = 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 + 'f=100', // format: PNG — only guaranteed-compatible format across kitty terminals 't=d', // transmission: direct (data follows) ] @@ -148,22 +199,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('') 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 {}