SLOPSHOPPER

image-inline

Claude の返答に出た画像パスの画像を、その返答の下にインラインで描く(Ghostty の画像プロトコル)

rowsprocess
★ 2v0.1.0no licenseupdated 2026-10-17gunubin/dotfiles/claude/.claude/mods/image-inline
A shopper browsing a rack in a slop shop
README

Dotfiles

macOS Homebrew Fish Shell Neovim Tmux

Terminal screenshot

My personal development environment managed with GNU Stow, Homebrew, and Make.

Features

  • GNU Stow — Symlink-based dotfile management
  • Brewfile — Declarative package management via Homebrew Bundle
  • Makefile — One-command setup and teardown

Packages

Shell & Prompt

PackageDescription
FishUser-friendly interactive shell
StarshipCross-shell prompt

Terminal

PackageDescription
GhosttyGPU-accelerated terminal emulator
iTerm2macOS terminal emulator
tmuxTerminal multiplexer
workmuxGit worktree + tmux workflow manager

Editor & IDE

PackageDescription
NeovimHyperextensible text editor
IntelliJ IDEAJetBrains IDE settings

CLI Tools

PackageDescription
batA cat clone with syntax highlighting
ezaA modern replacement for ls
yaziTerminal file manager
Claude CodeAI coding assistant

System

PackageDescription
GitVersion control configuration
Karabiner-ElementsmacOS keyboard customizer (config generated via karabiner.ts in karabiner-config/)

Prerequisites

Installation

Quick Start

git clone https://github.com/gunubin/dotfiles.git
cd dotfiles
make install

Individual Commands

CommandDescription
make installCreate symlinks for all packages
make brew-bundleInstall Homebrew dependencies from Brewfile
make updateRe-sync all symlinks (clean + install)
make cleanRemove all symlinks

Git Configuration

User-specific Git settings (name, email) should be stored in ~/.gitconfig.local, which is not tracked by this repository.

[user]
    name = YOUR_NAME
    email = YOUR_EMAIL
Source 1 files
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