hooks/register.tsx 197 lines
1import type { Register, RenderElement } from 'claude-code'
2
3// 描く画像の最大の大きさ(セル単位)。縦横比はこの枠に収まるよう保つ
4const MAX_COLUMNS = 60
5const MAX_ROWS = 20
6// 1 セルは幅 1 に対して高さ 2 とみなす(一般的な等幅フォントの比率)
7const CELL_HEIGHT_PER_WIDTH = 2
8// 1 つの返答で扱う画像の上限
9const MAX_IMAGES = 4
10// 端末が読むのは PNG だけなので、他の形式とこの大きさを超える PNG は縮小した PNG に変換する
11const DIRECT_PNG_MAX_BYTES = 3 * 1024 * 1024
12const CONVERTED_MAX_PIXELS = 1600
13const CACHE_DIR_NAME = '.cache/claude-image-inline'
14// ポップアップは画像の枠の外側に、左右 2 セルと上下の見出し・案内の行を足す
15const POPUP_EXTRA_COLUMNS = 4
16const POPUP_EXTRA_ROWS = 6
17
18// 拡張子で終わるパスらしい文字列。括弧・引用符・空白で切れ、末尾の句読点は拾わない
19const PATH_PATTERN = /(?:file:\/\/)?((?:(?:~|\.{1,2})?\/)?[^\s'"`()<>\[\]{}:]+\.(?:png|jpe?g|gif|webp))(?![\w.])/gi
20
21type Picture = { file: string; width: number; height: number; generation: number; id: number }
22
23// パス + 更新時刻ごとの結果。変換は一度だけ行い、描き直しのたびに sips を走らせない
24const pictures = new Map<string, Promise<Picture | undefined>>()
25let isCacheDirReady = false
26
27const hashOf = (text: string) => {
28 let hash = 5381
29 for (let i = 0; i < text.length; i++) {
30 hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
31 }
32
33 return hash
34}
35
36const extractPaths = (text: string): string[] => {
37 const found: string[] = []
38 for (const match of text.matchAll(PATH_PATTERN)) {
39 const path = match[1]
40 if (path !== undefined && !found.includes(path)) {
41 found.push(path)
42 }
43 }
44
45 return found
46}
47
48const resolvePath = (path: string, home: string, cwd: string) => {
49 if (path.startsWith('~/')) {
50 return `${home}${path.slice(1)}`
51 }
52 if (path.startsWith('/')) {
53 return path
54 }
55
56 return `${cwd}/${path.replace(/^\.\//, '')}`
57}
58
59// 枠に収まり、かつ縦横比を保つセル数。画像は枠いっぱいに引き伸ばされるので枠の方を画像に合わせる
60const fitCells = (width: number, height: number, maxColumns: number) => {
61 const aspect = width / height
62 let columns = maxColumns
63 let rows = Math.round(columns / (aspect * CELL_HEIGHT_PER_WIDTH))
64 if (rows > MAX_ROWS) {
65 rows = MAX_ROWS
66 columns = Math.round(rows * aspect * CELL_HEIGHT_PER_WIDTH)
67 }
68
69 return { columns: Math.max(1, Math.min(64, columns)), rows: Math.max(1, Math.min(64, rows)) }
70}
71
72const shellQuote = (text: string) => `'${text.replaceAll("'", `'\\''`)}'`
73
74export const register: Register = on => {
75 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
76 if (e.surface !== 'terminal' || e.props.isSummary === true) {
77 return next(e)
78 }
79 const paths = extractPaths(e.props.text).slice(0, MAX_IMAGES)
80 if (paths.length === 0) {
81 return next(e)
82 }
83
84 const [home, cwd, tmux] = await Promise.all([$.env.get('HOME'), $.session.cwd(), $.env.get('TMUX')])
85 if (home === undefined) {
86 return next(e)
87 }
88 const cacheDir = `${home}/${CACHE_DIR_NAME}`
89 // tmux は画像を知らないので、本文に貼った画像は画面が流れても元の座標に居座る。
90 // tmux の中では本文に描かず、ボタンで tmux のポップアップに開く
91 const isInTmux = tmux !== undefined && tmux !== ''
92
93 const load = async (path: string): Promise<Picture | undefined> => {
94 const absolute = resolvePath(path, home, cwd)
95 const stat = await $.fs.stat(absolute).catch(() => undefined)
96 if (stat === undefined || stat.kind !== 'file') {
97 return undefined
98 }
99 const key = `${absolute}:${stat.mtimeMs}:${stat.size}`
100 let pending = pictures.get(key)
101 if (pending === undefined) {
102 pending = (async () => {
103 const probe = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', absolute])
104 const width = Number(/pixelWidth:\s*(\d+)/.exec(probe.stdout)?.[1])
105 const height = Number(/pixelHeight:\s*(\d+)/.exec(probe.stdout)?.[1])
106 if (probe.exitCode !== 0 || !(width > 0) || !(height > 0)) {
107 return undefined
108 }
109 // ポップアップで端末に渡す画像 ID(24bit、0 は使えない)
110 const id = (hashOf(key) & 0xffffff) || 1
111 const generation = Math.floor(stat.mtimeMs)
112 const isPng = /\.png$/i.test(absolute)
113 if (isPng && stat.size <= DIRECT_PNG_MAX_BYTES) {
114 return { file: absolute, width, height, generation, id }
115 }
116 if (!isCacheDirReady) {
117 await $.fs.write(`${cacheDir}/.keep`, '')
118 isCacheDirReady = true
119 }
120 const converted = `${cacheDir}/${hashOf(key).toString(16)}.png`
121 if (!(await $.fs.exists(converted))) {
122 const result = await $.process.run([
123 'sips', '-s', 'format', 'png', '-Z', String(CONVERTED_MAX_PIXELS), absolute, '--out', converted,
124 ])
125 if (result.exitCode !== 0) {
126 return undefined
127 }
128 }
129
130 return { file: converted, width, height, generation, id }
131 })().catch(() => undefined)
132 pictures.set(key, pending)
133 }
134
135 return pending
136 }
137
138 const openPopup = (path: string, picture: Picture, columns: number, rows: number) => {
139 const caption = `${path} (${picture.width}×${picture.height})`
140 const viewer = [
141 'python3', '-I', `${$.plugin.root}/scripts/show-image.py`,
142 picture.file, caption, String(columns), String(rows), String(picture.id),
143 ].map(shellQuote).join(' ')
144 const popup = [
145 'tmux', 'display-popup', '-E',
146 '-w', String(columns + POPUP_EXTRA_COLUMNS),
147 '-h', String(rows + POPUP_EXTRA_ROWS),
148 '-T', shellQuote(` ${path} `),
149 shellQuote(viewer),
150 ].join(' ')
151 // display-popup -E はポップアップが閉じるまで戻らないので、run-shell -b で背景に流す
152 void $.process.run(['tmux', 'run-shell', '-b', popup]).catch(() => undefined)
153 }
154
155 const loaded = await Promise.all(paths.map(async path => ({ path, picture: await load(path) })))
156 const shown = loaded.filter((one): one is { path: string; picture: Picture } => one.picture !== undefined)
157 const own = await next(e)
158 if (shown.length === 0) {
159 return own
160 }
161
162 const { Box, Text, Image, Button } = $.ui.resolve(e)
163 const maxColumns = Math.max(10, Math.min(MAX_COLUMNS, (e.viewport?.columns ?? MAX_COLUMNS + 4) - 4))
164
165 return (
166 <Box flexDirection="column">
167 {own as RenderElement}
168 {shown.map(({ path, picture }) => {
169 const { columns, rows } = fitCells(picture.width, picture.height, maxColumns)
170 const caption = `${path} (${picture.width}×${picture.height})`
171
172 return isInTmux ? (
173 <Box key={path} flexDirection="row" gap={1} marginTop={1}>
174 <Button key={`open:${path}`} label="開く" onPress={() => openPopup(path, picture, columns, rows)} />
175 <Text dimColor wrap="truncate-end">
176 {caption}
177 </Text>
178 </Box>
179 ) : (
180 <Box key={path} flexDirection="column" marginTop={1}>
181 <Image
182 source={{ file: picture.file, format: 'png', generation: picture.generation }}
183 columns={columns}
184 rows={rows}
185 alt={`[画像: ${path}]`}
186 />
187 <Text dimColor wrap="truncate-end">
188 {caption}
189 </Text>
190 </Box>
191 )
192 })}
193 </Box>
194 )
195 })
196}
197