chenbhao commited on
Commit
f79a720
·
1 Parent(s): af27eec

feat: image in ui

Browse files
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 terminated by BEL (0x07) or ST (ESC \):
715
- // - OSC: ESC ] ... (Operating System Command)
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
- // BEL (0x07) terminates the sequence
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, { useContext, useEffect, useRef } from '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 when the terminal supports it, ' +
153
- 'or falls back to timg Unicode-block rendering for universal compatibility. ' +
154
- 'Supports both URLs (https://) and local file paths. Images are shown inline above the tool result.',
 
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
- // Kitty protocol output rendered via writeRaw (out-of-band) for native
245
- // terminal image rendering. The text message tells Ink the image exists
246
- // so it doesn't leave a blank gap.
247
- if (content.kittyOutput) {
248
- return (
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 === 0) {
263
- return <Text dimColor>{content.message}</Text>
 
 
 
 
 
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
- const result = await loadImage(url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
- if (!result) {
293
  return {
294
  data: {
295
  success: false,
@@ -298,24 +237,18 @@ export const ImageShowTool = buildTool({
298
  }
299
  }
300
 
301
- const { buffer, format } = result
302
-
303
  const mediaType =
304
- format === 'jpeg'
305
- ? 'image/jpeg'
306
- : format === 'gif'
307
- ? 'image/gif'
308
- : format === 'webp'
309
- ? 'image/webp'
310
  : 'image/png'
311
 
312
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
313
 
314
- // Try native Kitty protocol rendering via timg when the terminal supports it.
315
- // timg's -p kitty produces Kitty protocol escape sequences natively,
316
- // which are then wrapped for tmux passthrough if needed and written
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 via timg Kitty protocol')
328
  }
329
  }
330
 
331
- if (!kittyOutput) {
332
- timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
 
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 {