Before/after simulator screenshots on the same step as a UI edit, side by side in a pane, with a changed-pixel percentage and pinned baselines

A Claude Code mod that gives you visual feedback on the same step as a UI edit.
When Claude edits a UI file, Mirror Pane screenshots your iOS simulator or Android emulator just before the edit. It waits for hot reload, takes a second screenshot, and shows the two side by side in a pane. If ImageMagick is installed, it also reports what share of pixels changed (beyond a small colour tolerance). Claude gets a one-line note with that number and a caveat that clocks, spinners or a slow reload can affect it, so it knows a visual check happened. The note never includes the image itself.
.tsx, .jsx, .swift, .dart, .css/.scss/.less, .vue, .svelte, storyboards/xibs, Android layout, drawable and colour/theme XML, Compose .kt under ui/, screens/ or components/, and .ts/.js under components/, screens/, app/, ui/, views/ or pages/ inside the project. Tests, stories, Package.swift and app/api/ routes are skipped. Add more in the plugin settings (extraPatterns)./mirror device <udid|serial>, or else the only booted simulator or emulator-*. A physical Android phone is only ever used when you pick it, since its screen can show notifications. If several are running, it never guesses; it tells you once to pick./mirror baseline pins the current screen as known-good for that file. Later edits also report drift against it./mirror open the pane
/mirror on | off turn captures on or off
/mirror device <id|auto> choose the simulator (UDID) or Android device (adb serial)
/mirror baseline pin the current screen as the baseline for its file
xcrun simctl. Android: adb.brew install imagemagick). Without it, captures still happen and the percentage is left out.Settings (/plugin configure mirror-pane@ccdwyer-mods): delayMs (hot-reload wait, default 2000, at most 5000), noteToModel, extraPatterns, historySize.
/plugin marketplace add ccdwyer/claude-mods
/plugin install mirror-pane@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=mirror}tool.callui.render{component=Pane, requestId=mirror-pane}ui.render{component=AbovePrompt}A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod never refuses anything; it only adds the one-line note to UI edits.
It runs entirely on your machine and sends nothing over the network. Screenshots stay in ~/.cache/mirror-pane/ (readable only by you, and screenshots older than 7 days are deleted the next time Mirror Pane runs, except pinned baselines), and only a one-line note reaches Claude.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 557 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Next, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { Device, Pair } from '../types'
5
6const pairs = atom({ plugin: 'mirror-pane', key: 'pairs' } as const, [])
7const cursor = atom({ plugin: 'mirror-pane', key: 'cursor' } as const, -1)
8const isOn = atom({ plugin: 'mirror-pane', key: 'isOn' } as const, true)
9const warned = atom({ plugin: 'mirror-pane', key: 'warned' } as const, [])
10const quietUntil = atom({ plugin: 'mirror-pane', key: 'quietUntil' } as const, 0)
11
12const PANE = 'mirror-pane'
13const DEVICE_KEY = 'device'
14const CAPTURE_MS = 6000
15const MAX_DELAY = 5000
16// How long a UI edit waits for another capture of the same device before skipping its shots: tries of
17// 100ms sleeps, counted rather than timed so the wait never depends on the hook's clock.
18const LOCK_TRIES = 40
19// A lock whose owner token hasn't been refreshed for this long belongs to a session that died mid-capture.
20const LOCK_STALE_MS = 30000
21// How often a held lock's token is refreshed, for as long as the capture (edit included) runs.
22const LOCK_HEARTBEAT_MS = 5000
23// Screenshots older than this are deleted the next time Mirror Pane runs.
24const KEEP_MINUTES = 7 * 24 * 60
25// After a device stops answering, skip shots for this long.
26const QUIET_MS = 5 * 60 * 1000
27
28// Files whose edit can change what a screen looks like.
29const UI_EXT = /\.(tsx|jsx|swift|css|scss|sass|less|vue|svelte|dart|storyboard|xib)$/i
30const NOT_UI = /(\.(test|spec|stories|story)\.[^/]+$|\/(__tests__|__mocks__|tests?|e2e)\/|\/Package\.swift$|\/(app|pages)\/api\/|\/(route|actions?|middleware|instrumentation)\.(ts|js|mjs)$|\.server\.(ts|js|mjs)$|\/server\/)/i
31// UI sources that only take effect after a native rebuild, not a hot reload.
32const NEEDS_REBUILD = /\.(swift|storyboard|xib|kt)$|\/res\/[^/]+\/[^/]+\.xml$/i
33const UI_DIRS = /\/(components|screens|app|ui|views|pages|widgets|layouts?)\//i
34const SCRIPT_EXT = /\.(ts|js|mjs)$/i
35const ANDROID_RES = /\/res\/(layout[^/]*|drawable[^/]*|values[^/]*\/(colors|themes?|styles|dimens)[^/]*)\b.*\.xml$|\/res\/(layout|drawable)[^/]*\/[^/]+\.xml$/i
36const COMPOSE = /\.kt$/i
37
38const UDID = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i
39const SERIAL = /^[\w.:-]{1,64}$/
40const EMULATOR = /^emulator-\d+$/
41
42type Options = { delayMs: number; noteToModel: boolean; extraPatterns: string; historySize: number }
43type Run = { exitCode: number; stdout: string; stderr: string }
44
45// Folder hints are read below the project root, so a checkout living in ~/app/ doesn't make every file UI.
46export function isUiFile(path: string, extra: string, root = ''): boolean {
47 const extras = extra.split(',').map(s => s.trim()).filter(s => s.length > 0)
48 if (extras.some(s => path.includes(s))) return true
49 const inside = root !== '' && path.startsWith(`${root}/`) ? path.slice(root.length) : path
50 if (NOT_UI.test(inside)) return false
51 if (UI_EXT.test(path) || ANDROID_RES.test(path)) return true
52 if (COMPOSE.test(path)) return /\/(ui|compose|screens?|components)\//i.test(inside)
53 return SCRIPT_EXT.test(path) && UI_DIRS.test(inside)
54}
55
56export function parseDevice(arg: string): Device | null {
57 const id = arg.trim()
58 if (UDID.test(id)) return { kind: 'ios', id }
59 if (SERIAL.test(id) && id.length > 0) return { kind: 'android', id }
60 return null
61}
62
63export function bootedIos(json: string): string[] | null {
64 try {
65 const data = JSON.parse(json) as { devices?: Record<string, Array<{ udid?: string; state?: string }>> }
66 if (data.devices === undefined) return null
67 const out: string[] = []
68 for (const list of Object.values(data.devices)) {
69 for (const d of list) if (d.state === 'Booted' && typeof d.udid === 'string') out.push(d.udid)
70 }
71 return out
72 } catch {
73 return null
74 }
75}
76
77// Online adb devices, split into emulators (safe to pick) and physical phones (never auto-picked).
78export function onlineAndroid(text: string): { emulators: string[]; phones: string[] } {
79 const online = text
80 .split('\n')
81 .slice(1)
82 .map(line => line.trim().split(/\s+/))
83 .filter(parts => parts.length >= 2 && parts[1] === 'device' && SERIAL.test(parts[0] ?? ''))
84 .map(parts => parts[0] as string)
85 return { emulators: online.filter(s => EMULATOR.test(s)), phones: online.filter(s => !EMULATOR.test(s)) }
86}
87
88// ImageMagick `compare -metric AE` ends stderr with the metric (warnings may come first). ImageMagick 7 prints
89// the differing-pixel count and then the fraction of the frame, `240000 (0.12)`; ImageMagick 6 prints the count
90// alone. Any other last line (a size mismatch, an error) means no comparison. 0 means no pixel differed beyond
91// the colour tolerance.
92export function changedPercent(stderr: string, size: string): number | null {
93 const lines = stderr.split('\n').map(l => l.trim()).filter(l => l !== '')
94 const m = (lines[lines.length - 1] ?? '').match(/^(\d+(?:\.\d+)?(?:e[+-]?\d+)?)(?:\s*\((\d+(?:\.\d+)?(?:e[+-]?\d+)?)\))?$/i)
95 if (m === null) return null
96 if (m[2] !== undefined) {
97 const fraction = Number.parseFloat(m[2])
98 return Number.isFinite(fraction) && fraction >= 0 ? Math.min(100, fraction * 100) : null
99 }
100 const count = Number.parseFloat(m[1] as string)
101 const [w, hgt] = size.trim().split(/\s+/).map(Number)
102 if (!Number.isFinite(count) || count < 0 || !w || !hgt) return null
103 return Math.min(100, (count / (w * hgt)) * 100)
104}
105
106export const pct = (n: number | null) =>
107 n === null ? '?' : n === 0 ? '0%' : n < 0.1 ? '<0.1%' : `${Math.round(n * 10) / 10}%`
108
109export function noteFor(
110 kind: 'ios' | 'android',
111 hasBefore: boolean,
112 changed: number | null,
113 drift: number | null,
114 rebuild = false,
115 overlapped = false,
116): string {
117 const screen = kind === 'ios' ? 'iOS simulator' : 'Android'
118 const vs = drift === null ? '' : ` Against the pinned baseline, ${pct(drift)} of pixels differ.`
119 const caveat = rebuild
120 ? ' This file type usually needs a native rebuild, so the after-shot is probably from before the edit took effect: it is not evidence either way.'
121 : ' Status-bar clocks, spinners or a reload that had not painted yet can affect this; no change does not prove the edit has no visual effect.'
122 if (!hasBefore) return `Mirror Pane: only an after-shot of the ${screen} screen was taken (the before-shot failed), so there is no comparison.${vs} The user can review it with /mirror.`
123 if (overlapped) return `Mirror Pane: another edit to the same device landed while this one's screenshots were taken, so the comparison is not attributable to this edit.${vs} The user can review it with /mirror.`
124 if (changed === null) return `Mirror Pane: the ${screen} screen was captured before and after this edit, but no comparison was available.${vs} The user can review it with /mirror.`
125 const what =
126 changed === 0
127 ? rebuild
128 ? 'the screen has not changed yet'
129 : 'no pixels differed beyond a small colour tolerance'
130 : `about ${pct(changed)} of pixels differ`
131 return `Mirror Pane: between the ${screen} screenshots taken just before and ${'after'} this edit, ${what}.${caveat}${vs} The user can review it with /mirror.`
132}
133
134const base = (path: string) => path.split('/').pop() ?? path
135const clampDelay = (n: number) => Math.max(0, Math.min(MAX_DELAY, Number.isFinite(n) ? n : 2000))
136const uniqueId = () => crypto.randomUUID().slice(0, 8)
137
138// A rejection (timeout, spawn failure) comes back as null, distinct from a non-zero exit.
139async function run($: EngineInterface, argv: string[], timeoutMs = CAPTURE_MS): Promise<Run | null> {
140 try {
141 return await $.process.run(argv, { timeoutMs })
142 } catch {
143 return null
144 }
145}
146
147// Waits that must not count against the hook's own time budget run as processes.
148async function pause($: EngineInterface, ms: number) {
149 if (ms > 0) await run($, ['sleep', (ms / 1000).toFixed(2)], ms + 2000)
150}
151
152async function installed($: EngineInterface, tool: string): Promise<boolean> {
153 const found = await run($, ['/bin/sh', '-c', `command -v ${tool}`], 3000)
154 return found !== null && found.exitCode === 0
155}
156
157async function cacheRoot($: EngineInterface): Promise<string | null> {
158 const home = await $.env.get('HOME')
159 return typeof home === 'string' && home.length > 0 ? `${home}/.cache/mirror-pane` : null
160}
161
162// Screenshots of this session live in a folder of their own, readable only by the user.
163async function shotDir($: EngineInterface, sub: 'session' | 'baselines' | 'locks'): Promise<string | null> {
164 const root = await cacheRoot($)
165 if (root === null) return null
166 const session = await $.session.id()
167 const dir = sub === 'session' ? `${root}/sessions/${session}` : `${root}/${sub}`
168 const made = await run($, ['mkdir', '-p', '-m', '700', dir])
169 if (made === null || made.exitCode !== 0) return null
170 await run($, ['chmod', '700', root])
171 return dir
172}
173
174// The chosen device, or the only booted simulator/emulator. A physical phone is only ever used when chosen
175// explicitly ('phone' when it is the only thing attached); a discovery tool that is installed but fails leaves
176// the count unknown.
177async function pickDevice($: EngineInterface): Promise<Device | 'several' | 'phone' | null> {
178 const stored = parseDevice(String((await $.store.get(DEVICE_KEY)) ?? ''))
179 if (stored !== null) return stored
180 const found: Device[] = []
181 let unsure = false
182 let phones = 0
183 if (await installed($, 'xcrun')) {
184 const ios = await run($, ['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'])
185 const ids = ios !== null && ios.exitCode === 0 ? bootedIos(ios.stdout) : null
186 if (ids === null) unsure = true
187 for (const id of ids ?? []) found.push({ kind: 'ios', id })
188 }
189 if (await installed($, 'adb')) {
190 const adb = await run($, ['adb', 'devices'])
191 if (adb === null || adb.exitCode !== 0) unsure = true
192 else {
193 const online = onlineAndroid(adb.stdout)
194 phones = online.phones.length
195 for (const id of online.emulators) found.push({ kind: 'android', id })
196 }
197 }
198 if (found.length === 0) return phones > 0 && !unsure ? 'phone' : null
199 if (found.length === 1 && !unsure && phones === 0) return found[0] as Device
200 return 'several'
201}
202
203async function capture($: EngineInterface, device: Device, file: string): Promise<boolean> {
204 if (device.kind === 'ios') {
205 const shot = await run($, ['xcrun', 'simctl', 'io', device.id, 'screenshot', '--type=png', file])
206 return shot !== null && shot.exitCode === 0
207 }
208 // screencap to the device, then pull: stdout is text here, so PNG bytes can't come back through it.
209 const remote = `/data/local/tmp/mirror-pane-${uniqueId()}.png`
210 try {
211 const cap = await run($, ['adb', '-s', device.id, 'shell', 'screencap', '-p', remote])
212 if (cap === null || cap.exitCode !== 0) return false
213 const pull = await run($, ['adb', '-s', device.id, 'pull', remote, file])
214 return pull !== null && pull.exitCode === 0
215 } finally {
216 await run($, ['adb', '-s', device.id, 'shell', 'rm', '-f', remote], 3000)
217 }
218}
219
220async function compare($: EngineInterface, a: string, b: string): Promise<number | null> {
221 const diff = await run($, ['compare', '-metric', 'AE', '-fuzz', '3%', a, b, 'null:'])
222 if (diff === null || diff.exitCode > 1) return null
223 const size = await run($, ['identify', '-format', '%w %h', b])
224 if (size === null || size.exitCode !== 0) return null
225 return changedPercent(diff.stderr, size.stdout)
226}
227
228async function forget($: EngineInterface, paths: string[]) {
229 if (paths.length > 0) await run($, ['rm', '-f', '--', ...paths], 3000)
230}
231
232type Lock = { path: string; token: string; beat: { cancel: () => void } }
233
234const OWNER = 'owner'
235const OVERLAP = 'overlap'
236
237// Remove a lock only if its owner token still reads `token`; the check and the removal are one shell run.
238async function releaseIf($: EngineInterface, path: string, token: string) {
239 await run($, ['/bin/sh', '-c', '[ "$(cat "$1/owner" 2>/dev/null)" = "$2" ] && rm -f "$1/owner" "$1/overlap" && rmdir "$1"', 'sh', path, token], 3000)
240}
241
242// A lock per device shared by every session on the machine: mkdir is atomic, and an owner token inside it is
243// refreshed every few seconds for as long as the capture runs, the edit's own wait (a permission prompt, say)
244// included. Only a token that stopped being refreshed is broken, and only by matching its exact value. A
245// waiter that gives up marks the lock so the holder knows another edit landed during its shots.
246async function lockDevice($: EngineInterface, device: Device, next: Next<'tool.call'>): Promise<Lock | null> {
247 const locks = await shotDir($, 'locks')
248 if (locks === null) return null
249 const path = `${locks}/${device.id}`
250 const token = `${await $.clock.now()}-${uniqueId()}`
251 for (let tries = 0; ; tries += 1) {
252 const took = await run($, ['mkdir', path], 3000)
253 if (took !== null && took.exitCode === 0) {
254 await run($, ['/bin/sh', '-c', 'printf %s "$2" > "$1/owner"', 'sh', path, token], 3000)
255 const beat = $.clock.every(LOCK_HEARTBEAT_MS, () => {
256 void run($, ['touch', `${path}/${OWNER}`], 3000)
257 })
258 return { path, token, beat }
259 }
260 try {
261 const owner = await $.fs.stat(`${path}/${OWNER}`)
262 if ((await $.clock.now()) - owner.mtimeMs > LOCK_STALE_MS) {
263 const stale = await run($, ['cat', `${path}/${OWNER}`], 3000)
264 if (stale !== null && stale.exitCode === 0) await releaseIf($, path, stale.stdout)
265 }
266 } catch {
267 // No owner token yet (just made) or gone: try again.
268 }
269 if (next.signal.aborted || tries >= LOCK_TRIES) {
270 await run($, ['touch', `${path}/${OVERLAP}`], 3000)
271 return null
272 }
273 await pause($, 100)
274 }
275}
276
277async function unlockDevice($: EngineInterface, lock: Lock) {
278 lock.beat.cancel()
279 await releaseIf($, lock.path, lock.token)
280}
281
282async function sawOverlap($: EngineInterface, lock: Lock): Promise<boolean> {
283 try {
284 await $.fs.stat(`${lock.path}/${OVERLAP}`)
285 return true
286 } catch {
287 return false
288 }
289}
290
291async function baselineFor($: EngineInterface, device: Device, file: string): Promise<string | null> {
292 const path = await $.store.get(`baseline:${device.id}:${file}`)
293 return typeof path === 'string' ? path : null
294}
295
296async function pinBaseline($: EngineInterface): Promise<string> {
297 const list = await read($, pairs)
298 const at = await read($, cursor)
299 const pair = list[at < 0 ? list.length - 1 : at]
300 if (pair === undefined) return 'Mirror Pane: nothing captured yet. Edit a UI file first.'
301 const dir = await shotDir($, 'baselines')
302 if (dir === null) return 'Mirror Pane: could not find a folder for the baseline.'
303 const target = `${dir}/${pair.device}-${pair.id}-${uniqueId()}.png`
304 const copied = await run($, ['cp', pair.after, target])
305 if (copied === null || copied.exitCode !== 0) return 'Mirror Pane: could not copy that screenshot.'
306 const key = `baseline:${pair.device}:${pair.file}`
307 const old = await $.store.get(key)
308 await $.store.set(key, target)
309 if (typeof old === 'string' && old !== target) await forget($, [old])
310 return `Mirror Pane: pinned the current ${base(pair.file)} screen as its baseline. Later edits show drift against it.`
311}
312
313async function pinAndToast($: EngineInterface) {
314 $.ui.toast(await pinBaseline($))
315}
316
317async function commandText($: EngineInterface, args: string): Promise<string> {
318 const [verb = '', rest = ''] = args.trim().split(/\s+(.*)/s)
319 switch (verb) {
320 case '':
321 await $.ui.open({ id: PANE, title: 'Mirror Pane' })
322 return 'Mirror Pane opened.'
323 case 'on':
324 case 'off':
325 await update($, isOn, () => verb === 'on')
326 await update($, quietUntil, () => 0)
327 return `Mirror Pane is ${verb}.`
328 case 'device': {
329 await update($, quietUntil, () => 0)
330 await update($, warned, () => [])
331 if (rest === '' || rest === 'auto') {
332 await $.store.delete(DEVICE_KEY)
333 return 'Mirror Pane: device cleared; it will use the only booted simulator or emulator (never a physical phone).'
334 }
335 const device = parseDevice(rest)
336 if (device === null) return 'Mirror Pane: give an iOS simulator UDID or an adb serial.'
337 await $.store.set(DEVICE_KEY, device.id)
338 return `Mirror Pane: capturing from ${device.kind === 'ios' ? 'iOS simulator' : 'Android device'} ${device.id}.`
339 }
340 case 'baseline':
341 return pinBaseline($)
342 default:
343 return 'Usage: /mirror [on | off | device <udid|serial|auto> | baseline]'
344 }
345}
346
347// One toast per kind of problem, shown again only after that problem went away and came back.
348async function warnOnce($: EngineInterface, kind: string, text: string) {
349 if ((await read($, warned)).includes(kind)) return
350 await update($, warned, list => [...list.filter(k => k !== kind), kind])
351 $.ui.toast(text)
352}
353
354async function clearWarning($: EngineInterface, kind: string) {
355 if ((await read($, warned)).includes(kind)) await update($, warned, list => list.filter(k => k !== kind))
356}
357
358async function sweepOld($: EngineInterface) {
359 const root = await cacheRoot($)
360 if (root === null) return
361 await run($, ['find', `${root}/sessions`, '-type', 'f', '-name', '*.png', '-mmin', `+${KEEP_MINUTES}`, '-delete'])
362 await run($, ['find', `${root}/sessions`, '-mindepth', '1', '-type', 'd', '-empty', '-delete'])
363}
364
365// One capture cycle: before-shot, the edit, the reload wait, after-shot. Every path that does not
366// keep the pair deletes both files, and the edit's own result or exception always passes through.
367async function cycle(
368 $: EngineInterface,
369 opts: Options,
370 e: ToolCallInput,
371 next: Next<'tool.call'>,
372 device: Device,
373 dir: string,
374 file: string,
375 lock: Lock,
376): Promise<ToolCallResult> {
377 const id = await $.clock.now()
378 const tag = `${id}-${uniqueId()}`
379 const before = `${dir}/${tag}-before.png`
380 const after = `${dir}/${tag}-after.png`
381 let kept = false
382 try {
383 const hasBefore = await capture($, device, before)
384 if (!hasBefore) await quiet($, device)
385
386 const ran = await next(e)
387 if (ran.deny !== undefined || ran.isError === true) return ran
388 if ((ran.result as { staged?: boolean } | undefined)?.staged === true) return ran
389 if (!hasBefore) return ran
390
391 await pause($, clampDelay(Number(opts.delayMs)))
392 if (!(await capture($, device, after))) {
393 await quiet($, device)
394 return ran
395 }
396 await clearWarning($, 'quiet')
397 const mixed = await sawOverlap($, lock)
398 const changed = await compare($, before, after)
399 const pinned = await baselineFor($, device, file)
400 const drift = pinned !== null ? await compare($, pinned, after) : null
401 const pair: Pair = { id, file, device: device.id, before, after, changed, drift, ...(mixed ? { overlapped: true } : {}), at: id }
402 const keep = Math.max(1, Math.min(30, Number(opts.historySize) || 10))
403 let dropped: Pair[] = []
404 await update($, pairs, list => {
405 const all = [...list, pair]
406 dropped = all.slice(0, Math.max(0, all.length - keep))
407 return all.slice(-keep)
408 })
409 kept = true
410 await update($, cursor, () => -1)
411 await forget($, dropped.flatMap(p => (p.before === null ? [p.after] : [p.before, p.after])))
412 await sweepOld($)
413 $.ui.status(`🪞 ${base(file)} ${mixed ? 'changed (another edit overlapped)' : `${pct(changed)} of pixels changed`}`)
414
415 if (opts.noteToModel === false) return ran
416 return { ...ran, context: [...(ran.context ?? []), noteFor(device.kind, true, changed, drift, NEEDS_REBUILD.test(file), mixed)] }
417 } finally {
418 if (!kept) await forget($, [before, after])
419 }
420}
421
422// A device that stopped answering is left alone for a while, with one hint.
423async function quiet($: EngineInterface, device: Device) {
424 const now = await $.clock.now()
425 await update($, quietUntil, () => now + QUIET_MS)
426 $.ui.status('🪞 mirror-pane: device did not answer')
427 await warnOnce($, 'quiet', `Mirror Pane: ${device.id} did not answer, so screenshots are paused for 5 minutes. Pick another with /mirror device <udid|serial>, or /mirror device auto.`)
428}
429
430async function onToolCall($: EngineInterface, opts: Options, e: ToolCallInput, next: Next<'tool.call'>): Promise<ToolCallResult> {
431 if (e.tool !== 'Edit' && e.tool !== 'Write') return next(e)
432 const file = String((e as { file_path?: unknown }).file_path ?? '')
433 const root = await $.session.cwd()
434 if (!isUiFile(file, String(opts.extraPatterns ?? ''), root) || !(await read($, isOn))) return next(e)
435 if ((await $.clock.now()) < (await read($, quietUntil))) return next(e)
436
437 const device = await pickDevice($)
438 if (device === 'several') {
439 await warnOnce($, 'several', 'Mirror Pane: more than one simulator/device is running (or a physical phone is attached), so it will not guess. Pick one with /mirror device <udid|serial>.')
440 return next(e)
441 }
442 if (device === 'phone') {
443 await warnOnce($, 'phone', 'Mirror Pane: only a physical phone is attached. It never picks a phone on its own (its screen can show notifications or codes); opt in with /mirror device <serial>.')
444 return next(e)
445 }
446 if (device === null) return next(e)
447 await clearWarning($, 'several')
448 await clearWarning($, 'phone')
449 const dir = await shotDir($, 'session')
450 if (dir === null) return next(e)
451
452 const lock = await lockDevice($, device, next)
453 if (lock === null) return next(e)
454 try {
455 return await cycle($, opts, e, next, device, dir, file, lock)
456 } finally {
457 await unlockDevice($, lock)
458 }
459}
460
461async function onStart($: EngineInterface) {
462 await $.command.register({
463 name: 'mirror',
464 description: 'Mirror Pane: before/after screenshots of UI edits',
465 argumentHint: '[on|off|device <id>|baseline]',
466 immediate: true,
467 })
468 await sweepOld($)
469}
470
471export const register: Register = (on, options) => {
472 const opts = options as Options
473
474 on('session.start', async ($, e, next) => {
475 await onStart($)
476 return next(e)
477 })
478
479 on('command.run', { command: 'mirror' }, async ($, e) => ({ text: await commandText($, e.args) }))
480
481 on('tool.call', async ($, e, next) => onToolCall($, opts, e, next))
482
483 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
484 const list = await read($, pairs)
485 const at = await read($, cursor)
486 const { Box, Text, Button } = $.ui.resolve(e)
487 if (list.length === 0) {
488 return <Text dimColor>No screenshots yet. Edit a UI file with a simulator or emulator running.</Text>
489 }
490 const index = at < 0 || at >= list.length ? list.length - 1 : at
491 const pair = list[index] as Pair
492 // An Image takes 1 to 255 columns.
493 const width = Math.max(10, Math.min(255, Math.floor(((e.props.bodyColumns ?? 80) - 3) / 2)))
494 const tall = Math.max(6, Math.min(60, (e.viewport?.rows ?? 30) - 8))
495 const header = `${base(pair.file)} · ${pair.overlapped === true ? 'changed while another edit overlapped' : `${pct(pair.changed)} of pixels changed`}${pair.drift === null ? '' : ` · ${pct(pair.drift)} vs baseline`} · ${index + 1}/${list.length}`
496 const step = (by: number) => () => update($, cursor, cur => {
497 const from = cur < 0 ? list.length - 1 : cur
498 return Math.max(0, Math.min(list.length - 1, from + by))
499 })
500 const controls = (
501 <Box>
502 <Button key="prev" label="◀ prev" onPress={step(-1)} />
503 <Text> </Text>
504 <Button key="next" label="next ▶" onPress={step(1)} />
505 <Text> </Text>
506 <Button key="pin" label="pin baseline" onPress={() => pinAndToast($)} />
507 </Box>
508 )
509 if (e.surface !== 'terminal') {
510 return (
511 <Box flexDirection="column">
512 <Text>{header}</Text>
513 <Text dimColor>Before: {pair.before ?? 'not captured'}</Text>
514 <Text dimColor>After: {pair.after}</Text>
515 {controls}
516 </Box>
517 )
518 }
519 const { Image } = $.ui.resolve(e)
520 return (
521 <Box flexDirection="column">
522 <Text>{header}</Text>
523 <Box flexDirection="row">
524 <Box flexDirection="column" width={width}>
525 <Text dimColor>before</Text>
526 {pair.before === null ? (
527 <Text dimColor>(no before-shot)</Text>
528 ) : (
529 <Image key="before" source={{ file: pair.before, format: 'png' }} columns={width} rows={tall} alt={`before: ${base(pair.file)}`} />
530 )}
531 </Box>
532 <Text> </Text>
533 <Box flexDirection="column" width={width}>
534 <Text dimColor>after</Text>
535 <Image key="after" source={{ file: pair.after, format: 'png' }} columns={width} rows={tall} alt={`after: ${base(pair.file)}`} />
536 </Box>
537 </Box>
538 {controls}
539 </Box>
540 )
541 })
542
543 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
544 const list = await read($, pairs)
545 const last = list[list.length - 1]
546 if (last === undefined || e.props.hasSurvey || !(await read($, isOn))) return next(e)
547 const { Box, Text } = $.ui.resolve(e)
548 const below = await next(e)
549 return (
550 <Box flexDirection="column">
551 <Text dimColor>🪞 {base(last.file)} {pct(last.changed)} of pixels changed · /mirror</Text>
552 {below}
553 </Box>
554 )
555 })
556}
557types/index.d.ts 27 lines1export type Device = { kind: 'ios' | 'android'; id: string }
2export type Pair = {
3 id: number
4 file: string
5 device: string
6 before: string | null
7 after: string
8 changed: number | null
9 drift: number | null
10 // Another edit to the same device landed while this pair was being taken, so the change isn't this edit's alone.
11 overlapped?: boolean
12 at: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'mirror-pane': {
18 pairs: Pair[]
19 cursor: number
20 isOn: boolean
21 // Which one-time hints were shown: 'several', 'phone', 'quiet'. Each is cleared when its condition ends.
22 warned: string[]
23 quietUntil: number
24 }
25 }
26}
27