Skip to content

Commit 659d220

Browse files
committed
feat(cli): add colored image thumbnail preview for all terminals
- Add image-thumbnail.ts utility to extract pixel colors using Jimp - Add ImageThumbnail component rendering with Unicode half-blocks (▀) - Use OpenTUI native fg/backgroundColor styling instead of ANSI escapes - Works in terminals without iTerm2/Kitty inline image support - Falls back to emoji if image processing fails
1 parent 26e1f48 commit 659d220

File tree

6 files changed

+751
-49
lines changed

6 files changed

+751
-49
lines changed

bun.lock

Lines changed: 471 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
"open": "^10.1.0",
3939
"pino": "9.4.0",
4040
"posthog-node": "4.17.2",
41-
"string-width": "^7.2.0",
4241
"react": "^19.0.0",
4342
"react-reconciler": "^0.32.0",
4443
"remark-breaks": "^4.0.0",
4544
"remark-gfm": "^4.0.1",
4645
"remark-parse": "^11.0.0",
46+
"string-width": "^7.2.0",
47+
"terminal-image": "^4.1.0",
4748
"unified": "^11.0.0",
4849
"yoga-layout": "^3.2.1",
4950
"zod": "^3.24.1",

cli/src/components/image-card.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
22
import fs from 'fs'
33

44
import { Button } from './button'
5+
import { ImageThumbnail } from './image-thumbnail'
56

