SLOPSHOPPER

file-preview

Claude가 만들거나 고친 이미지·문서 파일을 패널에서 바로 미리보기

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-13wonr-spec/claude-mods/plugins/file-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-preview
│ ┃ 파일 미리보기 ✕ › fix the failing auth test and add an audit log call │ ┃ [ auth.ts ] │ ┃ auth.ts ⏺ Read(src/auth.ts) │ ┃ /work/app/src/auth.ts · 193 B ⎿ Read 6 lines │ ┃ import { verify } from './jwt' ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ export async function refresh(token: string) ⏺ Bash(bun test) │ ┃ const claims = await verify(token) ⎿ 3 pass, 1 fail │ ┃ if (!claims) throw new Error('invalid toke │ ┃ return issue(claims.sub) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ } │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ 기본 앱으로 열기 ][ 목록에서 빼기 ] │ › /preview │ ⎿ file-preview: 파일 미리보기 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 파일 미리보기
[ auth.ts ] auth.ts /work/app/src/auth.ts · 193 B import { verify } from './jwt' export async function refresh(token: string) { const claims = await verify(token) if (!claims) throw new Error('invalid token') return issue(claims.sub) } [ 기본 앱으로 열기 ][ 목록에서 빼기 ]
README

file-preview

Claude가 만들거나 고친 이미지·문서 파일을 "파일 미리보기" 패널에서 바로 보여주는 mod입니다.

  • 자동 감지: Write·Edit로 쓴 파일과, Bash 명령에 나온 경로 중 실제로 있는 파일을 목록에 올리고 패널을 엽니다.
  • 형식별 미리보기
  • 이미지(png, jpg, gif, webp): 데스크톱·VS Code·모바일은 패널에 그림으로 표시(약 95KB까지), 터미널은 PNG를 kitty·Ghostty 등에서 그림으로 표시
  • SVG: 그대로 표시
  • Markdown: 렌더링해서 표시
  • CSV·TSV: 앞 20행을 표로 표시
  • JSON·YAML·코드·HTML·텍스트: 코드 블록으로 표시
  • PDF·Office(docx, pptx, xlsx)·Keynote 등: 정보만 표시하고 기본 앱으로 열기 버튼 제공
  • 목록: 최근 파일 8개를 버튼으로 전환, 목록에서 빼기로 정리
  • /preview <경로>: 원하는 파일을 직접 미리보기
  • /preview on · /preview off: 새 파일이 생길 때 패널 자동 열기 켜기/끄기(기본 켬, 세션이 바뀌어도 유지)
Source 3 files
hooks/register.tsx 201 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PreviewFile } from '../types'
5import { MIME, baseName, headerBytes, extOf, formatSize, kindOf, pathsInCommand, pngSize, rasterSvg, tableMarkdown } from './files'
6
7const PANE = 'file-preview'
8const TITLE = '파일 미리보기'
9const STORE_AUTO = 'isAuto'
10const SVG_LIMIT = 131072
11const TEXT_LIMIT = 20000
12const TERMINAL_IMAGE_LIMIT = 2 * 1024 * 1024
13
14const files = atom({ plugin: 'file-preview', key: 'files' } as const, [] as PreviewFile[])
15const selected = atom({ plugin: 'file-preview', key: 'selected' } as const, null as string | null)
16const isAuto = atom({ plugin: 'file-preview', key: 'isAuto' } as const, true)
17
18async function track($: EngineInterface, path: string) {
19  const kind = kindOf(path)
20  if (kind === undefined) return
21  const stat = await $.fs.stat(path).catch(() => undefined)
22  if (stat?.kind !== 'file') return
23  const at = await $.clock.now()
24  await update($, files, list => [{ path, kind, at }, ...list.filter(f => f.path !== path)].slice(0, 30))
25  await update($, selected, () => path)
26  if (await read($, isAuto)) await $.ui.open({ id: PANE, title: TITLE })
27}
28
29async function openExternally($: EngineInterface, path: string) {
30  const mac = await $.process.run(['open', path]).catch(() => undefined)
31  if (mac?.exitCode === 0) return
32  const linux = await $.process.run(['xdg-open', path]).catch(() => undefined)
33  if (linux?.exitCode !== 0) $.ui.toast(`열 수 없습니다: ${baseName(path)}`)
34}
35
36async function runPreview($: EngineInterface, args: string): Promise<string> {
37  const arg = args.trim()
38  if (arg === 'on' || arg === 'off') {
39    const value = arg === 'on'
40    await $.store.set(STORE_AUTO, value)
41    await update($, isAuto, () => value)
42
43    return `새 파일 자동 미리보기를 ${value ? '켰' : '껐'}습니다.`
44  }
45  if (arg !== '') {
46    const path = arg.replace(/^['"]|['"]$/g, '')
47    if (kindOf(path) === undefined) return `미리보기를 지원하지 않는 형식입니다: ${baseName(path)}`
48    if (!(await $.fs.exists(path))) return `파일이 없습니다: ${path}`
49    await track($, path)
50  }
51  const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE })
52
53  return isPlaced
54    ? '파일 미리보기 패널을 열었습니다.'
55    : '파일 미리보기 패널을 열려고 했지만, 지금 화면(앱)이 mod 패널을 표시하지 않습니다.'
56}
57
58export const register: Register = on => {
59  on('session.start', async ($, e, next) => {
60    await $.command.register({
61      name: 'preview',
62      description: '파일 미리보기: /preview <경로>로 열기, /preview on|off로 자동 열기 켜기/끄기',
63    })
64    const stored = await $.store.get(STORE_AUTO)
65    await update($, isAuto, () => stored !== false)
66
67    return next(e)
68  })
69
70  on('command.run', { command: 'preview' }, async ($, e) => ({ text: await runPreview($, e.args) }))
71
72  // 슬래시 명령이 command.run으로 오지 않고 프롬프트로 들어올 때의 대체 처리: "/preview <경로>"
73  on('prompt.submit', async ($, e, next) => {
74    const m = /^\s*\/preview(?:\s+([\s\S]*))?$/.exec(e.text)
75    if (m === null) return next(e)
76    return { drop: await runPreview($, m[1] ?? '') }
77  }).catch(($, e, next) => next(e))
78
79  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
80    const ran = await next(e)
81    if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
82    return ran
83  }).catch(($, e, next) => next(e))
84
85  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
86    const ran = await next(e)
87    if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
88    return ran
89  }).catch(($, e, next) => next(e))
90
91  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92    const ran = await next(e)
93    if (ran.deny === undefined && ran.isError !== true) {
94      for (const path of pathsInCommand(e.command)) await track($, path)
95    }
96    return ran
97  }).catch(($, e, next) => next(e))
98
99  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100    const table = $.ui.resolve(e)
101    const { Box, Text, Button, Markdown, Code, Link } = table
102    const Svg = 'Svg' in table ? table.Svg : undefined
103    const list = await read($, files)
104    const chosen = (await read($, selected)) ?? list[0]?.path ?? null
105    const file = list.find(f => f.path === chosen)
106    const columns = e.props.bodyColumns
107
108    if (file === undefined) {
109      return (
110        <Box flexDirection="column">
111          <Text dimColor>아직 미리볼 파일이 없습니다.</Text>
112          <Text dimColor>Claude가 이미지·문서를 만들면 여기에 나타납니다. /preview 경로 로 직접 열 수도 있습니다.</Text>
113        </Box>
114      )
115    }
116
117    const stat = await $.fs.stat(file.path).catch(() => undefined)
118    const ext = extOf(file.path)
119    let body: JSX.Element
120
121    if (stat === undefined) {
122      body = <Text color="red">파일이 없어졌습니다.</Text>
123    } else if (file.kind === 'image') {
124      const tooBig = stat.size > 4 * 1024 * 1024
125      const bytes = tooBig ? undefined : await $.fs.read(file.path, { as: 'bytes' }).catch(() => undefined)
126      if (bytes === undefined) {
127        body = <Text dimColor>이미지가 너무 커서 미리보기를 생략했습니다. 아래 버튼으로 여세요.</Text>
128      } else if (e.surface === 'terminal') {
129        const { Image } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
130        body =
131          ext === 'png' && stat.size <= TERMINAL_IMAGE_LIMIT ? (
132            <Image source={{ png: bytes.base64 }} columns={Math.min(columns, 120)} rows={24} alt={baseName(file.path)} />
133          ) : (
134            <Text dimColor>터미널에서는 PNG만 그립니다. 아래 버튼으로 여세요.</Text>
135          )
136      } else {
137        const size = ext === 'png' ? pngSize(headerBytes(bytes.base64)) : undefined
138        const svg = rasterSvg(bytes.base64, MIME[ext] ?? 'image/png', size)
139        body =
140          Svg !== undefined && svg.length <= SVG_LIMIT ? (
141            <Svg source={svg} alt={baseName(file.path)} />
142          ) : (
143            <Text dimColor>이미지가 커서({formatSize(stat.size)}) 패널에 그리지 못했습니다. 아래 버튼으로 여세요.</Text>
144          )
145      }
146    } else if (file.kind === 'svg') {
147      const text = await $.fs.read(file.path).catch(() => '')
148      body =
149        Svg !== undefined && text.length <= SVG_LIMIT ? (
150          <Svg source={text} alt={baseName(file.path)} />
151        ) : (
152          <Code source={text.slice(0, TEXT_LIMIT)} language="xml" />
153        )
154    } else if (file.kind === 'markdown') {
155      const text = await $.fs.read(file.path).catch(() => '')
156      body = <Markdown text={text.slice(0, TEXT_LIMIT)} />
157    } else if (file.kind === 'table') {
158      const text = await $.fs.read(file.path).catch(() => '')
159      body = <Markdown text={tableMarkdown(text.slice(0, TEXT_LIMIT), ext === 'tsv' ? '\t' : ',')} />
160    } else if (file.kind === 'code' || file.kind === 'html') {
161      const text = await $.fs.read(file.path).catch(() => '')
162      body = <Code source={text.slice(0, TEXT_LIMIT)} language={ext === 'htm' ? 'html' : ext} path={file.path} />
163    } else {
164      body = <Text dimColor>{ext.toUpperCase()} 문서는 패널에서 그리지 않습니다. 아래 버튼으로 기본 앱에서 여세요.</Text>
165    }
166
167    return (
168      <Box flexDirection="column">
169        <Box flexDirection="row" flexWrap="wrap">
170          {list.slice(0, 8).map((f, i) => (
171            <Button
172              key={`f${i}`}
173              label={baseName(f.path)}
174              variant={f.path === file.path ? 'primary' : 'secondary'}
175              onPress={() => update($, selected, () => f.path)}
176            />
177          ))}
178        </Box>
179        <Text bold>{baseName(file.path)}</Text>
180        <Text dimColor>
181          {file.path}
182          {stat !== undefined ? ` · ${formatSize(stat.size)}` : ''}
183        </Text>
184        {body}
185        <Box flexDirection="row">
186          <Button key="open" label="기본 앱으로 열기" onPress={() => openExternally($, file.path)} />
187          {file.kind === 'html' && <Link href={`file://${file.path}`} label="브라우저로 열기" />}
188          <Button
189            key="remove"
190            label="목록에서 빼기"
191            onPress={async () => {
192              await update($, files, l => l.filter(f => f.path !== file.path))
193              await update($, selected, () => null)
194            }}
195          />
196        </Box>
197      </Box>
198    )
199  })
200}
201
hooks/files.ts 73 lines
1import type { PreviewKind } from '../types'
2
3const KINDS: Record<string, PreviewKind> = {
4  png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
5  svg: 'svg',
6  md: 'markdown', markdown: 'markdown',
7  csv: 'table', tsv: 'table',
8  json: 'code', yaml: 'code', yml: 'code', txt: 'code', log: 'code', xml: 'code',
9  swift: 'code', ts: 'code', tsx: 'code', js: 'code', py: 'code', kt: 'code', sh: 'code',
10  html: 'html', htm: 'html',
11  pdf: 'document', docx: 'document', pptx: 'document', xlsx: 'document',
12  key: 'document', pages: 'document', numbers: 'document',
13}
14
15export const MIME: Record<string, string> = {
16  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
17}
18
19export const extOf = (path: string): string => {
20  const name = baseName(path)
21  const dot = name.lastIndexOf('.')
22  return dot < 1 ? '' : name.slice(dot + 1).toLowerCase()
23}
24
25export const baseName = (path: string): string => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
26
27export const kindOf = (path: string): PreviewKind | undefined => KINDS[extOf(path)]
28
29/** Bash 명령에 등장하는, 미리보기 가능한 확장자의 경로 후보들. */
30export function pathsInCommand(command: string): string[] {
31  const found = new Set<string>()
32  for (const m of command.matchAll(/"([^"]+)"|'([^']+)'|([^\s;|&<>()'"]+)/g)) {
33    const token = (m[1] ?? m[2] ?? m[3] ?? '').replace(/^-[a-zA-Z-]+=/, '')
34    if (token.length > 0 && !token.startsWith('-') && !token.includes('*') && kindOf(token) !== undefined) found.add(token)
35  }
36  return [...found]
37}
38
39/** PNG 헤더에서 가로·세로 픽셀. PNG가 아니면 undefined. */
40export function pngSize(bytes: Uint8Array): { width: number; height: number } | undefined {
41  const sig = [0x89, 0x50, 0x4e, 0x47]
42  if (bytes.length < 24 || sig.some((b, i) => bytes[i] !== b)) return undefined
43  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
44  return { width: view.getUint32(16), height: view.getUint32(20) }
45}
46
47/** 래스터 이미지를 Svg 요소가 그릴 수 있는 SVG 문서로 감싼다. */
48export function rasterSvg(base64: string, mime: string, size?: { width: number; height: number }): string {
49  const w = size?.width ?? 800
50  const h = size?.height ?? 600
51  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><image width="${w}" height="${h}" href="data:${mime};base64,${base64}" xlink:href="data:${mime};base64,${base64}"/></svg>`
52}
53
54/** CSV/TSV 앞부분을 마크다운 표로. */
55export function tableMarkdown(text: string, separator: string, maxRows = 20): string {
56  const rows = text.split(/\r?\n/).filter(r => r.length > 0).slice(0, maxRows + 1)
57  if (rows.length === 0) return '_빈 파일_'
58  const cells = rows.map(r => r.split(separator).map(c => c.trim().replace(/^"|"$/g, '').replace(/\|/g, '\\|')))
59  const width = Math.max(...cells.map(c => c.length))
60  const pad = (c: string[]) => `| ${[...c, ...Array(width - c.length).fill('')].join(' | ')} |`
61  const [head = [], ...body] = cells
62  return [pad(head), `| ${Array(width).fill('---').join(' | ')} |`, ...body.map(pad)].join('\n')
63}
64
65export const formatSize = (bytes: number): string =>
66  bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
67
68/** base64 앞부분만 풀어 헤더 바이트를 얻는다(이미지 크기 판별용). */
69export function headerBytes(base64: string, count = 32): Uint8Array {
70  const binary = atob(base64.slice(0, Math.ceil(count / 3) * 4))
71  return Uint8Array.from(binary, c => c.charCodeAt(0))
72}
73
types/index.d.ts 10 lines
1export type PreviewKind = 'image' | 'svg' | 'markdown' | 'table' | 'code' | 'html' | 'document'
2
3export type PreviewFile = { path: string; kind: PreviewKind; at: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'file-preview': { files: PreviewFile[]; selected: string | null; isAuto: boolean }
8  }
9}
10