chenbhao Claude Big Pickle commited on
Commit
999003d
·
1 Parent(s): bfb2909

refactor: move ink-picture rendering from call() to UI React tree

Browse files

- Remove direct stdout ANSI rendering from ImageShowTool.call() (protocol
detection, renderImage, etc.)
- Simplify call() to only load image and return dimensions
- Add ImageDisplay component in UI.tsx using InkPictureProvider + Image
from ink-picture, mounted in Codev's React tree
- Image component reserves a TUI placeholder and renders the actual
image directly to the terminal framebuffer via Kitty/Sixel protocol
(bypassing Ink render cycle)
- Merge standalone.tsx to use ImageDisplay from UI.tsx

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

src/tools/ImageShowTool/ImageShowTool.ts CHANGED
@@ -10,23 +10,14 @@ import {
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
 
27
  export const CELL_WIDTH = 8;
28
  export const CELL_HEIGHT = 16;
29
 
 
 
30
  export interface ImageDimensions {
31
  width: number; // 字符宽度
32
  height: number; // 字符高度
@@ -34,6 +25,17 @@ export interface ImageDimensions {
34
  pixelHeight: number; // 像素高度
35
  }
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  export function getImagePath(args: string[]): string {
38
  return args[0] || "/home/yuki/Pictures/Wallpapers/3god.jpg";
39
  }
@@ -70,109 +72,6 @@ export function calculateDimensions(
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(() =>
@@ -184,8 +83,12 @@ type InputSchema = ReturnType<typeof inputSchema>
184
 
185
  const outputSchema = lazySchema(() =>
186
  z.object({
187
- src: z.string().describe('The image source that was displayed'),
188
- success: z.boolean().describe('Whether the image was displayed successfully'),
 
 
 
 
189
  }),
190
  )
191
  type OutputSchema = ReturnType<typeof outputSchema>
@@ -265,25 +168,24 @@ export const ImageShowTool = buildTool({
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,
274
  success: true,
275
- } satisfies Output,
 
276
  }
277
  } catch (error) {
 
278
  return {
279
  data: {
280
  src,
281
  success: false,
282
- } satisfies Output,
283
  }
284
  }
285
  },
286
- mapToolResultToToolResultBlockParam(output, toolUseID) {
287
  return {
288
  tool_use_id: toolUseID,
289
  type: 'tool_result',
@@ -297,4 +199,4 @@ export const ImageShowTool = buildTool({
297
  ],
298
  }
299
  },
300
- } satisfies ToolDef<InputSchema, Output>)
 
10
  renderToolResultMessage,
11
  renderToolUseMessage,
12
  } from './UI.js'
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ // ── Constants ──
15
 
16
  export const CELL_WIDTH = 8;
17
  export const CELL_HEIGHT = 16;
18
 
19
+ // ── Types ──
20
+
21
  export interface ImageDimensions {
22
  width: number; // 字符宽度
23
  height: number; // 字符高度
 
25
  pixelHeight: number; // 像素高度
26
  }
27
 
28
+ export interface ImageShowOutput {
29
+ src: string
30
+ success: boolean
31
+ width?: number
32
+ height?: number
33
+ pixelWidth?: number
34
+ pixelHeight?: number
35
+ }
36
+
37
+ // ── Utilities ──
38
+
39
  export function getImagePath(args: string[]): string {
40
  return args[0] || "/home/yuki/Pictures/Wallpapers/3god.jpg";
41
  }
 
72
  };
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  // ── Tool definition ──
76
 
77
  const inputSchema = lazySchema(() =>
 
83
 
84
  const outputSchema = lazySchema(() =>
85
  z.object({
86
+ src: z.string(),
87
+ success: z.boolean(),
88
+ width: z.number().optional(),
89
+ height: z.number().optional(),
90
+ pixelWidth: z.number().optional(),
91
+ pixelHeight: z.number().optional(),
92
  }),
93
  )
94
  type OutputSchema = ReturnType<typeof outputSchema>
 
168
  cols,
169
  )
