chenbhao Claude Big Pickle commited on
Commit
184c51a
Β·
1 Parent(s): e7b07b3

feat: integrate ink-picture rendering into ImageShowTool, remove InlineImage

Browse files

- Delete InlineImage.tsx (passive Markdown inline image component)
- Remove InlineImage import and image token handling from Markdown.tsx
- Rewrite ImageShowTool.call() to render images directly via ink-picture
renderers with terminal protocol detection (kitty/sixel/iterm2/halfBlock/
braille/ascii)

Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>

src/components/InlineImage.tsx DELETED
@@ -1,60 +0,0 @@
1
- import React from 'react'
2
- import Image, { InkPictureProvider } from 'src/ink-picture/index.ts'
3
- import Link from '../ink/components/Link.js'
4
- import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'
5
- import { Box, Text } from '../ink.js'
6
-
7
- type Props = {
8
- url: string
9
- alt?: string
10
- }
11
-
12
- function filenameFromUrl(url: string): string {
13
- try {
14
- const pathname = new URL(url).pathname
15
- const parts = pathname.split('/')
16
- const last = parts[parts.length - 1]
17
- return last || 'image'
18
- } catch {
19
- return 'image'
20
- }
21
- }
22
-
23
- /**
24
- * Renders an inline image in the terminal using ink-picture, which
25
- * auto-detects the best available protocol (Kitty, Sixel, iTerm2,
26
- * HalfBlock, Braille, ASCII) and handles loading/error states.
27
- *
28
- * A clickable [image_name] link is shown below the image as a stable
29
- * fallback for screen readers and quick URL access.
30
- */
31
- export function InlineImage({ url, alt }: Props) {
32
- const displayName = alt || filenameFromUrl(url)
33
-
34
- const cols = process.stdout.columns ?? 80
35
- const rows = process.stdout.rows ?? 40
36
- const imgWidth = Math.floor(cols * 0.6)
37
- const imgHeight = Math.floor(rows * 0.4)
38
-
39
- const link = supportsHyperlinks() ? (
40
- <Link url={url}>
41
- <Text dimColor>{`[${displayName}]`}</Text>
42
- </Link>
43
- ) : (
44
- <Text dimColor>{`[${displayName}: ${url}]`}</Text>
45
- )
46
-
47
- return (
48
- <Box flexDirection="column">
49
- <InkPictureProvider>
50
- <Image
51
- src={url}
52
- width={imgWidth}
53
- height={imgHeight}
54
- alt={alt}
55
- />
56
- </InkPictureProvider>
57
- {link}
58
- </Box>
59
- )
60
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/Markdown.tsx CHANGED
@@ -7,7 +7,6 @@ import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight
7
  import { hashContent } from '../utils/hash.js';
8
  import { configureMarked, formatToken } from '../utils/markdown.js';
9
  import { stripPromptXMLTags } from '../utils/messages.js';
10
- import { InlineImage } from './InlineImage.js';
11
  import { MarkdownTable } from './MarkdownTable.js';
12
  type Props = {
13
  children: string;
@@ -145,10 +144,6 @@ function MarkdownBody(t0) {
145
  if (token.type === "table") {
146
  flushNonTableContent();
147
  elements.push(<MarkdownTable key={elements.length} token={token as Tokens.Table} highlight={highlight} />);
148
- } else if (token.type === "image") {
149
- flushNonTableContent();
150
- console.error(`[MarkdownBody] Found image token: href=${token.href} text=${token.text}`)
151
- elements.push(<InlineImage key={elements.length} url={token.href} alt={token.text} />);
152
  } else {
153
  nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight);
154
  }
 
7
  import { hashContent } from '../utils/hash.js';
8
  import { configureMarked, formatToken } from '../utils/markdown.js';
9
  import { stripPromptXMLTags } from '../utils/messages.js';
 
10
  import { MarkdownTable } from './MarkdownTable.js';
11
  type Props = {
12
  children: string;
 
144
  if (token.type === "table") {
145
  flushNonTableContent();
146
  elements.push(<MarkdownTable key={elements.length} token={token as Tokens.Table} highlight={highlight} />);
 
 
 
 
147
  } else {
148
  nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight);
149
  }
src/tools/ImageShowTool/ImageShowTool.ts CHANGED
@@ -10,6 +10,17 @@ import {
10
  renderToolResultMessage,
11
  renderToolUseMessage,
12
  } from './UI.js'
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  // ── Utility functions (kept for standalone UI and external use) ──
15
 
@@ -59,6 +70,109 @@ export function calculateDimensions(
59
  };
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  // ── Tool definition ──
63
 
64
  const inputSchema = lazySchema(() =>
@@ -144,12 +258,16 @@ export const ImageShowTool = buildTool({
144
  async call({ src }) {
145
  try {
146
  const image = await loadImage(src)
 
147
  const dims = calculateDimensions(
148
  image.bitmap.width,
149
  image.bitmap.height,
150
- 80,
151
  )
152
- void dims
 
 
 
153
  return {
154
  data: {
155
  src,
@@ -179,4 +297,4 @@ export const ImageShowTool = buildTool({
179
  ],
180
  }
181
  },
182
- } satisfies ToolDef<InputSchema, Output>)
 
10
  renderToolResultMessage,
11
  renderToolUseMessage,
12
  } from './UI.js'
13
+ import type { PixelData, PngData } from "../../ink-picture/renderers/types.js";
14
+ import {
15
+ makeKittyTransmitChunks,
16
+ makeKittyPlacement,
17
+ } from "../../ink-picture/renderers/kitty.js";
18
+ import { renderSixel } from "../../ink-picture/renderers/sixel.js";
19
+ import { renderITerm2 } from "../../ink-picture/renderers/iterm2.js";
20
+ import { renderHalfBlock } from "../../ink-picture/renderers/halfBlock.js";
21
+ import { renderBraille } from "../../ink-picture/renderers/braille.js";
22
+ import { renderAscii } from "../../ink-picture/renderers/ascii.js";
23
+ import generateKittyId from "../../ink-picture/utils/generateKittyId.js";
24
 
25
  // ── Utility functions (kept for standalone UI and external use) ──
26
 
 
70
  };
71
  }
72
 
73
+ // ── Protocol detection ──
74
+
75
+ type Protocol = 'kitty' | 'sixel' | 'iterm2' | 'halfBlock' | 'braille' | 'ascii'
76
+
77
+ function detectProtocol(): Protocol {
78
+ const termProgram = process.env.TERM_PROGRAM
79
+ const term = process.env.TERM
80
+
81
+ // Kitty protocol β€” stores image in terminal GPU memory, survives screen clears
82
+ if (termProgram === 'ghostty' || termProgram === 'kitty' || term?.includes('kitty')) {
83
+ return 'kitty'
84
+ }
85
+
86
+ // Sixel
87
+ if (term?.includes('sixel') || termProgram === 'ghostty' || termProgram === 'vscode') {
88
+ return 'sixel'
89
+ }
90
+
91
+ // iTerm2 inline images
92
+ if (termProgram === 'iTerm.app' || termProgram === 'WezTerm' || termProgram === 'WarpTerminal') {
93
+ return 'iterm2'
94
+ }
95
+
96
+ // Text-based fallback
97
+ const colorterm = process.env.COLORTERM
98
+ const supportsColor = colorterm === 'truecolor' || !!colorterm ||
99
+ term?.includes('truecolor') || term?.includes('256color')
100
+ const supportsUnicode = true // all modern terminals
101
+
102
+ if (supportsUnicode && supportsColor) return 'halfBlock'
103
+ if (supportsUnicode) return 'braille'
104
+ return 'ascii'
105
+ }
106
+
107
+ type JimpInstance = Awaited<ReturnType<typeof Jimp.read>>
108
+
109
+ async function renderImage(image: JimpInstance, dims: ImageDimensions): Promise<void> {
110
+ const protocol = detectProtocol()
111
+
112
+ image.cover({ w: dims.pixelWidth, h: dims.pixelHeight })
113
+
114
+ const pixels: PixelData = {
115
+ data: image.bitmap.data,
116
+ info: { width: image.bitmap.width, height: image.bitmap.height, channels: 4 },
117
+ }
118
+
119
+ switch (protocol) {
120
+ case 'kitty': {
121
+ const pngBuf = await image.getBuffer("image/png")
122
+ const b64 = pngBuf.toString('base64')
123
+ const imgId = generateKittyId()
124
+ const chunks = makeKittyTransmitChunks(imgId, b64)
125
+ for (const chunk of chunks) {
126
+ process.stdout.write(chunk)
127
+ }
128
+ process.stdout.write('\n')
129
+ process.stdout.write(makeKittyPlacement(imgId, 1, dims.width, dims.height))
130
+ process.stdout.write('\n')
131
+ break
132
+ }
133
+ case 'sixel': {
134
+ const output = renderSixel(pixels)
135
+ process.stdout.write(output)
136
+ process.stdout.write('\n')
137
+ break
138
+ }
139
+ case 'iterm2': {
140
+ const pngBuf = await image.getBuffer("image/png")
141
+ const pngData: PngData = {
142
+ data: pngBuf,
143
+ info: { width: image.bitmap.width, height: image.bitmap.height },
144
+ }
145
+ const output = renderITerm2(pngData, { width: dims.pixelWidth, height: dims.pixelHeight })
146
+ process.stdout.write(output)
147
+ process.stdout.write('\n')
148
+ break
149
+ }
150
+ case 'halfBlock': {
151
+ const output = renderHalfBlock(pixels)
152
+ process.stdout.write('\n')
153
+ process.stdout.write(output)
154
+ process.stdout.write('\n')
155
+ break
156
+ }
157
+ case 'braille': {
158
+ const output = renderBraille(pixels)
159
+ process.stdout.write('\n')
160
+ process.stdout.write(output)
161
+ process.stdout.write('\n')
162
+ break
163
+ }
164
+ case 'ascii': {
165
+ const output = renderAscii(pixels, {
166
+ colored: !!(process.env.COLORTERM || process.env.TERM?.includes('truecolor')),
167
+ })
168
+ process.stdout.write('\n')
169
+ process.stdout.write(output)
170
+ process.stdout.write('\n')
171
+ break
172
+ }
173
+ }
174
+ }
175
+
176
  // ── Tool definition ──
177
 
178
  const inputSchema = lazySchema(() =>
 
258
  async call({ src }) {
259
  try {
260
  const image = await loadImage(src)
261
+ const cols = process.stdout.columns ?? 80
262
  const dims = calculateDimensions(
263
  image.bitmap.width,
264
  image.bitmap.height,
265
+ cols,
266
  )
267
+
268
+ // Render the image directly to terminal using ink-picture
269
+ await renderImage(image, dims)
270
+
271
  return {
272
  data: {
273
  src,
 
297
  ],
298
  }
299
  },
300
+ } satisfies ToolDef<InputSchema, Output>)