Shows Xcode RenderPreview snapshots of SwiftUI previews in a Claude Code pane

A Claude Code mod that shows SwiftUI previews right in the terminal, rendered by Xcode itself.
Claude calls Xcode's RenderPreview MCP tool on a #Preview; the mod shows the snapshot in a SwiftUI Preview pane — components 1:1 in points like Xcode's canvas at 100%, whole screens fit to the pane. Variants (dark mode, Dynamic Type, orientation), localizations and #Preview(arguments:) come from RenderPreview itself.
sudo xcrun mcp-server enable)claude mcp add xcode -s user -- xcrun mcpbridge
then, at a Claude Code prompt:
/plugin install swiftui-preview --marketplace degterev/swiftui-preview-mod
Answer y to add the marketplace, then pick a scope.
Ask Claude: "show SettingsView in dark mode at AX3". If the file has no #Preview, Claude adds one. /swiftui-preview reopens the pane with the last render.
hooks/register.tsx 76 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Shot } from '../types'
5import { CELL_SCRIPT, fitImage, parseRendered, pngSize } from './lib'
6import type { Cell } from './lib'
7
8const PANE = 'swiftui-preview'
9const TITLE = 'SwiftUI Preview'
10const shot = atom({ plugin: 'swiftui-preview', key: 'shot' } as const, null)
11
12// The terminal's cell in screen points; a guess until measured.
13let cell: Cell = { width: 8, height: 17 }
14async function measureCell($: EngineInterface) {
15 const run = await $.process.run(['python3', '-c', CELL_SCRIPT]).catch(() => undefined)
16 const [x, y] = (run?.stdout ?? '').split(' ').map(Number)
17 // ponytail: assumes a 2x (Retina) display; read the backing scale if a 1x screen shows up
18 if (x && y) cell = { width: x / 2, height: y / 2 }
19}
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 await $.command.register({ name: 'swiftui-preview', description: 'Show the last SwiftUI preview' })
24
25 return next(e)
26 })
27
28 on('command.run', { command: 'swiftui-preview' }, async $ => {
29 await $.ui.open({ id: PANE, title: TITLE })
30
31 return { text: 'SwiftUI preview pane opened.' }
32 })
33
34 // Xcode MCP's RenderPreview (`claude mcp add xcode -- xcrun mcpbridge`), whatever the server is named.
35 on('tool.call', async ($, e, next) => {
36 const ran = await next(e)
37 if (!e.tool.endsWith('__RenderPreview') || ran.isError) return ran
38 const rendered = parseRendered(ran.text)
39 if (!rendered) return ran
40 try {
41 const { base64 } = await $.fs.read(rendered.path, { as: 'bytes' })
42 const size = pngSize(base64)
43 await measureCell($)
44 await update($, shot, () => ({ ...rendered, ...size, generation: Date.now() }) satisfies Shot)
45 await $.ui.open({ id: PANE, title: TITLE })
46 } catch {
47 // The pane is a bonus: never fail the model's call over it.
48 }
49
50 return ran
51 })
52
53 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
54 const current = await read($, shot)
55 if (e.surface !== 'terminal') {
56 const { Text } = $.ui.resolve(e)
57 return <Text>{current ? `Preview saved to ${current.path}` : 'No preview yet.'}</Text>
58 }
59 const { Box, Text, Image } = $.ui.resolve(e)
60 if (!current) return <Text dimColor>No preview yet. Ask Claude to preview a view.</Text>
61 const fit = fitImage(current.width, current.height, cell, e.props.bodyColumns, e.props.scroll.bodyRows - 1)
62
63 return (
64 <Box flexDirection="column">
65 <Text dimColor wrap="truncate">{current.label}</Text>
66 <Image
67 source={{ file: current.path, format: 'png', generation: current.generation }}
68 columns={fit.columns}
69 rows={fit.rows}
70 alt={`SwiftUI preview: ${current.path}`}
71 />
72 </Box>
73 )
74 })
75}
76hooks/lib.ts 59 lines1// Pure helpers: reading Xcode's RenderPreview answer and sizing the pane.
2
3export type Rendered = { path: string; label: string }
4
5// Xcode MCP's RenderPreview answers JSON text; undefined when it rendered nothing.
6export function parseRendered(text: string | undefined): Rendered | undefined {
7 try {
8 const out = JSON.parse(text ?? '') as {
9 previewSnapshotPath?: string
10 displayName?: string
11 renderedDestination?: { deviceModelName?: string }
12 }
13 if (!out.previewSnapshotPath) return undefined
14 const device = out.renderedDestination?.deviceModelName
15
16 return { path: out.previewSnapshotPath, label: [out.displayName, device].filter(Boolean).join(' · ') }
17 } catch {
18 return undefined
19 }
20}
21
22// Width and height from a base64 PNG's IHDR chunk (its first 24 bytes are 32 base64 chars).
23export function pngSize(base64: string): { width: number; height: number } {
24 const head = atob(base64.slice(0, 32))
25 const u32 = (at: number) => [0, 1, 2, 3].reduce((n, i) => n * 256 + head.charCodeAt(at + i), 0)
26
27 return { width: u32(16), height: u32(20) }
28}
29
30const clamp = (n: number) => Math.max(1, Math.min(255, n))
31
32// A terminal cell in screen points: what `CELL_SCRIPT` prints, halved for a 2x display.
33export type Cell = { width: number; height: number }
34
35// Prints a cell's size in device pixels (`x y`) from the tty of the nearest ancestor that has one.
36export const CELL_SCRIPT = `
37import fcntl, os, struct, subprocess, termios
38p = os.getppid()
39while p > 1:
40 tty, ppid = (subprocess.run(['ps', '-o', 'tty=,ppid=', '-p', str(p)], capture_output=True, text=True).stdout.split() + ['', '1'])[:2]
41 if tty not in ('', '??'): break
42 p = int(ppid)
43rows, cols, x, y = struct.unpack('HHHH', fcntl.ioctl(os.open('/dev/' + tty, os.O_RDONLY | os.O_NOCTTY), termios.TIOCGWINSZ, bytes(8)))
44print(x / cols, y / rows)
45`
46
47// iOS snapshots are @3x, so the view's own size in points is pixels / 3, shown 1:1 in screen
48// points like Xcode's canvas at 100%, shrunk to fit the pane and never enlarged (that pixelates).
49// ponytail: Xcode caps a snapshot's long side at 1500 px (a whole device screen), so a capped one
50// is only fit to the pane; read the real scale from the device model if that ever matters.
51export function fitImage(width: number, height: number, cell: Cell, columns: number, rows: number) {
52 const isCapped = Math.max(width, height) >= 1500
53 const rowsPerColumn = (height / width) * (cell.width / cell.height)
54 const natural = isCapped ? Infinity : width / 3 / cell.width
55 const c = clamp(Math.round(Math.min(natural, columns, rows / rowsPerColumn)))
56
57 return { columns: c, rows: clamp(Math.round(c * rowsPerColumn)) }
58}
59types/index.d.ts 14 lines1export type Shot = {
2 path: string
3 width: number
4 height: number
5 label: string
6 generation: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'swiftui-preview': { shot: Shot | null }
12 }
13}
14