170
 
 
 
 
171
  return {
172
  data: {
173
  src,
174
  success: true,
175
+ ...dims,
176
+ } satisfies ImageShowOutput,
177
  }
178
  } catch (error) {
179
+ console.error(`[ImageShowTool] Failed to display ${src}:`, error)
180
  return {
181
  data: {
182
  src,
183
  success: false,
184
+ } satisfies ImageShowOutput,
185
  }
186
  }
187
  },
188
+ mapToolResultToToolResultBlockParam(output: ImageShowOutput, toolUseID: string) {
189
  return {
190
  tool_use_id: toolUseID,
191
  type: 'tool_result',
 
199
  ],
200
  }
201
  },
202
+ } satisfies ToolDef<InputSchema, ImageShowOutput>)
src/tools/ImageShowTool/UI.tsx CHANGED
@@ -1,6 +1,40 @@
1
  import React from 'react'
2
- import { MessageResponse } from '../../components/MessageResponse.js'
3
  import { Box, Text } from '../../ink.js'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export function renderToolUseMessage(
6
  { src }: { src?: string },
@@ -20,10 +54,12 @@ export function renderToolUseProgressMessage(): React.ReactNode {
20
  }
21
 
22
  export function renderToolResultMessage(
23
- { src, success }: { src: string; success: boolean },
24
  _progressMessages: unknown[],
25
  { verbose }: { verbose: boolean },
26
  ): React.ReactNode {
 
 
27
  if (!success) {
28
  return (
29
  <MessageResponse height={1}>
@@ -31,17 +67,21 @@ export function renderToolResultMessage(
31
  </MessageResponse>
32
  )
33
  }
34
- if (verbose) {
 
 
35
  return (
36
- <Box flexDirection="column">
37
- <MessageResponse height={1}>
38
- <Text>
39
- Image displayed: <Text bold>{src}</Text>
40
- </Text>
41
- </MessageResponse>
42
- </Box>
43
  )
44
  }
 
 
45
  return (
46
  <MessageResponse height={1}>
47
  <Text>
@@ -54,4 +94,4 @@ export function renderToolResultMessage(
54
  export function getToolUseSummary(input: { src?: string } | undefined): string | null {
55
  if (!input?.src) return null
56
  return input.src.length > 80 ? input.src.slice(0, 77) + '...' : input.src
57
- }
 
1
  import React from 'react'
2
+ import Image, { InkPictureProvider } from '../../ink-picture/index.js'
3
  import { Box, Text } from '../../ink.js'
4
+ import { MessageResponse } from '../../components/MessageResponse.js'
5
+ import type { ImageShowOutput } from './ImageShowTool.js'
6
+
7
+ // ── Image display component ──
8
+ // Renders a placeholder in Ink's TUI and uses Kitty/Sixel protocol to draw
9
+ // the full-resolution image directly on the terminal framebuffer (bypassing
10
+ // the Ink render cycle). The placeholder reserves character cells so the TUI
11
+ // layout isn't broken; ink-picture's useDirectRenderer repositions the image
12
+ // after each Ink screen refresh.
13
+
14
+ export function ImageDisplay({ src, width, height, pixelWidth, pixelHeight }: {
15
+ src: string
16
+ width: number
17
+ height: number
18
+ pixelWidth: number
19
+ pixelHeight: number
20
+ }) {
21
+ return (
22
+ <Box flexDirection="column">
23
+ <InkPictureProvider>
24
+ <Image
25
+ src={src}
26
+ width={width}
27
+ height={height}
28
+ pixelWidth={pixelWidth}
29
+ pixelHeight={pixelHeight}
30
+ alt={typeof src === 'string' ? src : 'image'}
31
+ />
32
+ </InkPictureProvider>
33
+ </Box>
34
+ )
35
+ }
36
+
37
+ // ── Tool rendering functions ──
38
 
39
  export function renderToolUseMessage(
40
  { src }: { src?: string },
 
54
  }
55
 
56
  export function renderToolResultMessage(
57
+ output: ImageShowOutput,
58
  _progressMessages: unknown[],
59
  { verbose }: { verbose: boolean },
60
  ): React.ReactNode {
61
+ const { src, success, width, height, pixelWidth, pixelHeight } = output
62
+
63
  if (!success) {
64
  return (
65
  <MessageResponse height={1}>
 
67
  </MessageResponse>
68
  )
69
  }
70
+
71
+ // Render the image via ink-picture when we have dimension data
72
+ if (width && height && pixelWidth && pixelHeight) {
73
  return (
74
+ <ImageDisplay
75
+ src={src}
76
+ width={width}
77
+ height={height}
78
+ pixelWidth={pixelWidth}
79
+ pixelHeight={pixelHeight}
80
+ />
81
  )
82
  }
83
+
84
+ // Fallback: text-only summary
85
  return (
86
  <MessageResponse height={1}>
87
  <Text>
 
94
  export function getToolUseSummary(input: { src?: string } | undefined): string | null {
95
  if (!input?.src) return null
96
  return input.src.length > 80 ? input.src.slice(0, 77) + '...' : input.src
97
+ }
src/tools/ImageShowTool/standalone.tsx CHANGED
@@ -12,7 +12,6 @@
12
 
13
  import { useEffect, useState } from "react";
14
  import { render, Box, Text, useApp } from "ink";
15
- import Image, { InkPictureProvider } from "../../ink-picture/index.ts";
16
  import {
17
  getImagePath,
18
  isUrl,
@@ -20,6 +19,7 @@ import {
20
  calculateDimensions,
21
  type ImageDimensions,
22
  } from "./ImageShowTool.ts";
 
23
 
24
  const args = process.argv.slice(2);
25
  const IMAGE_PATH = getImagePath(args);
@@ -36,7 +36,7 @@ function App() {
36
  const dims = calculateDimensions(
37
  image.bitmap.width,
38
  image.bitmap.height,
39
- process.stdout.columns ?? 80
40
  );
41
  setDimensions(dims);
42
  } catch (e) {
@@ -65,19 +65,16 @@ function App() {
65
 
66
  return (
67
  <Box flexDirection="column">
68
- <InkPictureProvider>
69
- <Image
70
- src={IMAGE_PATH}
71
- width={dimensions.width}
72
- height={dimensions.height}
73
- pixelWidth={dimensions.pixelWidth}
74
- pixelHeight={dimensions.pixelHeight}
75
- alt={isUrl(IMAGE_PATH) ? "url-image" : "local-image"}
76
- />
77
- </InkPictureProvider>
78
  </Box>
79
  );
80
  }
81
 
82
  const { waitUntilExit } = render(<App />);
83
- await waitUntilExit();
 
12
 
13
  import { useEffect, useState } from "react";
14
  import { render, Box, Text, useApp } from "ink";
 
15
  import {
16
  getImagePath,
17
  isUrl,
 
19
  calculateDimensions,
20
  type ImageDimensions,
21
  } from "./ImageShowTool.ts";
22
+ import { ImageDisplay } from "./UI.js";
23
 
24
  const args = process.argv.slice(2);
25
  const IMAGE_PATH = getImagePath(args);
 
36
  const dims = calculateDimensions(
37
  image.bitmap.width,
38
  image.bitmap.height,
39
+ process.stdout.columns ?? 80,
40
  );
41
  setDimensions(dims);
42
  } catch (e) {
 
65
 
66
  return (
67
  <Box flexDirection="column">
68
+ <ImageDisplay
69
+ src={IMAGE_PATH}
70
+ width={dimensions.width}
71
+ height={dimensions.height}
72
+ pixelWidth={dimensions.pixelWidth}
73
+ pixelHeight={dimensions.pixelHeight}
74
+ />
 
 
 
75
  </Box>
76
  );
77
  }
78
 
79
  const { waitUntilExit } = render(<App />);
80
+ await waitUntilExit();