67
import { useTheme } from '../hooks/use-theme'
78
import {
@@ -61,26 +62,40 @@ export const ImageCard = ({
6162
const theme = useTheme()
6263
const [isCloseHovered, setIsCloseHovered] = useState(false)
6364
const [thumbnailSequence, setThumbnailSequence] = useState<string | null>(null)
64-
const canShowThumbnail = supportsInlineImages()
65+
const canShowInlineImages = supportsInlineImages()
6566

66-
// Load thumbnail if terminal supports inline images
67+
// Load thumbnail if terminal supports inline images (iTerm2/Kitty)
6768
useEffect(() => {
68-
if (!canShowThumbnail) return
69-
70-
try {
71-
const imageData = fs.readFileSync(image.path)
72-
const base64Data = imageData.toString('base64')
73-
const sequence = renderInlineImage(base64Data, {
74-
width: 4, // Small thumbnail width in cells
75-
height: 3, // Small thumbnail height in cells
76-
filename: image.filename,
77-
})
78-
setThumbnailSequence(sequence)
79-
} catch {
80-
// Failed to load image, will show icon fallback
81-
setThumbnailSequence(null)
69+
if (!canShowInlineImages) return
70+
71+
let cancelled = false
72+
73+
const loadThumbnail = async () => {
74+
try {
75+
const imageData = fs.readFileSync(image.path)
76+
const base64Data = imageData.toString('base64')
77+
const sequence = renderInlineImage(base64Data, {
78+
width: 4,
79+
height: 3,
80+
filename: image.filename,
81+
})
82+
if (!cancelled) {
83+
setThumbnailSequence(sequence)
84+
}
85+
} catch {
86+
// Failed to load image, will show icon fallback
87+
if (!cancelled) {
88+
setThumbnailSequence(null)
89+
}
90+
}
91+
}
92+
93+
loadThumbnail()
94+
95+
return () => {
96+
cancelled = true
8297
}
83-
}, [image.path, image.filename, canShowThumbnail])
98+
}, [image.path, image.filename, canShowInlineImages])
8499

85100
const truncatedName = truncateFilename(image.filename)
86101

@@ -114,7 +129,12 @@ export const ImageCard = ({
114129
{thumbnailSequence ? (
115130
<text>{thumbnailSequence}</text>
116131
) : (
117-
<text style={{ fg: theme.info }}>🖼️</text>
132+
<ImageThumbnail
133+
imagePath={image.path}
134+
width={18}
135+
height={3}
136+
fallback={<text style={{ fg: theme.info }}>🖼️</text>}
137+
/>
118138
)}
119139
</box>
120140
{/* Close button in top-right corner */}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Image Thumbnail Component
3+
* Renders a small image preview using colored Unicode half-blocks
4+
* Uses OpenTUI's native fg/backgroundColor styling instead of ANSI escape sequences
5+
*/
6+
7+
import React, { useEffect, useState, memo } from 'react'
8+
9+
import {
10+
extractThumbnailColors,
11+
rgbToHex,
12+
type ThumbnailData,
13+
} from '../utils/image-thumbnail'
14+
15+
interface ImageThumbnailProps {
16+
imagePath: string
17+
width: number // Width in cells
18+
height: number // Height in rows (each row uses half-blocks for 2 pixel rows)
19+
fallback?: React.ReactNode
20+
}
21+
22+
/**
23+
* Renders an image as colored blocks using Unicode half-blocks (▀)
24+
* Each character cell displays 2 vertical pixels by using:
25+
* - Foreground color for top pixel
26+
* - Background color for bottom pixel
27+
* - ▀ (upper half block) character
28+
*/
29+
export const ImageThumbnail = memo(({
30+
imagePath,
31+
width,
32+
height,
33+
fallback,
34+
}: ImageThumbnailProps) => {
35+
const [thumbnailData, setThumbnailData] = useState<ThumbnailData | null>(null)
36+
const [isLoading, setIsLoading] = useState(true)
37+
const [error, setError] = useState(false)
38+
39+
useEffect(() => {
40+
let cancelled = false
41+
42+
const loadThumbnail = async () => {
43+
setIsLoading(true)
44+
setError(false)
45+
46+
const data = await extractThumbnailColors(imagePath, width, height)
47+
48+
if (!cancelled) {
49+
if (data) {
50+
setThumbnailData(data)
51+
} else {
52+
setError(true)
53+
}
54+
setIsLoading(false)
55+
}
56+
}
57+
58+
loadThumbnail()
59+
60+
return () => {
61+
cancelled = true
62+
}
63+
}, [imagePath, width, height])
64+
65+
if (isLoading) {
66+
return <>{fallback}</>
67+
}
68+
69+
if (error || !thumbnailData) {
70+
return <>{fallback}</>
71+
}
72+
73+
// Render the thumbnail using half-blocks
74+
// Each row of our output combines 2 pixel rows from the image
75+
const rows: React.ReactNode[] = []
76+
77+
for (let rowIndex = 0; rowIndex < thumbnailData.height; rowIndex += 2) {
78+
const topRow = thumbnailData.pixels[rowIndex]
79+
const bottomRow = thumbnailData.pixels[rowIndex + 1] || topRow // Use top row if no bottom
80+
81+
const cells: React.ReactNode[] = []
82+
83+
for (let col = 0; col < thumbnailData.width; col++) {
84+
const topPixel = topRow[col]
85+
const bottomPixel = bottomRow[col]
86+
87+
const fgColor = rgbToHex(topPixel.r, topPixel.g, topPixel.b)
88+
const bgColor = rgbToHex(bottomPixel.r, bottomPixel.g, bottomPixel.b)
89+
90+
cells.push(
91+
<box
92+
key={col}
93+
style={{
94+
backgroundColor: bgColor,
95+
}}
96+
>
97+
<text style={{ fg: fgColor }}></text>
98+
</box>
99+
)
100+
}
101+
102+
rows.push(
103+
<box key={rowIndex} style={{ flexDirection: 'row' }}>
104+
{cells}
105+
</box>
106+
)
107+
}
108+
109+
return (
110+
<box style={{ flexDirection: 'column' }}>
111+
{rows}
112+
</box>
113+
)
114+
})

cli/src/utils/image-thumbnail.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Image thumbnail utilities for extracting pixel colors
3+
* Uses Jimp to decode images and sample colors for display
4+
*/
5+
6+
import { Jimp } from 'jimp'
7+
8+
export interface ThumbnailPixel {
9+
r: number
10+
g: number
11+
b: number
12+
}
13+
14+
export interface ThumbnailData {
15+
width: number
16+
height: number
17+
pixels: ThumbnailPixel[][] // [row][col]
18+
}
19+
20+
/**
21+
* Extract a thumbnail grid of colors from an image file
22+
* @param imagePath - Path to the image file
23+
* @param targetWidth - Target width in cells
24+
* @param targetHeight - Target height in cells (will be doubled with half-blocks)
25+
* @returns Promise resolving to thumbnail data with pixel colors
26+
*/
27+
export async function extractThumbnailColors(
28+
imagePath: string,
29+
targetWidth: number,
30+
targetHeight: number,
31+
): Promise<ThumbnailData | null> {
32+
try {
33+
const image = await Jimp.read(imagePath)
34+
35+
// Resize to target dimensions (height * 2 because we use half-blocks)
36+
const resizedHeight = targetHeight * 2
37+
image.resize({ w: targetWidth, h: resizedHeight })
38+
39+
const width = image.width
40+
const height = image.height
41+
42+
const pixels: ThumbnailPixel[][] = []
43+
44+
for (let y = 0; y < height; y++) {
45+
const row: ThumbnailPixel[] = []
46+
for (let x = 0; x < width; x++) {
47+
const color = image.getPixelColor(x, y)
48+
// Jimp stores colors as 32-bit integers: RRGGBBAA
49+
const r = (color >> 24) & 0xff
50+
const g = (color >> 16) & 0xff
51+
const b = (color >> 8) & 0xff
52+
row.push({ r, g, b })
53+
}
54+
pixels.push(row)
55+
}
56+
57+
return { width, height, pixels }
58+
} catch {
59+
return null
60+
}
61+
}
62+
63+
/**
64+
* Convert RGB to hex color string
65+
*/
66+
export function rgbToHex(r: number, g: number, b: number): string {
67+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
68+
}

cli/src/utils/terminal-images.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/**
22
* Terminal image rendering utilities
33
* Supports iTerm2 inline images protocol and Kitty graphics protocol
4+
* Falls back to ANSI block characters for unsupported terminals
45
*/
56

7+
import terminalImage from 'terminal-image'
8+
69
export type TerminalImageProtocol = 'iterm2' | 'kitty' | 'sixel' | 'none'
710

811
let cachedProtocol: TerminalImageProtocol | null = null
@@ -221,3 +224,58 @@ export function getImageSupportDescription(): string {
221224
return 'No inline image support'
222225
}
223226
}
227+
228+
/**
229+
* Render an image using ANSI block characters (Unicode half-blocks)
230+
* This works in any terminal that supports 24-bit color
231+
* @param imageBuffer - Buffer containing image data
232+
* @param options - Display options
233+
* @returns Promise resolving to the ANSI escape sequence string
234+
*/
235+
export async function renderAnsiBlockImage(
236+
imageBuffer: Buffer,
237+
options: {
238+
width?: number
239+
height?: number
240+
} = {},
241+
): Promise<string> {
242+
const { width = 20, height = 10 } = options
243+
244+
try {
245+
const result = await terminalImage.buffer(imageBuffer, {
246+
width,
247+
height,
248+
preserveAspectRatio: true,
249+
})
250+
return result
251+
} catch {
252+
return ''
253+
}
254+
}
255+
256+
/**
257+
* Render an image from a file path using ANSI block characters
258+
* @param filePath - Path to the image file
259+
* @param options - Display options
260+
* @returns Promise resolving to the ANSI escape sequence string
261+
*/
262+
export async function renderAnsiBlockImageFromFile(
263+
filePath: string,
264+
options: {
265+
width?: number
266+
height?: number
267+
} = {},
268+
): Promise<string> {
269+
const { width = 20, height = 10 } = options
270+
271+
try {
272+
const result = await terminalImage.file(filePath, {
273+
width,
274+
height,
275+
preserveAspectRatio: true,
276+
})
277+
return result
278+
} catch {
279+
return ''
280+
}
281+
}

0 commit comments

Comments
 (0)