Skip to content

Commit 5b70758

Browse files
fix(cli): terminal detection for WezTerm/Ghostty/Warp/Konsole + Windows pwsh fallback
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 <noreply@codebuff.com>
1 parent 5ca1a99 commit 5b70758

6 files changed

Lines changed: 511 additions & 26 deletions

File tree

cli/src/types/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type CliEnv = BaseEnv & {
3333

3434
// Terminal-specific
3535
KITTY_WINDOW_ID?: string
36+
KONSOLE_VERSION?: string
3637
SIXEL_SUPPORT?: string
3738
ZED_NODE_ENV?: string
3839
ZED_TERM?: string
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'
2+
import os from 'os'
3+
import path from 'path'
4+
5+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
6+
import { Jimp } from 'jimp'
7+
8+
import { setProjectRoot } from '../../project-files'
9+
import { processImageFile } from '../image-handler'
10+
import { MAX_IMAGE_BASE64_SIZE } from '@codebuff/common/constants/images'
11+
12+
// Mock the logger to prevent analytics initialization errors in tests
13+
mock.module('../logger', () => ({
14+
logger: {
15+
debug: () => {},
16+
info: () => {},
17+
warn: () => {},
18+
error: () => {},
19+
fatal: () => {},
20+
},
21+
}))
22+
23+
let TEST_DIR: string
24+
25+
beforeEach(async () => {
26+
TEST_DIR = mkdtempSync(path.join(os.tmpdir(), 'cli-image-integrity-'))
27+
mkdirSync(path.join(TEST_DIR, 'debug'), { recursive: true })
28+
setProjectRoot(TEST_DIR)
29+
})
30+
31+
afterEach(() => {
32+
try {
33+
rmSync(TEST_DIR, { recursive: true, force: true })
34+
} catch {
35+
// Ignore cleanup errors
36+
}
37+
})
38+
39+
/** Fill an image with high-entropy content so its PNG encoding is large
40+
* enough to exercise the compression path. */
41+
function addNoise(image: InstanceType<typeof Jimp>, amount = 0.9): void {
42+
image.scan(0, 0, image.bitmap.width, image.bitmap.height, (x, y, idx) => {
43+
if (Math.random() < amount) {
44+
image.bitmap.data[idx] = Math.floor(Math.random() * 256)
45+
image.bitmap.data[idx + 1] = Math.floor(Math.random() * 256)
46+
image.bitmap.data[idx + 2] = Math.floor(Math.random() * 256)
47+
// Leave alpha as-is
48+
}
49+
})
50+
}
51+
52+
describe('image pipeline integrity', () => {
53+
test('small PNG passes through byte-identical and decodes', async () => {
54+
const image = new Jimp({ width: 200, height: 100, color: 0x1e90ffff })
55+
// Draw a readable "UI-like" pattern: white bar + dark text-like stripes
56+
image.scan(0, 0, 200, 100, (x, y, idx) => {
57+
if (x >= 20 && x < 180 && y >= 30 && y < 70) {
58+
image.bitmap.data[idx] = 0xff
59+
image.bitmap.data[idx + 1] = 0xff
60+
image.bitmap.data[idx + 2] = 0xff
61+
}
62+
})
63+
const filePath = path.join(TEST_DIR, 'small-200x100.png') as `${string}.${string}`
64+
await image.write(filePath)
65+
const originalBytes = readFileSync(filePath)
66+
67+
const result = await processImageFile('small-200x100.png', TEST_DIR)
68+
69+
expect(result.success).toBe(true)
70+
expect(result.imagePart).toBeDefined()
71+
expect(result.wasCompressed).toBe(false)
72+
73+
const part = result.imagePart!
74+
expect(part.width).toBe(200)
75+
expect(part.height).toBe(100)
76+
expect(part.mediaType).toBe('image/png')
77+
78+
// The passthrough path must not mutate the payload.
79+
const decoded = Buffer.from(part.image, 'base64')
80+
expect(decoded.equals(originalBytes)).toBe(true)
81+
82+
// And it must re-decode as a valid PNG with the right dimensions.
83+
const reread = await Jimp.read(decoded)
84+
expect(reread.bitmap.width).toBe(200)
85+
expect(reread.bitmap.height).toBe(100)
86+
})
87+
88+
test('large noisy image is compressed to a valid JPEG that still decodes', async () => {
89+
// 1600x1600 noise produces a PNG far above MAX_IMAGE_BASE64_SIZE.
90+
const image = new Jimp({ width: 1600, height: 1600, color: 0x000000ff })
91+
addNoise(image)
92+
const filePath = path.join(TEST_DIR, 'big-noisy.png') as `${string}.${string}`
93+
await image.write(filePath)
94+
95+
const originalBase64 = readFileSync(filePath).toString('base64')
96+
expect(originalBase64.length).toBeGreaterThan(MAX_IMAGE_BASE64_SIZE)
97+
98+
const result = await processImageFile('big-noisy.png', TEST_DIR)
99+
100+
expect(result.success).toBe(true)
101+
expect(result.wasCompressed).toBe(true)
102+
const part = result.imagePart!
103+
expect(part.mediaType).toBe('image/jpeg')
104+
expect(part.image.length).toBeLessThanOrEqual(MAX_IMAGE_BASE64_SIZE)
105+
expect(part.width).toBeDefined()
106+
expect(part.height).toBeDefined()
107+
108+
// The compressed payload must decode as a real JPEG.
109+
const decoded = Buffer.from(part.image, 'base64')
110+
expect(decoded[0]).toBe(0xff)
111+
expect(decoded[1]).toBe(0xd8)
112+
113+
const reread = await Jimp.read(decoded)
114+
// Aspect ratio preserved (1600x1600 → square at reduced dimension).
115+
expect(reread.bitmap.width).toBe(reread.bitmap.height)
116+
expect(reread.bitmap.width).toBe(part.width!)
117+
expect(reread.bitmap.height).toBe(part.height!)
118+
// Resized to the largest dimension that fit the budget.
119+
expect(reread.bitmap.width).toBeGreaterThan(0)
120+
expect(reread.bitmap.width).toBeLessThanOrEqual(1600)
121+
122+
// Content must survive: average luminance should stay high (noise).
123+
let sum = 0
124+
let count = 0
125+
reread.scan(0, 0, reread.bitmap.width, reread.bitmap.height, (_x, _y, idx) => {
126+
sum += reread.bitmap.data[idx]
127+
count++
128+
})
129+
const avg = sum / count
130+
expect(avg).toBeGreaterThan(30)
131+
expect(avg).toBeLessThan(235)
132+
})
133+
134+
test('landscape compression preserves aspect ratio', async () => {
135+
const image = new Jimp({ width: 2400, height: 1200, color: 0x00ff00ff })
136+
addNoise(image, 0.7)
137+
const filePath = path.join(TEST_DIR, 'wide-2400x1200.png') as `${string}.${string}`
138+
await image.write(filePath)
139+
140+
const result = await processImageFile('wide-2400x1200.png', TEST_DIR)
141+
expect(result.success).toBe(true)
142+
expect(result.wasCompressed).toBe(true)
143+
144+
const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64'))
145+
expect(reread.bitmap.width / reread.bitmap.height).toBeCloseTo(2, 1)
146+
})
147+
148+
test('portrait compression preserves aspect ratio', async () => {
149+
const image = new Jimp({ width: 1200, height: 2400, color: 0xff0000ff })
150+
addNoise(image, 0.7)
151+
const filePath = path.join(TEST_DIR, 'tall-1200x2400.png') as `${string}.${string}`
152+
await image.write(filePath)
153+
154+
const result = await processImageFile('tall-1200x2400.png', TEST_DIR)
155+
expect(result.success).toBe(true)
156+
expect(result.wasCompressed).toBe(true)
157+
158+
const reread = await Jimp.read(Buffer.from(result.imagePart!.image, 'base64'))
159+
expect(reread.bitmap.height / reread.bitmap.width).toBeCloseTo(2, 1)
160+
})
161+
162+
test('transparent PNG is compressed without crashing and keeps dimensions', async () => {
163+
const image = new Jimp({ width: 800, height: 600, color: 0x00000000 })
164+
// Transparent background with a small opaque shape — common for pasted
165+
// UI elements; JPEG re-encode must still produce a decodable image.
166+
image.scan(0, 0, 800, 600, (x, y, idx) => {
167+
if (x > 100 && x < 300 && y > 100 && y < 300) {
168+
image.bitmap.data[idx] = 0x00
169+
image.bitmap.data[idx + 1] = 0x80
170+
image.bitmap.data[idx + 2] = 0xff
171+
image.bitmap.data[idx + 3] = 0xff
172+
}
173+
})
174+
const filePath = path.join(TEST_DIR, 'alpha-800x600.png') as `${string}.${string}`
175+
await image.write(filePath)
176+
177+
const result = await processImageFile('alpha-800x600.png', TEST_DIR)
178+
expect(result.success).toBe(true)
179+
if (result.wasCompressed) {
180+
const part = result.imagePart!
181+
const reread = await Jimp.read(Buffer.from(part.image, 'base64'))
182+
expect(reread.bitmap.width).toBe(part.width!)
183+
expect(reread.bitmap.height).toBe(part.height!)
184+
}
185+
})
186+
187+
test('jpeg input is accepted and re-encodes validly when compressed', async () => {
188+
const image = new Jimp({ width: 1400, height: 900, color: 0x808080ff })
189+
addNoise(image, 0.8)
190+
const filePath = path.join(TEST_DIR, 'photo-1400x900.jpg') as `${string}.${string}`
191+
await image.write(filePath)
192+
193+
const result = await processImageFile('photo-1400x900.jpg', TEST_DIR)
194+
expect(result.success).toBe(true)
195+
const part = result.imagePart!
196+
expect(part.mediaType).toBe('image/jpeg')
197+
198+
const reread = await Jimp.read(Buffer.from(part.image, 'base64'))
199+
expect(reread.bitmap.width).toBe(part.width!)
200+
expect(reread.bitmap.height).toBe(part.height!)
201+
})
202+
203+
test('oversized file is rejected with a clear error, not corrupted', async () => {
204+
// MAX_IMAGE_FILE_SIZE is 10MB; write a file that exceeds it and confirm
205+
// we get a descriptive error instead of a silent failure.
206+
const bigPath = path.join(TEST_DIR, 'huge.png')
207+
const chunk = Buffer.alloc(1024 * 1024, 0x89)
208+
writeFileSync(bigPath, Buffer.concat(Array(11).fill(chunk)))
209+
210+
const result = await processImageFile('huge.png', TEST_DIR)
211+
expect(result.success).toBe(false)
212+
expect(result.error).toContain('too large')
213+
})
214+
})
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describe, test, expect, beforeEach } from 'bun:test'
2+
3+
import {
4+
detectTerminalImageSupport,
5+
renderInlineImage,
6+
resetTerminalImageSupportCache,
7+
} from '../terminal-images'
8+
9+
/** Detect kitty support and keep it cached so renderInlineImage uses it. */
10+
const useKitty = () => {
11+
resetTerminalImageSupportCache()
12+
expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe(
13+
'kitty',
14+
)
15+
}
16+
17+
/** Detect iTerm2 support and keep it cached so renderInlineImage uses it. */
18+
const useITerm2 = () => {
19+
resetTerminalImageSupportCache()
20+
expect(detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any)).toBe(
21+
'iterm2',
22+
)
23+
}
24+
25+
describe('detectTerminalImageSupport', () => {
26+
beforeEach(() => {
27+
resetTerminalImageSupportCache()
28+
})
29+
30+
test('detects iTerm2', () => {
31+
expect(
32+
detectTerminalImageSupport({ TERM_PROGRAM: 'iTerm.app' } as any),
33+
).toBe('iterm2')
34+
})
35+
36+
test('detects kitty by TERM', () => {
37+
expect(detectTerminalImageSupport({ TERM: 'xterm-kitty' } as any)).toBe(
38+
'kitty',
39+
)
40+
})
41+
42+
test('detects kitty by KITTY_WINDOW_ID', () => {
43+
expect(
44+
detectTerminalImageSupport({ KITTY_WINDOW_ID: '1' } as any),
45+
).toBe('kitty')
46+
})
47+
48+
test('detects WezTerm', () => {
49+
expect(
50+
detectTerminalImageSupport({ TERM_PROGRAM: 'WezTerm' } as any),
51+
).toBe('kitty')
52+
})
53+
54+
test('detects Ghostty', () => {
55+
expect(
56+
detectTerminalImageSupport({ TERM_PROGRAM: 'Ghostty' } as any),
57+
).toBe('kitty')
58+
})
59+
60+
test('detects Warp', () => {
61+
expect(
62+
detectTerminalImageSupport({ TERM_PROGRAM: 'WarpTerminal' } as any),
63+
).toBe('kitty')
64+
})
65+
66+
test('detects Konsole', () => {
67+
expect(
68+
detectTerminalImageSupport({ KONSOLE_VERSION: '230604' } as any),
69+
).toBe('kitty')
70+
})
71+
72+
test('unknown terminal (e.g. Windows Terminal) falls back to none', () => {
73+
expect(
74+
detectTerminalImageSupport({
75+
TERM: 'xterm-256color',
76+
TERM_PROGRAM: 'Windows Terminal',
77+
WT_SESSION: 'abc',
78+
} as any),
79+
).toBe('none')
80+
})
81+
})
82+
83+
describe('generateKittyImageSequence (via renderInlineImage)', () => {
84+
beforeEach(() => {
85+
resetTerminalImageSupportCache()
86+
})
87+
88+
test('single chunk carries m=0 to close the transmission', () => {
89+
useKitty()
90+
const seq = renderInlineImage('aGVsbG8=', {
91+
width: 4,
92+
height: 3,
93+
})
94+
expect(seq).toContain('a=T')
95+
expect(seq).toContain('f=100')
96+
expect(seq).toContain('t=d')
97+
expect(seq).toContain('c=4')
98+
expect(seq).toContain('r=3')
99+
expect(seq).toContain('m=0')
100+
expect(seq).toEndWith('aGVsbG8=\x1b\\')
101+
})
102+
103+
test('multi-chunk: full control only on first chunk; m=1 middle; m=0 last', () => {
104+
useKitty()
105+
// 9000 base64 chars → 3 chunks (4096 + 4096 + 808)
106+
const seq = renderInlineImage('A'.repeat(9000), {
107+
width: 10,
108+
height: 5,
109+
})
110+
expect(seq).toContain('f=100')
111+
expect(seq).toContain('c=10')
112+
113+
const parts = seq!.split('\x1b\\').filter(Boolean)
114+
expect(parts).toHaveLength(3)
115+
116+
// First chunk: full control data + m=1
117+
expect(parts[0]).toContain('a=T')
118+
expect(parts[0]).toContain('f=100')
119+
expect(parts[0]).toContain('m=1')
120+
// Middle chunk: m only — no a=, no f=, no c=, no r=
121+
expect(parts[1]).toMatch(/^\x1b_Gm=1;A{4096}$/)
122+
// Last chunk: m=0
123+
expect(parts[2]).toMatch(/^\x1b_Gm=0;A{808}$/)
124+
})
125+
126+
test('subsequent chunks never repeat a=T / f= / c= (kitty spec)', () => {
127+
useKitty()
128+
// 9000 chars → 3 chunks, so index 1 is a true middle chunk.
129+
const seq = renderInlineImage('B'.repeat(9000), {
130+
})
131+
const middle = seq!.split('\x1b\\')[1]
132+
expect(middle).not.toContain('a=T')
133+
expect(middle).not.toContain('f=')
134+
expect(middle).not.toContain('c=')
135+
expect(middle).toMatch(/^\x1b_Gm=1;/)
136+
})
137+
})
138+
139+
describe('generateITerm2ImageSequence (via renderInlineImage)', () => {
140+
beforeEach(() => {
141+
resetTerminalImageSupportCache()
142+
})
143+
144+
test('size param is the decoded byte length, not the base64 length', () => {
145+
useITerm2()
146+
const seq = renderInlineImage('aGVsbG8=', { filename: 'x.png' })
147+
// 'hello' → 5 decoded bytes; base64 'aGVsbG8=' → 8 chars
148+
expect(seq).toContain('size=5')
149+
expect(seq).not.toContain('size=8')
150+
expect(seq).toContain('inline=1')
151+
expect(seq).toContain('name=eC5wbmc=')
152+
})
153+
154+
test('returns null when the terminal does not support inline images', () => {
155+
// Prime the cache with an explicit 'none' so the assertion doesn't depend
156+
// on whatever terminal this test happens to run inside.
157+
resetTerminalImageSupportCache()
158+
expect(
159+
detectTerminalImageSupport({ TERM: 'xterm-256color' } as any),
160+
).toBe('none')
161+
const seq = renderInlineImage('aGVsbG8=', {})
162+
expect(seq).toBeNull()
163+
})
164+
})

0 commit comments

Comments
 (0)