fix: simplify ImageShowTool to always render timg blocks within Ink's virtual DOM
Browse filesRemove Kitty protocol/writeRaw bypass that caused TUI cursor corruption and
image display failures. Always render via timg Unicode blocks through RawAnsi
so Ink knows the image dimensions and the cursor stays in sync.
Clean up terminalImage.ts by removing TimgMode, mode parameter, getImageRowsCount,
and tmux TERM_PROGRAM detection that were added in subsequent broken commits.
Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>
- src/tools/ImageShowTool/ImageShowTool.tsx +113 -147
- src/utils/terminalImage.ts +41 -140
src/tools/ImageShowTool/ImageShowTool.tsx
CHANGED
|
@@ -1,14 +1,11 @@
|
|
| 1 |
import { readFile } from 'fs/promises'
|
| 2 |
import { homedir } from 'os'
|
| 3 |
-
import React
|
| 4 |
import { z } from 'zod/v4'
|
| 5 |
import { RawAnsi, Text } from '../../ink.js'
|
| 6 |
-
import { wrapForMultiplexer } from '../../ink/termio/osc.js'
|
| 7 |
-
import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
|
| 8 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 9 |
import { logForDebugging } from '../../utils/debug.js'
|
| 10 |
import {
|
| 11 |
-
detectImageProtocol,
|
| 12 |
renderImageWithTimgSync,
|
| 13 |
} from '../../utils/terminalImage.js'
|
| 14 |
|
|
@@ -40,46 +37,111 @@ function detectFormat(url: string): string {
|
|
| 40 |
return 'png'
|
| 41 |
}
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
-
/**
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
offset?: number
|
| 61 |
-
}) {
|
| 62 |
-
const writeRaw = useContext(TerminalWriteContext)
|
| 63 |
-
|
| 64 |
-
useEffect(() => {
|
| 65 |
-
if (writeRaw && kittyOutput) {
|
| 66 |
-
writeRaw(`\x1b[${imageRows + offset}A${kittyOutput}\x1b[${imageRows + offset}B`)
|
| 67 |
}
|
| 68 |
-
}, [kittyOutput, imageRows, offset, writeRaw])
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
return <RawAnsi lines={lines} width={width} />
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
export const ImageShowTool = buildTool({
|
| 76 |
name: IMAGE_TOOL_NAME,
|
| 77 |
description:
|
| 78 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 79 |
-
'
|
| 80 |
-
'
|
| 81 |
-
'
|
| 82 |
-
'stays in sync and the image scrolls with conversation content.',
|
| 83 |
|
| 84 |
getToolUseSummary,
|
| 85 |
getActivityDescription(input) {
|
|
@@ -102,7 +164,7 @@ export const ImageShowTool = buildTool({
|
|
| 102 |
},
|
| 103 |
|
| 104 |
async prompt(_options): Promise<string> {
|
| 105 |
-
return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal
|
| 106 |
},
|
| 107 |
|
| 108 |
async checkPermissions(): Promise<{ behavior: 'allow' }> {
|
|
@@ -157,9 +219,7 @@ export const ImageShowTool = buildTool({
|
|
| 157 |
content: {
|
| 158 |
success: boolean
|
| 159 |
message: string
|
| 160 |
-
kittyOutput?: string
|
| 161 |
timgOutput?: string
|
| 162 |
-
imageRows?: number
|
| 163 |
},
|
| 164 |
_progressMessages,
|
| 165 |
_options,
|
|
@@ -168,26 +228,8 @@ export const ImageShowTool = buildTool({
|
|
| 168 |
return null
|
| 169 |
}
|
| 170 |
|
| 171 |
-
// Kitty protocol: bypass Ink's virtual DOM via writeRaw and reserve
|
| 172 |
-
// space with empty RawAnsi lines.
|
| 173 |
-
if (content.kittyOutput && content.imageRows && content.imageRows > 0) {
|
| 174 |
-
return (
|
| 175 |
-
<KittyImage
|
| 176 |
-
kittyOutput={content.kittyOutput}
|
| 177 |
-
imageRows={content.imageRows}
|
| 178 |
-
/>
|
| 179 |
-
)
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
// Block-mode fallback when Kitty protocol is not supported.
|
| 183 |
if (content.timgOutput) {
|
| 184 |
-
|
| 185 |
-
const lines = cleaned.split('\n').filter(l => l.length > 0)
|
| 186 |
-
if (lines.length > 0) {
|
| 187 |
-
const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
| 188 |
-
const width = Math.max(...lines.map(l => ansiStrip(l).length))
|
| 189 |
-
return <RawAnsi lines={lines} width={width} />
|
| 190 |
-
}
|
| 191 |
}
|
| 192 |
|
| 193 |
return <Text dimColor>{content.message}</Text>
|
|
@@ -199,8 +241,6 @@ export const ImageShowTool = buildTool({
|
|
| 199 |
message: string
|
| 200 |
imageData?: { base64: string; mediaType: string }
|
| 201 |
base64?: string
|
| 202 |
-
format?: string
|
| 203 |
-
kittyOutput?: string
|
| 204 |
timgOutput?: string
|
| 205 |
}
|
| 206 |
}> {
|
|
@@ -209,60 +249,9 @@ export const ImageShowTool = buildTool({
|
|
| 209 |
|
| 210 |
logForDebugging(`ImageShow: loading ${url}`)
|
| 211 |
|
| 212 |
-
|
| 213 |
-
const normalizedUrl = url.startsWith('~')
|
| 214 |
-
? url.replace(/^~(?=$|\/)/, homedir())
|
| 215 |
-
: url
|
| 216 |
-
|
| 217 |
-
let imgResult: { buffer: Buffer; format: string } | null = null
|
| 218 |
-
|
| 219 |
-
if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
|
| 220 |
-
const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
|
| 221 |
-
try {
|
| 222 |
-
const format = detectFormat(path)
|
| 223 |
-
const buffer = await readFile(path)
|
| 224 |
-
imgResult = { buffer, format }
|
| 225 |
-
} catch {
|
| 226 |
-
// fall through
|
| 227 |
-
}
|
| 228 |
-
} else {
|
| 229 |
-
try {
|
| 230 |
-
const response = await fetch(normalizedUrl, {
|
| 231 |
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Codev/1.0)' },
|
| 232 |
-
redirect: 'follow',
|
| 233 |
-
})
|
| 234 |
-
if (response.ok) {
|
| 235 |
-
const contentType = response.headers.get('content-type') ?? ''
|
| 236 |
-
const format = detectFormat(normalizedUrl !== contentType ? normalizedUrl : contentType)
|
| 237 |
-
const reader = response.body?.getReader()
|
| 238 |
-
if (reader) {
|
| 239 |
-
const chunks: Uint8Array[] = []
|
| 240 |
-
let totalSize = 0
|
| 241 |
-
while (true) {
|
| 242 |
-
const { done, value } = await reader.read()
|
| 243 |
-
if (done) break
|
| 244 |
-
totalSize += value.byteLength
|
| 245 |
-
if (totalSize > 10_000_000) {
|
| 246 |
-
reader.cancel()
|
| 247 |
-
break
|
| 248 |
-
}
|
| 249 |
-
chunks.push(value)
|
| 250 |
-
}
|
| 251 |
-
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.byteLength, 0))
|
| 252 |
-
let offset = 0
|
| 253 |
-
for (const chunk of chunks) {
|
| 254 |
-
combined.set(chunk, offset)
|
| 255 |
-
offset += chunk.byteLength
|
| 256 |
-
}
|
| 257 |
-
imgResult = { buffer: Buffer.from(combined.buffer), format }
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
} catch (err) {
|
| 261 |
-
logForDebugging(`ImageShow: fetch error ${err} for ${normalizedUrl}`)
|
| 262 |
-
}
|
| 263 |
-
}
|
| 264 |
|
| 265 |
-
if (!
|
| 266 |
return {
|
| 267 |
data: {
|
| 268 |
success: false,
|
|
@@ -271,45 +260,25 @@ export const ImageShowTool = buildTool({
|
|
| 271 |
}
|
| 272 |
}
|
| 273 |
|
| 274 |
-
const { buffer, format } =
|
|
|
|
| 275 |
const mediaType =
|
| 276 |
-
format === 'jpeg'
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
| 279 |
: 'image/png'
|
| 280 |
|
| 281 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 282 |
|
| 283 |
-
//
|
| 284 |
-
//
|
| 285 |
-
//
|
| 286 |
-
const
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
let imageRows = 0
|
| 290 |
-
|
| 291 |
-
if (protocol === 'kitty') {
|
| 292 |
-
const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
|
| 293 |
-
if (rawKitty) {
|
| 294 |
-
// Pass the raw timg output directly through writeRaw — no need to
|
| 295 |
-
// strip cursor sequences since writeRaw bypasses Ink's screen buffer.
|
| 296 |
-
kittyOutput = wrapForMultiplexer(rawKitty.trimEnd())
|
| 297 |
-
const termRows = process.stdout.rows ?? 40
|
| 298 |
-
imageRows = Math.max(10, Math.floor(termRows * 0.5))
|
| 299 |
-
logForDebugging(
|
| 300 |
-
`ImageShow: generated Kitty protocol output, image rows = ${imageRows}`,
|
| 301 |
-
)
|
| 302 |
-
}
|
| 303 |
-
if (imageRows === 0) {
|
| 304 |
-
kittyOutput = undefined
|
| 305 |
-
}
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
if (!kittyOutput) {
|
| 309 |
-
timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
|
| 310 |
-
if (timgOutput) {
|
| 311 |
-
logForDebugging('ImageShow: generated block-mode output via timg')
|
| 312 |
-
}
|
| 313 |
}
|
| 314 |
|
| 315 |
return {
|
|
@@ -321,10 +290,7 @@ export const ImageShowTool = buildTool({
|
|
| 321 |
mediaType,
|
| 322 |
},
|
| 323 |
base64: buffer.toString('base64'),
|
| 324 |
-
format,
|
| 325 |
-
kittyOutput,
|
| 326 |
timgOutput,
|
| 327 |
-
imageRows,
|
| 328 |
},
|
| 329 |
}
|
| 330 |
},
|
|
|
|
| 1 |
import { readFile } from 'fs/promises'
|
| 2 |
import { homedir } from 'os'
|
| 3 |
+
import React from 'react'
|
| 4 |
import { z } from 'zod/v4'
|
| 5 |
import { RawAnsi, Text } from '../../ink.js'
|
|
|
|
|
|
|
| 6 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 7 |
import { logForDebugging } from '../../utils/debug.js'
|
| 8 |
import {
|
|
|
|
| 9 |
renderImageWithTimgSync,
|
| 10 |
} from '../../utils/terminalImage.js'
|
| 11 |
|
|
|
|
| 37 |
return 'png'
|
| 38 |
}
|
| 39 |
|
| 40 |
+
/** Read a local file and return its buffer + detected format */
|
| 41 |
+
async function readLocalImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 42 |
+
try {
|
| 43 |
+
const format = detectFormat(url)
|
| 44 |
+
const buffer = await readFile(url)
|
| 45 |
+
return { buffer, format }
|
| 46 |
+
} catch {
|
| 47 |
+
return null
|
| 48 |
+
}
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/** Download an image from URL and return its buffer + detected format */
|
| 52 |
+
async function fetchImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 53 |
+
try {
|
| 54 |
+
const response = await fetch(url, {
|
| 55 |
+
headers: {
|
| 56 |
+
'User-Agent': 'Mozilla/5.0 (compatible; Codev/1.0)',
|
| 57 |
+
},
|
| 58 |
+
redirect: 'follow',
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
if (!response.ok) {
|
| 62 |
+
logForDebugging(`ImageShow: HTTP ${response.status} for ${url}`)
|
| 63 |
+
return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
|
|
|
| 65 |
|
| 66 |
+
const contentType = response.headers.get('content-type') ?? ''
|
| 67 |
+
const format = detectFormat(url !== contentType ? url : contentType)
|
| 68 |
+
|
| 69 |
+
const reader = response.body?.getReader()
|
| 70 |
+
if (!reader) return null
|
| 71 |
+
|
| 72 |
+
const chunks: Uint8Array[] = []
|
| 73 |
+
let totalSize = 0
|
| 74 |
+
const MAX_SIZE = 10_000_000
|
| 75 |
+
|
| 76 |
+
while (true) {
|
| 77 |
+
const { done, value } = await reader.read()
|
| 78 |
+
if (done) break
|
| 79 |
+
totalSize += value.byteLength
|
| 80 |
+
if (totalSize > MAX_SIZE) {
|
| 81 |
+
logForDebugging(`ImageShow: image too large (${totalSize} bytes) for ${url}`)
|
| 82 |
+
reader.cancel()
|
| 83 |
+
return null
|
| 84 |
+
}
|
| 85 |
+
chunks.push(value)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const combinedLength = chunks.reduce((acc, c) => acc + c.byteLength, 0)
|
| 89 |
+
const combined = new Uint8Array(combinedLength)
|
| 90 |
+
let offset = 0
|
| 91 |
+
for (const chunk of chunks) {
|
| 92 |
+
combined.set(chunk, offset)
|
| 93 |
+
offset += chunk.byteLength
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return { buffer: Buffer.from(combined.buffer), format }
|
| 97 |
+
} catch (err) {
|
| 98 |
+
logForDebugging(`ImageShow: fetch error ${err} for ${url}`)
|
| 99 |
+
return null
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/** Load image: local file or remote URL */
|
| 104 |
+
async function loadImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 105 |
+
// Expand ~ to home directory
|
| 106 |
+
const normalizedUrl = url.startsWith('~')
|
| 107 |
+
? url.replace(/^~(?=$|\/)/, homedir())
|
| 108 |
+
: url
|
| 109 |
+
if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
|
| 110 |
+
const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
|
| 111 |
+
return readLocalImage(path)
|
| 112 |
+
}
|
| 113 |
+
return fetchImage(normalizedUrl)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/**
|
| 117 |
+
* React component that renders timg Unicode-block output within Ink's
|
| 118 |
+
* virtual DOM so Ink knows the image dimensions and the cursor stays
|
| 119 |
+
* in sync with the terminal.
|
| 120 |
+
*/
|
| 121 |
+
function TimgDisplay({ output }: { output: string }): React.ReactNode {
|
| 122 |
+
// Strip cursor hide/show sequences that timg adds
|
| 123 |
+
const cleaned = output.replace(/\x1b\[\?25[hl]/g, '')
|
| 124 |
+
const lines = cleaned.split('\n').filter(l => l.length > 0)
|
| 125 |
+
if (lines.length === 0) {
|
| 126 |
+
return null
|
| 127 |
+
}
|
| 128 |
+
// Measure visible width (strip ANSI escape codes)
|
| 129 |
+
const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
| 130 |
+
const width = Math.max(...lines.map(l => ansiStrip(l).length))
|
| 131 |
return <RawAnsi lines={lines} width={width} />
|
| 132 |
}
|
| 133 |
|
| 134 |
+
function getToolUseSummary(input: Partial<Input>): string | null {
|
| 135 |
+
return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
export const ImageShowTool = buildTool({
|
| 139 |
name: IMAGE_TOOL_NAME,
|
| 140 |
description:
|
| 141 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 142 |
+
'Renders with timg Unicode-block characters within Ink\'s virtual DOM ' +
|
| 143 |
+
'so the cursor stays in sync and the image scrolls with conversation content. ' +
|
| 144 |
+
'Supports both URLs (https://) and local file paths.',
|
|
|
|
| 145 |
|
| 146 |
getToolUseSummary,
|
| 147 |
getActivityDescription(input) {
|
|
|
|
| 164 |
},
|
| 165 |
|
| 166 |
async prompt(_options): Promise<string> {
|
| 167 |
+
return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal using timg Unicode-block rendering. Supports local file paths (e.g. /tmp/image.png) and HTTPS URLs (e.g. https://example.com/image.png). The image is rendered within Ink's virtual DOM so the cursor stays in sync.`
|
| 168 |
},
|
| 169 |
|
| 170 |
async checkPermissions(): Promise<{ behavior: 'allow' }> {
|
|
|
|
| 219 |
content: {
|
| 220 |
success: boolean
|
| 221 |
message: string
|
|
|
|
| 222 |
timgOutput?: string
|
|
|
|
| 223 |
},
|
| 224 |
_progressMessages,
|
| 225 |
_options,
|
|
|
|
| 228 |
return null
|
| 229 |
}
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if (content.timgOutput) {
|
| 232 |
+
return <TimgDisplay output={content.timgOutput} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
}
|
| 234 |
|
| 235 |
return <Text dimColor>{content.message}</Text>
|
|
|
|
| 241 |
message: string
|
| 242 |
imageData?: { base64: string; mediaType: string }
|
| 243 |
base64?: string
|
|
|
|
|
|
|
| 244 |
timgOutput?: string
|
| 245 |
}
|
| 246 |
}> {
|
|
|
|
| 249 |
|
| 250 |
logForDebugging(`ImageShow: loading ${url}`)
|
| 251 |
|
| 252 |
+
const result = await loadImage(url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
+
if (!result) {
|
| 255 |
return {
|
| 256 |
data: {
|
| 257 |
success: false,
|
|
|
|
| 260 |
}
|
| 261 |
}
|
| 262 |
|
| 263 |
+
const { buffer, format } = result
|
| 264 |
+
|
| 265 |
const mediaType =
|
| 266 |
+
format === 'jpeg'
|
| 267 |
+
? 'image/jpeg'
|
| 268 |
+
: format === 'gif'
|
| 269 |
+
? 'image/gif'
|
| 270 |
+
: format === 'webp'
|
| 271 |
+
? 'image/webp'
|
| 272 |
: 'image/png'
|
| 273 |
|
| 274 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 275 |
|
| 276 |
+
// Always render via timg Unicode blocks within Ink's virtual DOM.
|
| 277 |
+
// RawAnsi in renderToolResultMessage renders the output so Ink knows
|
| 278 |
+
// the image dimensions and the cursor stays in sync.
|
| 279 |
+
const timgOutput = renderImageWithTimgSync(buffer, format) ?? undefined
|
| 280 |
+
if (timgOutput) {
|
| 281 |
+
logForDebugging('ImageShow: generated timg Unicode-block output')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
return {
|
|
|
|
| 290 |
mediaType,
|
| 291 |
},
|
| 292 |
base64: buffer.toString('base64'),
|
|
|
|
|
|
|
| 293 |
timgOutput,
|
|
|
|
| 294 |
},
|
| 295 |
}
|
| 296 |
},
|
src/utils/terminalImage.ts
CHANGED
|
@@ -58,14 +58,6 @@ export function detectImageProtocol(): ImageProtocol {
|
|
| 58 |
return 'kitty'
|
| 59 |
}
|
| 60 |
|
| 61 |
-
// Inside tmux, TERM_PROGRAM may reveal the host terminal if forwarded
|
| 62 |
-
if (process.env.TMUX) {
|
| 63 |
-
const hostTerm = process.env.TERM_PROGRAM ?? ''
|
| 64 |
-
if (['kitty', 'WezTerm', 'ghostty', 'foot'].includes(hostTerm)) {
|
| 65 |
-
return 'kitty'
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
return null
|
| 70 |
}
|
| 71 |
|
|
@@ -141,32 +133,25 @@ export function getImageProtocolSummary(): string {
|
|
| 141 |
}
|
| 142 |
|
| 143 |
/**
|
| 144 |
-
*
|
| 145 |
-
*
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
/**
|
| 149 |
-
* Render an image using the `timg` utility.
|
| 150 |
-
*
|
| 151 |
-
* In `kitty` mode, uses the Kitty graphics protocol for native-quality
|
| 152 |
-
* rendering in terminals that support it.
|
| 153 |
*
|
| 154 |
-
*
|
| 155 |
-
*
|
|
|
|
| 156 |
*
|
| 157 |
* @param buffer - The raw image buffer (decoded)
|
| 158 |
* @param format - Image format (png, jpeg, gif, webp)
|
| 159 |
-
* @param columns - Optional terminal width in character columns
|
| 160 |
-
* @param rows - Optional terminal height in character rows
|
| 161 |
-
* @
|
| 162 |
-
* @returns Escape sequence string for rendering, or null on failure
|
| 163 |
*/
|
| 164 |
export async function renderImageWithTimg(
|
| 165 |
buffer: Buffer,
|
| 166 |
format: string,
|
| 167 |
columns?: number,
|
| 168 |
rows?: number,
|
| 169 |
-
mode: TimgMode = 'blocks',
|
| 170 |
): Promise<string | null> {
|
| 171 |
try {
|
| 172 |
const timgPath = await which('timg')
|
|
@@ -185,37 +170,25 @@ export async function renderImageWithTimg(
|
|
| 185 |
try {
|
| 186 |
writeFileSync(tmpFile, buffer)
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
const
|
| 201 |
timgPath,
|
| 202 |
-
['-p', '
|
| 203 |
{ timeout: 15000, preserveOutputOnError: true },
|
| 204 |
)
|
| 205 |
-
if (
|
| 206 |
-
result =
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 210 |
-
if (!result) {
|
| 211 |
-
const half = await execFileNoThrow(
|
| 212 |
-
timgPath,
|
| 213 |
-
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 214 |
-
{ timeout: 15000, preserveOutputOnError: true },
|
| 215 |
-
)
|
| 216 |
-
if (half.code === 0 && half.stdout) {
|
| 217 |
-
result = half.stdout
|
| 218 |
-
}
|
| 219 |
}
|
| 220 |
}
|
| 221 |
} finally {
|
|
@@ -232,63 +205,6 @@ export async function renderImageWithTimg(
|
|
| 232 |
}
|
| 233 |
}
|
| 234 |
|
| 235 |
-
/**
|
| 236 |
-
* Determine the number of terminal rows an image occupies when rendered.
|
| 237 |
-
* Runs `timg -p q` (block-mode) and counts the output lines.
|
| 238 |
-
* Used to reserve layout space in Ink's virtual DOM when the Kitty
|
| 239 |
-
* protocol is used for native-quality rendering.
|
| 240 |
-
*
|
| 241 |
-
* @param buffer - The raw image buffer
|
| 242 |
-
* @param format - Image format (png, jpeg, gif, webp)
|
| 243 |
-
* @param columns - Optional terminal width in character columns (default: stdout.columns)
|
| 244 |
-
* @returns Number of terminal rows, or 0 on failure
|
| 245 |
-
*/
|
| 246 |
-
export function getImageRowsCount(
|
| 247 |
-
buffer: Buffer,
|
| 248 |
-
format: string,
|
| 249 |
-
columns?: number,
|
| 250 |
-
): number {
|
| 251 |
-
try {
|
| 252 |
-
const timgPath = whichSync('timg')
|
| 253 |
-
if (!timgPath) return 0
|
| 254 |
-
|
| 255 |
-
const cols = columns ?? process.stdout.columns ?? 80
|
| 256 |
-
const termRows = process.stdout.rows ?? 40
|
| 257 |
-
const maxRows = Math.max(10, Math.floor(termRows * 0.5))
|
| 258 |
-
const tmpDir = mkdtempSync(join(tmpdir(), 'codev-timg-rows-'))
|
| 259 |
-
const ext = format === 'jpeg' ? 'jpg' : format
|
| 260 |
-
const tmpFile = join(tmpDir, `image.${ext}`)
|
| 261 |
-
|
| 262 |
-
try {
|
| 263 |
-
writeFileSync(tmpFile, buffer)
|
| 264 |
-
|
| 265 |
-
const stdout = execFileSync(
|
| 266 |
-
timgPath,
|
| 267 |
-
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 268 |
-
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
// Count non-empty lines; strip ANSI escape sequences first so we
|
| 272 |
-
// only count lines that actually contain block-mode content.
|
| 273 |
-
const lines = stdout
|
| 274 |
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
| 275 |
-
.replace(/\x1b\[?25[hl]/g, '')
|
| 276 |
-
.split('\n')
|
| 277 |
-
.filter(l => l.trim().length > 0)
|
| 278 |
-
|
| 279 |
-
return lines.length
|
| 280 |
-
} finally {
|
| 281 |
-
try {
|
| 282 |
-
rmSync(tmpDir, { recursive: true, force: true })
|
| 283 |
-
} catch {
|
| 284 |
-
// ignore cleanup errors
|
| 285 |
-
}
|
| 286 |
-
}
|
| 287 |
-
} catch {
|
| 288 |
-
return 0
|
| 289 |
-
}
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
/**
|
| 293 |
* Synchronous version of renderImageWithTimg. Blocks the event loop while
|
| 294 |
* spawning timg, which prevents Ink from rendering between the tool call
|
|
@@ -301,7 +217,6 @@ export function renderImageWithTimgSync(
|
|
| 301 |
format: string,
|
| 302 |
columns?: number,
|
| 303 |
rows?: number,
|
| 304 |
-
mode: TimgMode = 'blocks',
|
| 305 |
): string | null {
|
| 306 |
try {
|
| 307 |
const timgPath = whichSync('timg')
|
|
@@ -318,43 +233,29 @@ export function renderImageWithTimgSync(
|
|
| 318 |
try {
|
| 319 |
writeFileSync(tmpFile, buffer)
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
try {
|
| 336 |
const stdout = execFileSync(
|
| 337 |
timgPath,
|
| 338 |
-
['-p', '
|
| 339 |
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 340 |
)
|
| 341 |
if (stdout) result = stdout
|
| 342 |
} catch {
|
| 343 |
-
//
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 347 |
-
if (!result) {
|
| 348 |
-
try {
|
| 349 |
-
const stdout = execFileSync(
|
| 350 |
-
timgPath,
|
| 351 |
-
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 352 |
-
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 353 |
-
)
|
| 354 |
-
if (stdout) result = stdout
|
| 355 |
-
} catch {
|
| 356 |
-
// ignored
|
| 357 |
-
}
|
| 358 |
}
|
| 359 |
}
|
| 360 |
} finally {
|
|
|
|
| 58 |
return 'kitty'
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return null
|
| 62 |
}
|
| 63 |
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
/**
|
| 136 |
+
* Render an image using the `timg` utility with Unicode block characters.
|
| 137 |
+
* Works in any terminal that supports Unicode and 24-bit color (virtually all
|
| 138 |
+
* modern terminals). Falls back gracefully if timg is not installed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
*
|
| 140 |
+
* The image is rendered using quarter-block characters ("pixelation q") for
|
| 141 |
+
* the best quality-to-compatibility ratio. If timg fails, half-blocks are
|
| 142 |
+
* tried as a fallback.
|
| 143 |
*
|
| 144 |
* @param buffer - The raw image buffer (decoded)
|
| 145 |
* @param format - Image format (png, jpeg, gif, webp)
|
| 146 |
+
* @param columns - Optional terminal width in character columns (auto-detected)
|
| 147 |
+
* @param rows - Optional terminal height in character rows (auto-detected)
|
| 148 |
+
* @returns ANSI escape sequence string for rendering, or null on failure
|
|
|
|
| 149 |
*/
|
| 150 |
export async function renderImageWithTimg(
|
| 151 |
buffer: Buffer,
|
| 152 |
format: string,
|
| 153 |
columns?: number,
|
| 154 |
rows?: number,
|
|
|
|
| 155 |
): Promise<string | null> {
|
| 156 |
try {
|
| 157 |
const timgPath = await which('timg')
|
|
|
|
| 170 |
try {
|
| 171 |
writeFileSync(tmpFile, buffer)
|
| 172 |
|
| 173 |
+
// Try quarter blocks first (4 pixels per cell, better quality)
|
| 174 |
+
const quarter = await execFileNoThrow(
|
| 175 |
+
timgPath,
|
| 176 |
+
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 177 |
+
{ timeout: 15000, preserveOutputOnError: true },
|
| 178 |
+
)
|
| 179 |
+
if (quarter.code === 0 && quarter.stdout) {
|
| 180 |
+
result = quarter.stdout
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 184 |
+
if (!result) {
|
| 185 |
+
const half = await execFileNoThrow(
|
| 186 |
timgPath,
|
| 187 |
+
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 188 |
{ timeout: 15000, preserveOutputOnError: true },
|
| 189 |
)
|
| 190 |
+
if (half.code === 0 && half.stdout) {
|
| 191 |
+
result = half.stdout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
| 193 |
}
|
| 194 |
} finally {
|
|
|
|
| 205 |
}
|
| 206 |
}
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
/**
|
| 209 |
* Synchronous version of renderImageWithTimg. Blocks the event loop while
|
| 210 |
* spawning timg, which prevents Ink from rendering between the tool call
|
|
|
|
| 217 |
format: string,
|
| 218 |
columns?: number,
|
| 219 |
rows?: number,
|
|
|
|
| 220 |
): string | null {
|
| 221 |
try {
|
| 222 |
const timgPath = whichSync('timg')
|
|
|
|
| 233 |
try {
|
| 234 |
writeFileSync(tmpFile, buffer)
|
| 235 |
|
| 236 |
+
// Try quarter blocks first (4 pixels per cell, better quality)
|
| 237 |
+
try {
|
| 238 |
+
const stdout = execFileSync(
|
| 239 |
+
timgPath,
|
| 240 |
+
['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 241 |
+
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 242 |
+
)
|
| 243 |
+
if (stdout) result = stdout
|
| 244 |
+
} catch {
|
| 245 |
+
// fall through to half blocks
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Fallback to half blocks (2 pixels per cell, max compatibility)
|
| 249 |
+
if (!result) {
|
| 250 |
try {
|
| 251 |
const stdout = execFileSync(
|
| 252 |
timgPath,
|
| 253 |
+
['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
|
| 254 |
{ encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
|
| 255 |
)
|
| 256 |
if (stdout) result = stdout
|
| 257 |
} catch {
|
| 258 |
+
// ignored
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
}
|
| 261 |
} finally {
|