Skip to content

Commit b30b35b

Browse files
fix(cli): spec-compliant kitty format ids + PNG conversion for non-PNG payloads
getKittyFormat() now always returns 100 (PNG) — the only format id that all kitty-protocol terminals guarantee. Non-PNG payloads (JPEG, WebP, GIF) are converted to PNG via Jimp before transmission, so the bytes always match f=100. This prevents terminals from silently dropping images due to fabricated format ids (101-104) that don't exist in the kitty spec. renderInlineImage() is now async to support the Jimp conversion step. image-block.tsx and image-card.tsx updated to handle async with useEffect + state. New tests validate that the escape sequence uses only spec-compliant format ids (f=24|32|100) across all media types, and that no fabricated ids (101-104) appear in any chunk. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent a912ea6 commit b30b35b

4 files changed

Lines changed: 140 additions & 65 deletions

File tree

cli/src/components/blocks/image-block.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TextAttributes } from '@opentui/core'
2-
import { memo, useMemo } from 'react'
2+
import { memo, useEffect, useMemo, useState } from 'react'
33

44
import { useTheme } from '../../hooks/use-theme'
55
import { calculateDisplaySize } from '../../utils/image-display'
@@ -27,19 +27,32 @@ export const ImageBlock = memo(({ block, availableWidth }: ImageBlockProps) => {
2727
[width, height, availableWidth]
2828
)
2929

30-
// Try to render inline if supported
31-
const inlineSequence = useMemo(() => {
30+
// renderInlineImage is async (converts non-PNG to PNG for kitty),
31+
// so we render it in an effect and store the result in state.
32+
const [inlineSequence, setInlineSequence] = useState<string | null>(null)
33+
34+
useEffect(() => {
3235
if (!supportsInlineImages()) {
33-
return null
36+
setInlineSequence(null)
37+
return
3438
}
3539

36-
return renderInlineImage(image, {
40+
let cancelled = false
41+
renderInlineImage(image, {
3742
width: displaySize.width,
3843
height: displaySize.height,
3944
filename,
4045
mediaType,
46+
}).then((seq) => {
47+
if (!cancelled) {
48+
setInlineSequence(seq)
49+
}
4150
})
42-
}, [image, filename, displaySize])
51+
52+
return () => {
53+
cancelled = true
54+
}
55+
}, [image, filename, mediaType, displaySize])
4356

4457
// Format file size
4558
const formattedSize = useMemo(() => {

cli/src/components/image-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const ImageCard = ({
7777
}
7878

7979
if (base64Data) {
80-
const sequence = renderInlineImage(base64Data, {
80+
const sequence = await renderInlineImage(base64Data, {
8181
width: INLINE_IMAGE_WIDTH,
8282
height: INLINE_IMAGE_HEIGHT,
8383
filename: image.filename,

cli/src/utils/__tests__/terminal-images.test.ts

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
resetTerminalImageSupportCache,
88
} from '../terminal-images'
99

10+
/** Helper: await renderInlineImage and return the result. */
11+
const render = (
12+
...args: Parameters<typeof renderInlineImage>
13+
): Promise<string | null> => renderInlineImage(...args)
14+
1015
/** Detect kitty support and keep it cached so renderInlineImage uses it. */
1116
const useKitty = () => {
1217
resetTerminalImageSupportCache()
@@ -82,36 +87,30 @@ describe('detectTerminalImageSupport', () => {
8287
})
8388

8489
describe('getKittyFormat', () => {
85-
test('maps PNG to 100', () => {
90+
test('always returns 100 (PNG) — the only spec-compatible format id', () => {
8691
expect(getKittyFormat('image/png')).toBe(100)
87-
})
88-
89-
test('maps JPEG to 102', () => {
90-
expect(getKittyFormat('image/jpeg')).toBe(102)
91-
})
92-
93-
test('maps WebP to 103', () => {
94-
expect(getKittyFormat('image/webp')).toBe(103)
95-
})
96-
97-
test('maps GIF to 104', () => {
98-
expect(getKittyFormat('image/gif')).toBe(104)
99-
})
100-
101-
test('defaults unknown to PNG', () => {
92+
expect(getKittyFormat('image/jpeg')).toBe(100)
93+
expect(getKittyFormat('image/webp')).toBe(100)
94+
expect(getKittyFormat('image/gif')).toBe(100)
10295
expect(getKittyFormat(undefined)).toBe(100)
10396
expect(getKittyFormat('application/pdf')).toBe(100)
10497
})
10598
})
10699

100+
/**
101+
* Kitty spec only defines f=24 (RGB), f=32 (RGBA), f=100 (PNG).
102+
* Any other format id will be silently dropped or errored by real terminals.
103+
*/
104+
const VALID_KITTY_FORMAT_IDS = new Set([24, 32, 100])
105+
107106
describe('generateKittyImageSequence (via renderInlineImage)', () => {
108107
beforeEach(() => {
109108
resetTerminalImageSupportCache()
110109
})
111110

112-
test('single chunk carries m=0 to close the transmission', () => {
111+
test('single chunk carries m=0 to close the transmission', async () => {
113112
useKitty()
114-
const seq = renderInlineImage('aGVsbG8=', {
113+
const seq = await render('aGVsbG8=', {
115114
width: 4,
116115
height: 3,
117116
mediaType: 'image/png',
@@ -125,34 +124,34 @@ describe('generateKittyImageSequence (via renderInlineImage)', () => {
125124
expect(seq).toEndWith('aGVsbG8=\x1b\\')
126125
})
127126

128-
test('multi-chunk: full control only on first chunk; m=1 middle; m=0 last', () => {
127+
test('multi-chunk: full control only on first chunk; m=1 middle; m=0 last', async () => {
129128
useKitty()
130129
// 9000 base64 chars → 3 chunks (4096 + 4096 + 808)
131-
const seq = renderInlineImage('A'.repeat(9000), {
130+
const seq = await render('A'.repeat(9000), {
132131
width: 10,
133132
height: 5,
134-
mediaType: 'image/jpeg',
133+
mediaType: 'image/png',
135134
})
136-
expect(seq).toContain('f=102')
135+
expect(seq).toContain('f=100')
137136
expect(seq).toContain('c=10')
138137

139138
const parts = seq!.split('\x1b\\').filter(Boolean)
140139
expect(parts).toHaveLength(3)
141140

142141
// First chunk: full control data + m=1
143142
expect(parts[0]).toContain('a=T')
144-
expect(parts[0]).toContain('f=102')
143+
expect(parts[0]).toContain('f=100')
145144
expect(parts[0]).toContain('m=1')
146145
// Middle chunk: m only — no a=, no f=, no c=, no r=
147146
expect(parts[1]).toMatch(/^\x1b_Gm=1;A{4096}$/)
148147
// Last chunk: m=0
149148
expect(parts[2]).toMatch(/^\x1b_Gm=0;A{808}$/)
150149
})
151150

152-
test('subsequent chunks never repeat a=T / f= / c= (kitty spec)', () => {
151+
test('subsequent chunks never repeat a=T / f= / c= (kitty spec)', async () => {
153152
useKitty()
154153
// 9000 chars → 3 chunks, so index 1 is a true middle chunk.
155-
const seq = renderInlineImage('B'.repeat(9000), {
154+
const seq = await render('B'.repeat(9000), {
156155
mediaType: 'image/png',
157156
})
158157
const middle = seq!.split('\x1b\\')[1]
@@ -161,31 +160,74 @@ describe('generateKittyImageSequence (via renderInlineImage)', () => {
161160
expect(middle).not.toContain('c=')
162161
expect(middle).toMatch(/^\x1b_Gm=1;/)
163162
})
163+
164+
test('non-PNG payload is converted to PNG before transmission', async () => {
165+
useKitty()
166+
// 'hello' as base64 — pretending it's JPEG. The conversion will re-encode
167+
// it as a tiny PNG, so the output will differ from the raw input, but
168+
// f=100 must be used.
169+
const seq = await render('aGVsbG8=', {
170+
mediaType: 'image/jpeg',
171+
})
172+
expect(seq).toContain('f=100')
173+
// The payload must not be the original 'aGVsbG8=' — it was re-encoded as PNG.
174+
expect(seq).not.toContain('aGVsbG8=')
175+
})
176+
177+
test.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', undefined])(
178+
'escape sequence for %s uses a spec-compliant format id (f=100|24|32)',
179+
async (mediaType) => {
180+
useKitty()
181+
const seq = await render('aGVsbG8=', { mediaType })
182+
// Extract the f= value from the first chunk's control data.
183+
const fMatch = seq!.match(/f=(\d+)/)
184+
expect(fMatch).not.toBeNull()
185+
const formatId = Number(fMatch![1])
186+
expect(VALID_KITTY_FORMAT_IDS).toContain(formatId)
187+
},
188+
)
189+
190+
test('no fabricated format ids (101-104) appear in any chunk', async () => {
191+
useKitty()
192+
// Use a large payload to force multiple chunks and cover all code paths.
193+
const seq = await render('C'.repeat(9000), { mediaType: 'image/jpeg' })
194+
const allFormatIds = [...seq!.matchAll(/f=(\d+)/g)].map((m) =>
195+
Number(m[1]),
196+
)
197+
for (const id of allFormatIds) {
198+
expect(VALID_KITTY_FORMAT_IDS).toContain(id)
199+
}
200+
// Specifically ensure none of the fabricated ids from the old code appear.
201+
expect(allFormatIds).not.toContain(101)
202+
expect(allFormatIds).not.toContain(102)
203+
expect(allFormatIds).not.toContain(103)
204+
expect(allFormatIds).not.toContain(104)
205+
})
164206
})
165207

166208
describe('generateITerm2ImageSequence (via renderInlineImage)', () => {
167209
beforeEach(() => {
168210
resetTerminalImageSupportCache()
169211
})
170212

171-
test('size param is the decoded byte length, not the base64 length', () => {
213+
test('size param is the decoded byte length, not the base64 length', async () => {
172214
useITerm2()
173-
const seq = renderInlineImage('aGVsbG8=', { filename: 'x.png' })
215+
const seq = await render('aGVsbG8=', { filename: 'x.png' })
174216
// 'hello' → 5 decoded bytes; base64 'aGVsbG8=' → 8 chars
175217
expect(seq).toContain('size=5')
176218
expect(seq).not.toContain('size=8')
177219
expect(seq).toContain('inline=1')
178220
expect(seq).toContain('name=eC5wbmc=')
179221
})
180222

181-
test('returns null when the terminal does not support inline images', () => {
223+
test('returns null when the terminal does not support inline images', async () => {
182224
// Prime the cache with an explicit 'none' so the assertion doesn't depend
183225
// on whatever terminal this test happens to run inside.
184226
resetTerminalImageSupportCache()
185227
expect(
186228
detectTerminalImageSupport({ TERM: 'xterm-256color' } as any),
187229
).toBe('none')
188-
const seq = renderInlineImage('aGVsbG8=', {})
230+
const seq = await render('aGVsbG8=', {})
189231
expect(seq).toBeNull()
190232
})
191233
})

cli/src/utils/terminal-images.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* iTerm2 protocol reference: https://iterm2.com/documentation-images.html
77
*/
88

9+
import { Jimp } from 'jimp'
10+
911
import { getCliEnv } from './env'
1012

1113
import type { CliEnv } from '../types/env'
@@ -96,24 +98,13 @@ function normalizeMediaType(mediaType?: string): string {
9698
}
9799

98100
/**
99-
* Kitty graphics format ids: 100 = PNG, 101 = PNG with alpha, 102 = JPEG,
100-
* 103 = WebP, 104 = GIF. Anything unknown falls back to PNG (100); the
101-
* terminal will fail to decode a mismatched payload, so this must match the
102-
* actual bytes being sent.
101+
* Kitty graphics format id. The spec only defines f=24 (RGB), f=32 (RGBA),
102+
* and f=100 (PNG). Non-PNG payloads are converted to PNG before transmission
103+
* so we always send f=100 — this is the only guaranteed-compatible format
104+
* across all kitty-protocol terminals (kitty, WezTerm, Ghostty, etc.).
103105
*/
104-
export function getKittyFormat(mediaType?: string): number {
105-
switch (normalizeMediaType(mediaType)) {
106-
case 'image/jpeg':
107-
case 'image/jpg':
108-
return 102
109-
case 'image/webp':
110-
return 103
111-
case 'image/gif':
112-
return 104
113-
case 'image/png':
114-
default:
115-
return 100
116-
}
106+
export function getKittyFormat(_mediaType?: string): number {
107+
return 100
117108
}
118109

119110
/**
@@ -181,6 +172,31 @@ function generateITerm2ImageSequence(
181172
return `\x1b]1337;File=${paramString}:${base64Data}\x07`
182173
}
183174

175+
/**
176+
* Check whether the payload is already PNG (f=100 compatible).
177+
*/
178+
function isPng(mediaType?: string): boolean {
179+
const mt = normalizeMediaType(mediaType)
180+
return mt === 'image/png'
181+
}
182+
183+
/**
184+
* Convert a base64-encoded image (JPEG, WebP, GIF, etc.) to PNG via Jimp.
185+
* Returns the original data unchanged if it is already PNG.
186+
*/
187+
async function convertToPngIfNeeded(
188+
base64Data: string,
189+
mediaType?: string,
190+
): Promise<string> {
191+
if (isPng(mediaType)) {
192+
return base64Data
193+
}
194+
const inputBuffer = Buffer.from(base64Data, 'base64')
195+
const image = await Jimp.read(inputBuffer)
196+
const pngBuffer = await image.getBuffer('image/png')
197+
return pngBuffer.toString('base64')
198+
}
199+
184200
/**
185201
* Generate Kitty graphics protocol escape sequence.
186202
*
@@ -192,28 +208,32 @@ function generateITerm2ImageSequence(
192208
* renders the image or renders a fragment)
193209
* - non-final chunk payloads must be a multiple of 4 bytes of base64
194210
*
211+
* Non-PNG payloads (JPEG, WebP, GIF) are converted to PNG before
212+
* transmission because the kitty spec only defines f=100 (PNG) as a
213+
* guaranteed-compatible format across all terminals.
214+
*
195215
* @param base64Data - Base64 encoded image data
196216
* @param options - Display options
197217
*/
198-
function generateKittyImageSequence(
218+
async function generateKittyImageSequence(
199219
base64Data: string,
200220
options: {
201221
width?: number // cells
202222
height?: number // cells
203223
id?: number
204224
mediaType?: string
205225
} = {},
206-
): string {
226+
): Promise<string> {
207227
const { width, height, id, mediaType } = options
208228

229+
// Convert non-PNG payloads to PNG so the terminal can decode them.
230+
// The kitty spec only defines f=100 (PNG), f=24 (RGB), f=32 (RGBA).
231+
const pngBase64 = await convertToPngIfNeeded(base64Data, mediaType)
232+
209233
// Build key-value pairs for the control data (first chunk only)
210234
const kvPairs: string[] = [
211235
'a=T', // action: transmit and display
212-
// Format must match the payload bytes. JPEG (102) is what image-handler
213-
// produces after compression; kitty/WezTerm/Ghostty decode it natively.
214-
// Terminals that only implement the spec's mandatory RGB/RGBA/PNG set
215-
// won't render non-PNG payloads — the metadata-card fallback covers them.
216-
`f=${getKittyFormat(mediaType)}`,
236+
'f=100', // always PNG after conversion
217237
't=d', // transmission: direct (data follows)
218238
]
219239

@@ -236,9 +256,9 @@ function generateKittyImageSequence(
236256
const CHUNK_SIZE = 4096
237257

238258
const chunks: string[] = []
239-
for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
240-
const chunk = base64Data.slice(i, i + CHUNK_SIZE)
241-
const isLast = i + CHUNK_SIZE >= base64Data.length
259+
for (let i = 0; i < pngBase64.length; i += CHUNK_SIZE) {
260+
const chunk = pngBase64.slice(i, i + CHUNK_SIZE)
261+
const isLast = i + CHUNK_SIZE >= pngBase64.length
242262

243263
// First chunk: full control data + m. Subsequent chunks: m only, so the
244264
// terminal continues the same transmission instead of starting a new
@@ -257,15 +277,15 @@ function generateKittyImageSequence(
257277
* @param options - Display options
258278
* @returns The escape sequence string, or null if not supported
259279
*/
260-
export function renderInlineImage(
280+
export async function renderInlineImage(
261281
base64Data: string,
262282
options: {
263283
width?: number
264284
height?: number
265285
filename?: string
266286
mediaType?: string
267287
} = {},
268-
): string | null {
288+
): Promise<string | null> {
269289
const protocol = detectTerminalImageSupport()
270290

271291
switch (protocol) {

0 commit comments

Comments
 (0)