feat: image in ui
Browse files- src/ink/output.ts +51 -13
- src/tools/ImageShowTool/ImageShowTool.tsx +81 -147
src/ink/output.ts
CHANGED
|
@@ -704,29 +704,67 @@ function writeLineToScreen(
|
|
| 704 |
break
|
| 705 |
}
|
| 706 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
} else if (
|
| 708 |
nextChar === ']' ||
|
| 709 |
nextChar === 'P' ||
|
| 710 |
-
nextChar === '_' ||
|
| 711 |
nextChar === '^' ||
|
| 712 |
nextChar === 'X'
|
| 713 |
) {
|
| 714 |
-
// String-based sequences
|
| 715 |
-
//
|
| 716 |
-
// - DCS: ESC P ... (Device Control String)
|
| 717 |
-
// - APC: ESC _ ... (Application Program Command)
|
| 718 |
-
// - PM: ESC ^ ... (Privacy Message)
|
| 719 |
-
// - SOS: ESC X ... (Start of String)
|
| 720 |
charIdx++ // skip the introducer char
|
| 721 |
while (charIdx < characters.length - 1) {
|
| 722 |
charIdx++
|
| 723 |
const c = characters[charIdx]?.value
|
| 724 |
-
|
| 725 |
-
if (c === '\x07') {
|
| 726 |
-
break
|
| 727 |
-
}
|
| 728 |
-
// ST (String Terminator) is ESC \
|
| 729 |
-
// When we see ESC, check if next char is backslash
|
| 730 |
if (c === '\x1b') {
|
| 731 |
const nextC = characters[charIdx + 1]?.value
|
| 732 |
if (nextC === '\\') {
|
|
|
|
| 704 |
break
|
| 705 |
}
|
| 706 |
}
|
| 707 |
+
} else if (nextChar === '_') {
|
| 708 |
+
// APC (Application Program Command): pass through to screen
|
| 709 |
+
// buffer. Terminal receives the raw \x1b_G...\x1b\\ sequence
|
| 710 |
+
// and interprets it as a Kitty graphics protocol command,
|
| 711 |
+
// rendering the native image at this position within Ink's
|
| 712 |
+
// layout — no \x1b[H or writeRaw positioning needed.
|
| 713 |
+
setCellAt(screen, offsetX, y, {
|
| 714 |
+
char: '\x1b',
|
| 715 |
+
styleId: stylePool.none,
|
| 716 |
+
width: CellWidth.Narrow,
|
| 717 |
+
hyperlink: undefined,
|
| 718 |
+
})
|
| 719 |
+
offsetX++
|
| 720 |
+
setCellAt(screen, offsetX, y, {
|
| 721 |
+
char: '_',
|
| 722 |
+
styleId: stylePool.none,
|
| 723 |
+
width: CellWidth.Narrow,
|
| 724 |
+
hyperlink: undefined,
|
| 725 |
+
})
|
| 726 |
+
offsetX++
|
| 727 |
+
charIdx++ // skip past ESC
|
| 728 |
+
while (charIdx < characters.length - 1) {
|
| 729 |
+
charIdx++
|
| 730 |
+
const c = characters[charIdx]?.value
|
| 731 |
+
if (c === undefined) break
|
| 732 |
+
setCellAt(screen, offsetX, y, {
|
| 733 |
+
char: c,
|
| 734 |
+
styleId: stylePool.none,
|
| 735 |
+
width: CellWidth.Narrow,
|
| 736 |
+
hyperlink: undefined,
|
| 737 |
+
})
|
| 738 |
+
offsetX++
|
| 739 |
+
if (c === '\x07') break
|
| 740 |
+
if (c === '\x1b') {
|
| 741 |
+
const nextC = characters[charIdx + 1]?.value
|
| 742 |
+
if (nextC === '\\') {
|
| 743 |
+
charIdx++
|
| 744 |
+
setCellAt(screen, offsetX, y, {
|
| 745 |
+
char: '\\',
|
| 746 |
+
styleId: stylePool.none,
|
| 747 |
+
width: CellWidth.Narrow,
|
| 748 |
+
hyperlink: undefined,
|
| 749 |
+
})
|
| 750 |
+
offsetX++
|
| 751 |
+
break
|
| 752 |
+
}
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
} else if (
|
| 756 |
nextChar === ']' ||
|
| 757 |
nextChar === 'P' ||
|
|
|
|
| 758 |
nextChar === '^' ||
|
| 759 |
nextChar === 'X'
|
| 760 |
) {
|
| 761 |
+
// String-based sequences (OSC, DCS, PM, SOS) terminated by
|
| 762 |
+
// BEL (0x07) or ST (ESC \): skip as before.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
charIdx++ // skip the introducer char
|
| 764 |
while (charIdx < characters.length - 1) {
|
| 765 |
charIdx++
|
| 766 |
const c = characters[charIdx]?.value
|
| 767 |
+
if (c === '\x07') break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
if (c === '\x1b') {
|
| 769 |
const nextC = characters[charIdx + 1]?.value
|
| 770 |
if (nextC === '\\') {
|
src/tools/ImageShowTool/ImageShowTool.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 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 { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
|
| 7 |
import { wrapForMultiplexer } from '../../ink/termio/osc.js'
|
| 8 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 9 |
import { logForDebugging } from '../../utils/debug.js'
|
|
@@ -40,118 +39,18 @@ function detectFormat(url: string): string {
|
|
| 40 |
return 'png'
|
| 41 |
}
|
| 42 |
|
| 43 |
-
/** Read a local file and return its buffer + detected format */
|
| 44 |
-
async function readLocalImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 45 |
-
try {
|
| 46 |
-
const format = detectFormat(url)
|
| 47 |
-
const buffer = await readFile(url)
|
| 48 |
-
return { buffer, format }
|
| 49 |
-
} catch {
|
| 50 |
-
return null
|
| 51 |
-
}
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
/** Download an image from URL and return its buffer + detected format */
|
| 55 |
-
async function fetchImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 56 |
-
try {
|
| 57 |
-
const response = await fetch(url, {
|
| 58 |
-
headers: {
|
| 59 |
-
'User-Agent': 'Mozilla/5.0 (compatible; VersperClaw/1.0)',
|
| 60 |
-
},
|
| 61 |
-
redirect: 'follow',
|
| 62 |
-
})
|
| 63 |
-
|
| 64 |
-
if (!response.ok) {
|
| 65 |
-
logForDebugging(`ImageShow: HTTP ${response.status} for ${url}`)
|
| 66 |
-
return null
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
const contentType = response.headers.get('content-type') ?? ''
|
| 70 |
-
const format = detectFormat(url !== contentType ? url : contentType)
|
| 71 |
-
|
| 72 |
-
const reader = response.body?.getReader()
|
| 73 |
-
if (!reader) return null
|
| 74 |
-
|
| 75 |
-
const chunks: Uint8Array[] = []
|
| 76 |
-
let totalSize = 0
|
| 77 |
-
const MAX_SIZE = 10_000_000
|
| 78 |
-
|
| 79 |
-
while (true) {
|
| 80 |
-
const { done, value } = await reader.read()
|
| 81 |
-
if (done) break
|
| 82 |
-
totalSize += value.byteLength
|
| 83 |
-
if (totalSize > MAX_SIZE) {
|
| 84 |
-
logForDebugging(`ImageShow: image too large (${totalSize} bytes) for ${url}`)
|
| 85 |
-
reader.cancel()
|
| 86 |
-
return null
|
| 87 |
-
}
|
| 88 |
-
chunks.push(value)
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
const combinedLength = chunks.reduce((acc, c) => acc + c.byteLength, 0)
|
| 92 |
-
const combined = new Uint8Array(combinedLength)
|
| 93 |
-
let offset = 0
|
| 94 |
-
for (const chunk of chunks) {
|
| 95 |
-
combined.set(chunk, offset)
|
| 96 |
-
offset += chunk.byteLength
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
return { buffer: Buffer.from(combined.buffer), format }
|
| 100 |
-
} catch (err) {
|
| 101 |
-
logForDebugging(`ImageShow: fetch error ${err} for ${url}`)
|
| 102 |
-
return null
|
| 103 |
-
}
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
/** Load image: local file or remote URL */
|
| 107 |
-
async function loadImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
|
| 108 |
-
// Expand ~ to home directory
|
| 109 |
-
const normalizedUrl = url.startsWith('~')
|
| 110 |
-
? url.replace(/^~(?=$|\/)/, homedir())
|
| 111 |
-
: url
|
| 112 |
-
if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
|
| 113 |
-
const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
|
| 114 |
-
return readLocalImage(path)
|
| 115 |
-
}
|
| 116 |
-
return fetchImage(normalizedUrl)
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
function getToolUseSummary(input: Partial<Input>): string | null {
|
| 120 |
return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
|
| 121 |
}
|
| 122 |
|
| 123 |
-
/**
|
| 124 |
-
* Renders a pre-generated Kitty protocol image sequence by writing it
|
| 125 |
-
* directly to stdout via writeRaw (bypassing Ink's virtual DOM so the
|
| 126 |
-
* native image protocol reaches the terminal cleanly).
|
| 127 |
-
*/
|
| 128 |
-
function TerminalImageDisplay({
|
| 129 |
-
sequence,
|
| 130 |
-
message,
|
| 131 |
-
}: {
|
| 132 |
-
sequence: string
|
| 133 |
-
message: string
|
| 134 |
-
}): React.ReactNode {
|
| 135 |
-
const writeRaw = useContext(TerminalWriteContext)
|
| 136 |
-
const renderedRef = useRef(false)
|
| 137 |
-
|
| 138 |
-
useEffect(() => {
|
| 139 |
-
if (renderedRef.current || !writeRaw) return
|
| 140 |
-
renderedRef.current = true
|
| 141 |
-
writeRaw(sequence + '\n')
|
| 142 |
-
logForDebugging('ImageShow: displayed via Kitty protocol (timg)')
|
| 143 |
-
}, [sequence, writeRaw])
|
| 144 |
-
|
| 145 |
-
return <Text dimColor>{message}</Text>
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
export const ImageShowTool = buildTool({
|
| 149 |
name: IMAGE_TOOL_NAME,
|
| 150 |
description:
|
| 151 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 152 |
-
'Uses the Kitty graphics protocol via timg
|
| 153 |
-
'
|
| 154 |
-
'
|
|
|
|
| 155 |
|
| 156 |
getToolUseSummary,
|
| 157 |
getActivityDescription(input) {
|
|
@@ -229,8 +128,6 @@ export const ImageShowTool = buildTool({
|
|
| 229 |
content: {
|
| 230 |
success: boolean
|
| 231 |
message: string
|
| 232 |
-
base64?: string
|
| 233 |
-
format?: string
|
| 234 |
kittyOutput?: string
|
| 235 |
timgOutput?: string
|
| 236 |
},
|
|
@@ -241,31 +138,22 @@ export const ImageShowTool = buildTool({
|
|
| 241 |
return null
|
| 242 |
}
|
| 243 |
|
| 244 |
-
//
|
| 245 |
-
//
|
| 246 |
-
//
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
<TerminalImageDisplay
|
| 250 |
-
sequence={content.kittyOutput}
|
| 251 |
-
message={content.message}
|
| 252 |
-
/>
|
| 253 |
-
)
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
// timg block-mode output rendered in-band via RawAnsi so Ink knows the
|
| 257 |
-
// image dimensions and its virtual cursor stays in sync with the terminal.
|
| 258 |
if (content.timgOutput) {
|
| 259 |
-
// Strip cursor hide/show sequences that timg adds
|
| 260 |
const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
|
| 261 |
const lines = cleaned.split('\n').filter(l => l.length > 0)
|
| 262 |
-
if (lines.length
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
-
// Measure visible width (strip ANSI escape codes)
|
| 266 |
-
const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
| 267 |
-
const width = Math.max(...lines.map(l => ansiStrip(l).length))
|
| 268 |
-
return <RawAnsi lines={lines} width={width} />
|
| 269 |
}
|
| 270 |
|
| 271 |
return <Text dimColor>{content.message}</Text>
|
|
@@ -287,9 +175,60 @@ export const ImageShowTool = buildTool({
|
|
| 287 |
|
| 288 |
logForDebugging(`ImageShow: loading ${url}`)
|
| 289 |
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
-
if (!
|
| 293 |
return {
|
| 294 |
data: {
|
| 295 |
success: false,
|
|
@@ -298,24 +237,18 @@ export const ImageShowTool = buildTool({
|
|
| 298 |
}
|
| 299 |
}
|
| 300 |
|
| 301 |
-
const { buffer, format } =
|
| 302 |
-
|
| 303 |
const mediaType =
|
| 304 |
-
format === 'jpeg'
|
| 305 |
-
? 'image/
|
| 306 |
-
|
| 307 |
-
? 'image/gif'
|
| 308 |
-
: format === 'webp'
|
| 309 |
-
? 'image/webp'
|
| 310 |
: 'image/png'
|
| 311 |
|
| 312 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 313 |
|
| 314 |
-
//
|
| 315 |
-
//
|
| 316 |
-
//
|
| 317 |
-
// directly to stdout via writeRaw in TerminalImageDisplay.
|
| 318 |
-
// Falls back to Unicode block rendering for universal compatibility.
|
| 319 |
const protocol = detectImageProtocol()
|
| 320 |
let kittyOutput: string | undefined
|
| 321 |
let timgOutput: string | undefined
|
|
@@ -324,12 +257,13 @@ export const ImageShowTool = buildTool({
|
|
| 324 |
const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
|
| 325 |
if (rawKitty) {
|
| 326 |
kittyOutput = wrapForMultiplexer(rawKitty)
|
| 327 |
-
logForDebugging('ImageShow: generated
|
| 328 |
}
|
| 329 |
}
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
|
|
|
| 333 |
}
|
| 334 |
|
| 335 |
return {
|
|
|
|
| 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 { wrapForMultiplexer } from '../../ink/termio/osc.js'
|
| 7 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 8 |
import { logForDebugging } from '../../utils/debug.js'
|
|
|
|
| 39 |
return 'png'
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
function getToolUseSummary(input: Partial<Input>): string | null {
|
| 43 |
return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
export const ImageShowTool = buildTool({
|
| 47 |
name: IMAGE_TOOL_NAME,
|
| 48 |
description:
|
| 49 |
'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
|
| 50 |
+
'Uses the Kitty graphics protocol via timg for native quality when the ' +
|
| 51 |
+
'terminal supports it, with a Unicode-block fallback for universal compatibility. ' +
|
| 52 |
+
'The block-mode output is rendered within Ink\'s virtual DOM so the cursor ' +
|
| 53 |
+
'stays in sync and the image scrolls with conversation content.',
|
| 54 |
|
| 55 |
getToolUseSummary,
|
| 56 |
getActivityDescription(input) {
|
|
|
|
| 128 |
content: {
|
| 129 |
success: boolean
|
| 130 |
message: string
|
|
|
|
|
|
|
| 131 |
kittyOutput?: string
|
| 132 |
timgOutput?: string
|
| 133 |
},
|
|
|
|
| 138 |
return null
|
| 139 |
}
|
| 140 |
|
| 141 |
+
// Block-mode placeholder rendered in-band via RawAnsi so Ink knows the
|
| 142 |
+
// image dimensions and its virtual cursor stays in sync. The Kitty protocol
|
| 143 |
+
// APC escape sequence (when available) is prepended to the first block-mode
|
| 144 |
+
// line; output.ts's writeLineToScreen passes APC through to the screen
|
| 145 |
+
// buffer, so the terminal receives the native image at this position.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
if (content.timgOutput) {
|
|
|
|
| 147 |
const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
|
| 148 |
const lines = cleaned.split('\n').filter(l => l.length > 0)
|
| 149 |
+
if (lines.length > 0) {
|
| 150 |
+
const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
| 151 |
+
const width = Math.max(...lines.map(l => ansiStrip(l).length))
|
| 152 |
+
if (content.kittyOutput) {
|
| 153 |
+
lines[0] = content.kittyOutput + lines[0]
|
| 154 |
+
}
|
| 155 |
+
return <RawAnsi lines={lines} width={width} />
|
| 156 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
return <Text dimColor>{content.message}</Text>
|
|
|
|
| 175 |
|
| 176 |
logForDebugging(`ImageShow: loading ${url}`)
|
| 177 |
|
| 178 |
+
// Expand ~ to home directory for local files
|
| 179 |
+
const normalizedUrl = url.startsWith('~')
|
| 180 |
+
? url.replace(/^~(?=$|\/)/, homedir())
|
| 181 |
+
: url
|
| 182 |
+
|
| 183 |
+
let imgResult: { buffer: Buffer; format: string } | null = null
|
| 184 |
+
|
| 185 |
+
if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
|
| 186 |
+
const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
|
| 187 |
+
try {
|
| 188 |
+
const format = detectFormat(path)
|
| 189 |
+
const buffer = await readFile(path)
|
| 190 |
+
imgResult = { buffer, format }
|
| 191 |
+
} catch {
|
| 192 |
+
// fall through
|
| 193 |
+
}
|
| 194 |
+
} else {
|
| 195 |
+
try {
|
| 196 |
+
const response = await fetch(normalizedUrl, {
|
| 197 |
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; VersperClaw/1.0)' },
|
| 198 |
+
redirect: 'follow',
|
| 199 |
+
})
|
| 200 |
+
if (response.ok) {
|
| 201 |
+
const contentType = response.headers.get('content-type') ?? ''
|
| 202 |
+
const format = detectFormat(normalizedUrl !== contentType ? normalizedUrl : contentType)
|
| 203 |
+
const reader = response.body?.getReader()
|
| 204 |
+
if (reader) {
|
| 205 |
+
const chunks: Uint8Array[] = []
|
| 206 |
+
let totalSize = 0
|
| 207 |
+
while (true) {
|
| 208 |
+
const { done, value } = await reader.read()
|
| 209 |
+
if (done) break
|
| 210 |
+
totalSize += value.byteLength
|
| 211 |
+
if (totalSize > 10_000_000) {
|
| 212 |
+
reader.cancel()
|
| 213 |
+
break
|
| 214 |
+
}
|
| 215 |
+
chunks.push(value)
|
| 216 |
+
}
|
| 217 |
+
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.byteLength, 0))
|
| 218 |
+
let offset = 0
|
| 219 |
+
for (const chunk of chunks) {
|
| 220 |
+
combined.set(chunk, offset)
|
| 221 |
+
offset += chunk.byteLength
|
| 222 |
+
}
|
| 223 |
+
imgResult = { buffer: Buffer.from(combined.buffer), format }
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
} catch (err) {
|
| 227 |
+
logForDebugging(`ImageShow: fetch error ${err} for ${normalizedUrl}`)
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
|
| 231 |
+
if (!imgResult) {
|
| 232 |
return {
|
| 233 |
data: {
|
| 234 |
success: false,
|
|
|
|
| 237 |
}
|
| 238 |
}
|
| 239 |
|
| 240 |
+
const { buffer, format } = imgResult
|
|
|
|
| 241 |
const mediaType =
|
| 242 |
+
format === 'jpeg' ? 'image/jpeg'
|
| 243 |
+
: format === 'gif' ? 'image/gif'
|
| 244 |
+
: format === 'webp' ? 'image/webp'
|
|
|
|
|
|
|
|
|
|
| 245 |
: 'image/png'
|
| 246 |
|
| 247 |
logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
|
| 248 |
|
| 249 |
+
// Hybrid rendering:
|
| 250 |
+
// 1. Kitty protocol via timg for native-quality overlay (when supported)
|
| 251 |
+
// 2. Block-mode via timg for Ink DOM cursor tracking (always, as fallback)
|
|
|
|
|
|
|
| 252 |
const protocol = detectImageProtocol()
|
| 253 |
let kittyOutput: string | undefined
|
| 254 |
let timgOutput: string | undefined
|
|
|
|
| 257 |
const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
|
| 258 |
if (rawKitty) {
|
| 259 |
kittyOutput = wrapForMultiplexer(rawKitty)
|
| 260 |
+
logForDebugging('ImageShow: generated Kitty protocol output via timg')
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
+
timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
|
| 265 |
+
if (timgOutput) {
|
| 266 |
+
logForDebugging('ImageShow: generated block-mode output via timg')
|
| 267 |
}
|
| 268 |
|
| 269 |
return {
|