Puts the iOS Simulator beside your Claude Code session: a live screenshot, dark mode and text size switches, your app's own logs, and tools that let Claude see…

The iOS Simulator beside your Claude Code session, and a way for Claude to see it.
simpane is a Claude Code mod for people building iOS apps. It puts a live picture of the booted simulator in a pane, with switches for dark mode and text size and your app's own log lines. It also gives Claude three tools, so that after changing a view it can take a screenshot and look, read what your app logged, or flip to dark mode and check again, without asking you to describe the screen.
Needs macOS with Xcode, and Claude Code 2.1.295 or later.
/plugin marketplace add griches/simpane
/plugin install simpane@simpane
Or from a shell:
claude plugin marketplace add griches/simpane
claude plugin install simpane@simpane
Run /reload-plugins in a session that is already open.
/simpane opens it. It also opens by itself when Claude installs or launches an app, or builds for a simulator.
iPhone 17
iOS 27.0 · light · text large
┌──────────────┐
│ │
│ (screen) │
│ │
└──────────────┘
Tideline · mobi.bouncingball.Tideline
r: Refresh l: Live on a: Dark u: Text + d: Text −
s: Send to Claude g: Logs
| Key | What it does |
|---|---|
r | Takes a fresh screenshot |
l | Live on or off. On, the picture is retaken every two seconds while the pane is on screen |
a | Switches the simulator between light and dark |
u d | Steps Dynamic Type up or down, through the accessibility sizes |
s | Puts the screenshot's path in your prompt, to ask Claude about what you are looking at |
g | Shows the last minute of your app's own log |
n | Moves to the next simulator, when more than one is booted |
With no simulator booted, the pane offers the last three you used, to boot with one key.
The picture is drawn in terminals that show images: Ghostty, kitty and others with the kitty graphics protocol. Elsewhere, and in the desktop app, the pane shows the device, the app and the screenshot's path, and Claude's tools work the same.
| Tool | What it does |
|---|---|
screenshot | Takes a screenshot and answers its path, with the device, appearance, text size and the app in front. Claude reads the file to see the screen |
logs | Answers what your app logged lately: print, NSLog, os_log and Logger output and every error or fault in its process. System chatter is removed and repeats are folded. Takes seconds, contains and bundleId |
set | Sets light or dark appearance, a Dynamic Type size, a clean 9:41 status bar, or opens a URL or deep link. Answers a fresh screenshot |
In a folder with an Xcode project, a workspace or a Package.swift, Claude has the tools from the first prompt. In any other folder they are declared the first time a booted simulator is seen, so a session that never touches iOS pays nothing for them.
Things to ask once it is installed:
A simulator's log for one app runs to thousands of lines a minute, nearly all of it the system talking to itself. simpane keeps:
It drops activity markers and Apple-subsystem chatter, folds identical lines that follow each other into one with a count, and cuts long lines. Claude gets the last 150 lines at most.
Both under simpane in /config.
| Setting | Default | What it does |
|---|---|---|
| Live refresh, in seconds | 2 | 1, 5, or off for refresh on demand only |
| Open the pane | on | Off: only on /simpane |
simctl has no way to.simctl io screenshot, which takes about a second, so Live is a slideshow and not video.bundleId to the logs tool to name another.xcpane reads xcodebuild output into a pane of errors. With both, Claude builds, sees why it failed, fixes it, runs it and looks at the result.
simpane makes no network requests and calls no model. It runs xcrun simctl and sips on your Mac and writes screenshots to your temporary folder.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. See LICENSE.
hooks/register.tsx 558 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { App, Device, Screen, Shot } from '../types'
5import { filterLog, fit, parseApp, parseDevices, parseSize, readCommand } from './simctl'
6
7const PANE = 'simpane'
8const TITLE = 'Simulator'
9const COMMAND = 'simpane'
10// Patterns, since the mod's own tools are not among the tools the type declarations list.
11const SCREENSHOT_TOOL = /^mcp__simpane__screenshot$/
12const LOGS_TOOL = /^mcp__simpane__logs$/
13const SET_TOOL = /^mcp__simpane__set$/
14const XCODE_MCP_TOOL = /^mcp__.+__(BuildProject|RunAllTests|RunSomeTests)$/
15/** The longest side, in pixels, of the copy the pane draws and Claude reads. */
16const SMALL_SIDE = 1000
17const SETTLE_MS = 1500
18const PANE_LOG_LINES = 14
19const TOOL_LOG_LINES = 150
20const APPLE_PROJECT = /\.(xcodeproj|xcworkspace)$|^(Package\.swift|project\.yml|Project\.swift|Podfile)$/
21const TEXT_SIZES = [
22 'extra-small', 'small', 'medium', 'large', 'extra-large', 'extra-extra-large', 'extra-extra-extra-large',
23 'accessibility-medium', 'accessibility-large', 'accessibility-extra-large', 'accessibility-extra-extra-large', 'accessibility-extra-extra-extra-large',
24]
25const EMPTY: Screen = { status: 'unknown', device: null, devices: [], candidates: [], shot: null, appearance: '', textSize: '', app: null }
26
27const screen = atom({ plugin: 'simpane', key: 'screen' } as const, EMPTY)
28const isLive = atom({ plugin: 'simpane', key: 'isLive' } as const, true)
29const logs = atom({ plugin: 'simpane', key: 'logs' } as const, null)
30const isBusy = atom({ plugin: 'simpane', key: 'isBusy' } as const, false)
31
32/** What cannot be kept in `$.state`: which simulator and app the person or Claude last pointed at. */
33const chosen = { udid: null as string | null, bundleId: null as string | null, hasTools: false }
34
35/** Runs a command and answers its output, or null when it failed or could not start. */
36const run = async ($: EngineInterface, argv: readonly string[], timeoutMs = 20_000) => {
37 try {
38 const ran = await $.process.run(argv, { timeoutMs })
39
40 return ran.exitCode === 0 ? ran.stdout : null
41 } catch {
42 return null
43 }
44}
45
46const simctl = ($: EngineInterface, args: readonly string[], timeoutMs?: number) => run($, ['xcrun', 'simctl', ...args], timeoutMs)
47
48const folder = async ($: EngineInterface) => `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/simpane`
49
50/** Takes a screenshot of `device` and makes the scaled copy the pane draws. */
51const capture = async ($: EngineInterface, device: Device): Promise<Shot | null> => {
52 const dir = await folder($)
53 const path = `${dir}/${device.udid}.png`
54 const smallPath = `${dir}/${device.udid}-small.png`
55 await run($, ['/bin/mkdir', '-p', dir])
56
57 if ((await simctl($, ['io', device.udid, 'screenshot', '--type=png', path])) === null) {
58 return null
59 }
60
61 const scaled = await run($, ['/usr/bin/sips', '-Z', String(SMALL_SIDE), path, '--out', smallPath])
62 const size = parseSize((await run($, ['/usr/bin/sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path])) ?? '')
63 const at = await $.clock.now()
64
65 return { path, smallPath: scaled === null ? path : smallPath, width: size?.width ?? 0, height: size?.height ?? 0, at, generation: at }
66}
67
68const appOf = async ($: EngineInterface, device: Device): Promise<App | null> => {
69 const [running, installed] = await Promise.all([
70 simctl($, ['spawn', device.udid, 'launchctl', 'list']),
71 run($, ['/bin/sh', '-c', 'xcrun simctl listapps "$1" | plutil -convert json -o - -', 'sh', device.udid]),
72 ])
73
74 return running === null || installed === null ? null : parseApp(running, installed, chosen.bundleId)
75}
76
77/** Declares the tools Claude can use, the first time a simulator is seen: a session with none pays nothing for them. */
78const offerTools = async ($: EngineInterface) => {
79 if (chosen.hasTools) {
80 return
81 }
82
83 chosen.hasTools = true
84 await $.tool.register({
85 name: 'screenshot',
86 description:
87 'Takes a screenshot of the booted iOS Simulator and answers the path of the PNG, with the device, its appearance, text size and the app in front. Read the file at that path to see the screen. Call it after a change to the UI to check how it looks, instead of asking the user to describe it.',
88 inputSchema: {
89 type: 'object',
90 properties: { size: { type: 'string', enum: ['small', 'full'], description: 'small (default): scaled to 1000 pixels, cheaper to read. full: the device resolution.' } },
91 },
92 isDeferred: false,
93 })
94 await $.tool.register({
95 name: 'logs',
96 description:
97 "Answers what the app in front of the booted iOS Simulator logged lately: its own messages (print, NSLog, os_log, Logger) and every error or fault in its process, with system chatter removed and repeats folded. Call it when the app misbehaves or crashes, instead of reading the device's whole log.",
98 inputSchema: {
99 type: 'object',
100 properties: {
101 seconds: { type: 'number', description: 'How far back to read, 60 by default, 600 at most.' },
102 contains: { type: 'string', description: 'Keep only lines containing this text, compared without case.' },
103 bundleId: { type: 'string', description: "The app's bundle identifier; the app in front by default." },
104 },
105 },
106 })
107 await $.tool.register({
108 name: 'set',
109 description:
110 'Changes how the booted iOS Simulator looks, then answers a fresh screenshot path: light or dark appearance, Dynamic Type text size, a clean 9:41 status bar for store screenshots, or a URL to open (a deep link or a web page). Use it to check a screen in dark mode or at accessibility text sizes.',
111 inputSchema: {
112 type: 'object',
113 properties: {
114 appearance: { type: 'string', enum: ['light', 'dark'] },
115 textSize: { type: 'string', enum: [...TEXT_SIZES, 'increment', 'decrement'] },
116 statusBar: { type: 'string', enum: ['clean', 'default'] },
117 openUrl: { type: 'string' },
118 },
119 },
120 })
121}
122
123/** Reads the simulators and, when one is booted, its screen, its settings and the app in front. */
124const look = async ($: EngineInterface): Promise<Screen> => {
125 const listed = await simctl($, ['list', 'devices', '-j'])
126
127 if (listed === null) {
128 const none: Screen = { ...EMPTY, status: 'unavailable' }
129 await update($, screen, () => none)
130
131 return none
132 }
133
134 const devices = parseDevices(listed, 'Booted')
135 const device = devices.find(one => one.udid === chosen.udid) ?? devices[0]
136
137 if (device === undefined) {
138 const none: Screen = { ...EMPTY, status: 'absent', candidates: parseDevices(listed, 'Shutdown').filter(one => /^i(Phone|Pad)/.test(one.name)).slice(0, 3) }
139 await update($, screen, () => none)
140
141 return none
142 }
143
144 chosen.udid = device.udid
145 await offerTools($).catch(() => undefined)
146 const [shot, appearance, textSize, app] = await Promise.all([
147 capture($, device),
148 simctl($, ['ui', device.udid, 'appearance']),
149 simctl($, ['ui', device.udid, 'content_size']),
150 appOf($, device),
151 ])
152 const seen: Screen = {
153 status: 'ready',
154 device,
155 devices,
156 candidates: [],
157 shot,
158 appearance: (appearance ?? '').trim(),
159 textSize: (textSize ?? '').trim(),
160 app,
161 }
162 await update($, screen, () => seen)
163
164 return seen
165}
166
167/** `look`, one at a time: a refresh asked for while one runs is dropped, since that one is already fresh. */
168const refresh = async ($: EngineInterface): Promise<Screen> => {
169 if (await read($, isBusy)) {
170 return read($, screen)
171 }
172
173 await update($, isBusy, () => true)
174
175 try {
176 return await look($)
177 } finally {
178 await update($, isBusy, () => false).catch(() => undefined)
179 }
180}
181
182const appLogs = async ($: EngineInterface, device: Device, app: App, seconds: number, contains: string | null) => {
183 const predicate = `process == "${app.executable.replace(/["\\]/g, '')}" AND (NOT subsystem BEGINSWITH "com.apple." OR messageType == error OR messageType == fault)`
184 const raw = await simctl($, ['spawn', device.udid, 'log', 'show', '--last', `${seconds}s`, '--style', 'compact', '--predicate', predicate], 60_000)
185
186 return raw === null ? null : filterLog(raw, app.executable, contains)
187}
188
189/** `iPhone 17 · iOS 27.0 · dark · text large`. */
190const headline = (seen: Screen) =>
191 [seen.device?.name, seen.device?.runtime, seen.appearance, seen.textSize === '' ? '' : `text ${seen.textSize}`].filter(Boolean).join(' · ')
192
193/** What a tool answers after looking: where the picture is and what is on screen. */
194const described = (seen: Screen, size: 'small' | 'full') => {
195 if (seen.status === 'unavailable') {
196 return "simpane could not run `xcrun simctl`: it needs macOS with Xcode's command line tools."
197 }
198
199 if (seen.device === null) {
200 return 'No simulator is booted. Boot one with `xcrun simctl boot <device>` or from Xcode, then call this again.'
201 }
202
203 if (seen.shot === null) {
204 return `${headline(seen)}: the screenshot could not be taken.`
205 }
206
207 const path = size === 'full' ? seen.shot.path : seen.shot.smallPath
208 const front = seen.app === null ? 'None of your own apps is running.' : `App in front: ${seen.app.name} (${seen.app.bundleId}).`
209
210 return [`${headline(seen)}. ${front}`, `Screenshot (${seen.shot.width}×${seen.shot.height} on the device): ${path}`, 'Read that file to see the screen.'].join('\n')
211}
212
213const openPane = ($: EngineInterface) => {
214 void $.ui.open({ id: PANE, title: TITLE, columns: 46 }).catch(() => undefined)
215}
216
217/** The timer that keeps the picture fresh, while there is one. */
218const timer: { ticker: { cancel: () => void } | null } = { ticker: null }
219
220const unwatch = () => {
221 timer.ticker?.cancel()
222 timer.ticker = null
223}
224
225/** Keeps the picture fresh while the pane is on screen and Live is on. */
226const watch = ($: EngineInterface, every: number) => {
227 if (timer.ticker !== null || every === 0) {
228 return
229 }
230
231 timer.ticker = $.clock.every(every, () => {
232 void (async () => {
233 const [pane] = (await $.ui.panes()).filter(one => one.id === PANE)
234
235 if (pane === undefined) {
236 unwatch()
237 } else if (pane.isShown && pane.isPlaced && (await read($, isLive))) {
238 await refresh($)
239 }
240 })().catch(() => undefined)
241 })
242}
243
244export const register: Register = (on, options) => {
245 const every = options.refresh === 'off' ? 0 : Number(options.refresh ?? 2) * 1000 || 2000
246 const autoOpen = options.autoOpen !== false
247 on('session.start', async ($, e, next) => {
248 await $.command.register({
249 name: COMMAND,
250 description: 'Show the iOS Simulator beside the session: live screenshot, appearance, text size, app logs',
251 })
252 // In an Apple project Claude has the tools from the first prompt; elsewhere they wait until a simulator is seen.
253 const entries = await $.fs.list().catch(() => [])
254
255 if (entries.some(one => APPLE_PROJECT.test(one.name))) {
256 await offerTools($).catch(() => undefined)
257 }
258
259 // A pane left open across a reload keeps being refreshed.
260 const panes = await $.ui.panes().catch(() => [])
261
262 if (panes.some(one => one.id === PANE)) {
263 watch($, every)
264 }
265
266 return next(e)
267 })
268
269 on('command.run', { command: COMMAND }, async ($, e) => {
270 await $.ui.open({ id: PANE, title: TITLE, columns: 46 })
271 const seen = await refresh($)
272 watch($, every)
273
274 if (seen.status === 'unavailable') {
275 return { text: "simpane needs macOS with Xcode's command line tools: `xcrun simctl` could not be run." }
276 }
277
278 return { text: seen.device === null ? 'Simulator pane opened. No simulator is booted.' : `Simulator pane opened: ${headline(seen)}.` }
279 })
280
281 on('ui.close', { id: PANE }, ($, e, next) => {
282 unwatch()
283
284 return next(e)
285 })
286
287 on('tool.call', { tool: SCREENSHOT_TOOL }, async ($, e) => {
288 const size = (e as { size?: unknown }).size === 'full' ? 'full' : 'small'
289
290 return { result: described(await look($), size) }
291 })
292
293 on('tool.call', { tool: LOGS_TOOL }, async ($, e) => {
294 const asked = e as { seconds?: unknown; contains?: unknown; bundleId?: unknown }
295 const seconds = Math.min(600, Math.max(5, typeof asked.seconds === 'number' && Number.isFinite(asked.seconds) ? Math.round(asked.seconds) : 60))
296
297 if (typeof asked.bundleId === 'string' && asked.bundleId !== '') {
298 chosen.bundleId = asked.bundleId
299 }
300
301 const seen = await look($)
302
303 if (seen.device === null) {
304 return { result: described(seen, 'small') }
305 }
306
307 if (seen.app === null) {
308 return { result: 'None of your own apps is running in the simulator, so there is no app log to read. Launch the app, then call this again.' }
309 }
310
311 const lines = await appLogs($, seen.device, seen.app, seconds, typeof asked.contains === 'string' && asked.contains !== '' ? asked.contains : null)
312
313 if (lines === null) {
314 return { result: `The log of ${seen.device.name} could not be read.` }
315 }
316
317 const shown = lines.slice(-TOOL_LOG_LINES)
318 const head = `${seen.app.name} (${seen.app.bundleId}) on ${seen.device.name}, last ${seconds}s: ${lines.length} line${lines.length === 1 ? '' : 's'} of its own${lines.length > shown.length ? `, the last ${shown.length} shown` : ''}.`
319
320 return { result: lines.length === 0 ? `${head} Nothing logged by the app and no errors in its process.` : [head, ...shown].join('\n') }
321 })
322
323 on('tool.call', { tool: SET_TOOL }, async ($, e) => {
324 const asked = e as { appearance?: unknown; textSize?: unknown; statusBar?: unknown; openUrl?: unknown }
325 const before = await read($, screen)
326 const device = before.device ?? (await look($)).device
327
328 if (device === null) {
329 return { result: described(await read($, screen), 'small') }
330 }
331
332 const done: string[] = []
333 const apply = async (what: string, args: readonly string[]) => {
334 done.push((await simctl($, args)) === null ? `${what}: failed` : what)
335 }
336
337 if (asked.appearance === 'light' || asked.appearance === 'dark') {
338 await apply(`appearance ${asked.appearance}`, ['ui', device.udid, 'appearance', asked.appearance])
339 }
340
341 if (typeof asked.textSize === 'string' && [...TEXT_SIZES, 'increment', 'decrement'].includes(asked.textSize)) {
342 await apply(`text size ${asked.textSize}`, ['ui', device.udid, 'content_size', asked.textSize])
343 }
344
345 if (asked.statusBar === 'clean') {
346 await apply('clean status bar', ['status_bar', device.udid, 'override', '--time', '9:41', '--batteryState', 'charged', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'])
347 } else if (asked.statusBar === 'default') {
348 await apply('default status bar', ['status_bar', device.udid, 'clear'])
349 }
350
351 if (typeof asked.openUrl === 'string' && /^[a-z][a-z0-9+.-]*:/i.test(asked.openUrl)) {
352 await apply(`opened ${asked.openUrl}`, ['openurl', device.udid, asked.openUrl])
353 }
354
355 if (done.length === 0) {
356 return { result: 'Nothing to change: name an appearance, a text size, a status bar or a URL.' }
357 }
358
359 await $.clock.sleep(SETTLE_MS).catch(() => undefined)
360
361 return { result: [`Done: ${done.join(', ')}.`, described(await look($), 'small')].join('\n') }
362 })
363
364 // A command that changes what the simulator shows is followed by a fresh look, once the screen has settled.
365 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
366 const { changesScreen, launched } = readCommand(e.command)
367 const ran = await next(e)
368
369 if (changesScreen && ran.deny === undefined) {
370 chosen.bundleId = launched ?? chosen.bundleId
371 $.clock.after(SETTLE_MS, () => {
372 void (async () => {
373 const seen = await refresh($)
374
375 if (autoOpen && seen.device !== null) {
376 openPane($)
377 watch($, every)
378 }
379 })().catch(() => undefined)
380 })
381 }
382
383 return ran
384 })
385
386 on('tool.call', { tool: XCODE_MCP_TOOL }, async ($, e, next) => {
387 const ran = await next(e)
388 $.clock.after(SETTLE_MS, () => {
389 void refresh($).catch(() => undefined)
390 })
391
392 return ran
393 })
394
395 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
396 const seen = await read($, screen)
397 const live = await read($, isLive)
398 const lines = await read($, logs)
399
400 if (e.surface === 'terminal') {
401 const { Box, Button, Image, Text } = $.ui.resolve(e)
402 const room = { columns: Math.max(8, e.props.bodyColumns - 1), rows: Math.max(6, (e.viewport?.rows ?? 40) - (lines === null ? 9 : 9 + PANE_LOG_LINES)) }
403 const box = seen.shot === null || seen.shot.width === 0 ? null : fit(seen.shot.width, seen.shot.height, room)
404
405 return (
406 <Box flexDirection="column">
407 {seen.status === 'unavailable' && <Text dimColor>simpane needs macOS with Xcode's command line tools.</Text>}
408 {seen.status === 'unknown' && <Text dimColor>Looking for a booted simulator…</Text>}
409 {seen.status === 'absent' && (
410 <Box flexDirection="column">
411 <Text dimColor>No simulator is booted.</Text>
412 {seen.candidates.map((device, at) => (
413 <Button
414 key={`boot-${device.udid}`}
415 hotkey={String(at + 1)}
416 plain
417 label={`Boot ${device.name} (${device.runtime})`}
418 onPress={async () => {
419 chosen.udid = device.udid
420 await simctl($, ['boot', device.udid], 120_000)
421 await simctl($, ['bootstatus', device.udid], 180_000)
422 await refresh($)
423 }}
424 />
425 ))}
426 <Button key="refresh" hotkey="r" plain label="Look again" onPress={() => refresh($)} />
427 </Box>
428 )}
429 {seen.status === 'ready' && (
430 <Box flexDirection="column">
431 <Text bold>{seen.device?.name ?? ''}</Text>
432 <Text dimColor>{[seen.device?.runtime, seen.appearance, seen.textSize === '' ? '' : `text ${seen.textSize}`].filter(Boolean).join(' · ')}</Text>
433 {seen.shot !== null && box !== null && (
434 <Image
435 key="screen"
436 source={{ file: seen.shot.smallPath, format: 'png', generation: seen.shot.generation }}
437 columns={box.columns}
438 rows={box.rows}
439 alt={`Screenshot saved to ${seen.shot.path}`}
440 />
441 )}
442 {seen.shot === null && <Text color="warning">The screenshot could not be taken.</Text>}
443 <Text dimColor>{seen.app === null ? 'None of your own apps is running.' : `${seen.app.name} · ${seen.app.bundleId}`}</Text>
444 <Box flexDirection="row" gap={2} flexWrap="wrap" marginTop={1}>
445 <Button key="refresh" hotkey="r" plain label="Refresh" onPress={() => refresh($)} />
446 <Button key="live" hotkey="l" plain label={live ? 'Live on' : 'Live off'} onPress={() => update($, isLive, lives => !lives)} />
447 <Button
448 key="appearance"
449 hotkey="a"
450 plain
451 label={seen.appearance === 'dark' ? 'Light' : 'Dark'}
452 onPress={async () => {
453 await simctl($, ['ui', seen.device?.udid ?? 'booted', 'appearance', seen.appearance === 'dark' ? 'light' : 'dark'])
454 await refresh($)
455 }}
456 />
457 <Button
458 key="bigger"
459 hotkey="u"
460 plain
461 label="Text +"
462 onPress={async () => {
463 await simctl($, ['ui', seen.device?.udid ?? 'booted', 'content_size', 'increment'])
464 await refresh($)
465 }}
466 />
467 <Button
468 key="smaller"
469 hotkey="d"
470 plain
471 label="Text −"
472 onPress={async () => {
473 await simctl($, ['ui', seen.device?.udid ?? 'booted', 'content_size', 'decrement'])
474 await refresh($)
475 }}
476 />
477 </Box>
478 <Box flexDirection="row" gap={2} flexWrap="wrap">
479 <Button
480 key="send"
481 hotkey="s"
482 plain
483 label="Send to Claude"
484 onPress={async () => {
485 const fresh = await refresh($)
486
487 if (fresh.shot !== null) {
488 await $.prompt.fill({ text: `Look at the simulator screenshot at ${fresh.shot.smallPath} `, mode: 'insert' })
489 }
490 }}
491 />
492 <Button
493 key="logs"
494 hotkey="g"
495 plain
496 label={lines === null ? 'Logs' : 'Hide logs'}
497 onPress={async () => {
498 if (lines !== null || seen.device === null || seen.app === null) {
499 await update($, logs, () => null)
500 } else {
501 await update($, logs, () => ['Reading…'])
502 const found = await appLogs($, seen.device, seen.app, 60, null)
503 await update($, logs, () => found ?? ['The log could not be read.'])
504 }
505 }}
506 />
507 {seen.devices.length > 1 && (
508 <Button
509 key="device"
510 hotkey="n"
511 plain
512 label="Next device"
513 onPress={async () => {
514 const at = seen.devices.findIndex(one => one.udid === seen.device?.udid)
515 chosen.udid = seen.devices[(at + 1) % seen.devices.length]?.udid ?? null
516 await refresh($)
517 }}
518 />
519 )}
520 </Box>
521 {lines !== null && (
522 <Box flexDirection="column" marginTop={1}>
523 <Text bold>{`Logs · last minute${seen.app === null ? '' : ` · ${seen.app.name}`}`}</Text>
524 {lines.length === 0 && <Text dimColor>Nothing logged by the app.</Text>}
525 {lines.slice(-PANE_LOG_LINES).map(line => (
526 <Text wrap="truncate-end" color={/ (error|fault): /.test(line) ? 'error' : undefined}>
527 {line.split('\n')[0] ?? ''}
528 </Text>
529 ))}
530 </Box>
531 )}
532 </Box>
533 )}
534 </Box>
535 )
536 }
537
538 // Other surfaces draw no pictures from a file: the facts and the path stand in.
539 const { Box, Text } = $.ui.resolve(e)
540
541 return (
542 <Box flexDirection="column">
543 {seen.status === 'unavailable' && <Text dimColor>simpane needs macOS with Xcode's command line tools.</Text>}
544 {(seen.status === 'absent' || seen.status === 'unknown') && <Text dimColor>No simulator is booted.</Text>}
545 {seen.status === 'ready' && (
546 <Box flexDirection="column">
547 <Text bold>{seen.device?.name ?? ''}</Text>
548 <Text dimColor>{[seen.device?.runtime, seen.appearance, seen.textSize === '' ? '' : `text ${seen.textSize}`].filter(Boolean).join(' · ')}</Text>
549 <Text dimColor>{seen.app === null ? 'None of your own apps is running.' : `${seen.app.name} · ${seen.app.bundleId}`}</Text>
550 {seen.shot !== null && <Text>{`Screenshot: ${seen.shot.path}`}</Text>}
551 <Text dimColor>The live picture is drawn in a terminal that shows images (Ghostty, kitty). Ask Claude to look at the simulator from here.</Text>
552 </Box>
553 )}
554 </Box>
555 )
556 })
557}
558hooks/simctl.ts 171 lines1import type { App, Device } from '../types'
2import { commands } from './shell'
3
4type Json = Record<string, unknown>
5
6const record = (value: unknown): Json => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : {})
7
8const list = (value: unknown): Json[] => (Array.isArray(value) ? value.map(record) : [])
9
10const text = (value: unknown) => (typeof value === 'string' ? value : '')
11
12const json = (raw: string): Json => {
13 try {
14 return record(JSON.parse(raw))
15 } catch {
16 return {}
17 }
18}
19
20/** `com.apple.CoreSimulator.SimRuntime.iOS-27-0` as `iOS 27.0`. */
21export const runtimeName = (identifier: string) => {
22 const [platform = '', ...version] = identifier.slice(identifier.lastIndexOf('.') + 1).split('-')
23
24 return version.length === 0 ? platform : `${platform} ${version.join('.')}`
25}
26
27/** The simulators `simctl list devices -j` lists, the most recently used first; `state` narrows them. */
28export const parseDevices = (raw: string, state?: 'Booted' | 'Shutdown'): Device[] =>
29 Object.entries(record(json(raw).devices))
30 .flatMap(([runtime, devices]) =>
31 list(devices)
32 .filter(one => one.isAvailable !== false && (state === undefined || one.state === state))
33 .map(one => ({ udid: text(one.udid), name: text(one.name), runtime: runtimeName(runtime), usedAt: text(one.lastUsedAt) })),
34 )
35 .filter(one => one.udid !== '')
36 .sort((a, b) => b.usedAt.localeCompare(a.usedAt))
37 .map(({ usedAt: _usedAt, ...device }) => device)
38
39/**
40 * The app in front: of the apps `launchctl list` shows running, the newest
41 * that `simctl listapps` (as JSON) says the developer installed, or the one
42 * `preferred` names when it is running.
43 */
44export const parseApp = (launchctl: string, apps: string, preferred: string | null): App | null => {
45 const installed = json(apps)
46 const running = [...launchctl.matchAll(/^(\d+)\s+\S+\s+UIKitApplication:([^[\s]+)/gm)]
47 .map(found => ({ pid: Number(found[1]), bundleId: found[2] ?? '' }))
48 .sort((a, b) => b.pid - a.pid)
49 const own = running.filter(one => record(installed[one.bundleId]).ApplicationType === 'User')
50 const picked = own.find(one => one.bundleId === preferred) ?? own[0]
51
52 if (picked === undefined) {
53 return null
54 }
55
56 const info = record(installed[picked.bundleId])
57 const executable = text(info.CFBundleExecutable)
58
59 return {
60 bundleId: picked.bundleId,
61 name: text(info.CFBundleDisplayName) || text(info.CFBundleName) || executable || picked.bundleId,
62 executable: executable || picked.bundleId.slice(picked.bundleId.lastIndexOf('.') + 1),
63 }
64}
65
66/** The pixel size `sips -g pixelWidth -g pixelHeight` printed. */
67export const parseSize = (raw: string) => {
68 const width = Number(/pixelWidth:\s*(\d+)/.exec(raw)?.[1] ?? 0)
69 const height = Number(/pixelHeight:\s*(\d+)/.exec(raw)?.[1] ?? 0)
70
71 return width > 0 && height > 0 ? { width, height } : null
72}
73
74/** A terminal cell is about twice as tall as it is wide. */
75const CELL_ASPECT = 2.1
76
77/** The box of terminal cells a picture fills without distortion, inside the room given. */
78export const fit = (width: number, height: number, room: { columns: number; rows: number }) => {
79 const columns = Math.max(1, Math.min(255, room.columns, Math.floor((room.rows * CELL_ASPECT * width) / height)))
80 const rows = Math.max(1, Math.min(255, room.rows, Math.round((columns * height) / width / CELL_ASPECT)))
81
82 return { columns, rows }
83}
84
85const LOG_LINE = /^\d{4}-\d\d-\d\d (\d\d:\d\d:\d\d)\.\d+\s+(\S+)\s+\S+\[\d+:[0-9a-f]+\]\s+(.*)$/
86const LEVEL: Record<string, string> = { E: 'error', F: 'fault', Df: '', Db: 'debug', I: 'info' }
87const LOG_WIDTH = 300
88
89/**
90 * An app's own lines out of `log show --style compact`: what it logged
91 * itself and every error or fault in its process, system chatter dropped,
92 * repeats folded, each line cut to a width.
93 */
94export const filterLog = (raw: string, executable: string, contains: string | null): string[] => {
95 const kept: { line: string; times: number }[] = []
96 const wanted = contains?.toLowerCase() ?? null
97
98 for (const line of raw.split('\n')) {
99 const found = LOG_LINE.exec(line)
100
101 if (found === null) {
102 // A message's later lines belong to the line kept before them.
103 const last = kept.at(-1)
104
105 if (last !== undefined && /^\s+\S/.test(line) && last.times === 1 && last.line.length < LOG_WIDTH * 3) {
106 last.line += `\n${line.slice(0, LOG_WIDTH)}`
107 }
108
109 continue
110 }
111
112 const [, time = '', type = '', rest = ''] = found
113 const isSerious = type === 'E' || type === 'F'
114 const subsystem = /^\[([^:\]]*):/.exec(rest)?.[1]
115 const library = /^\(([^)]+)\)/.exec(rest)?.[1]
116 const isOwn =
117 subsystem !== undefined
118 ? !subsystem.startsWith('com.apple.')
119 : library === undefined || library === executable || library.startsWith(`${executable}.`)
120
121 if (type === 'A' || (!isSerious && !isOwn)) {
122 continue
123 }
124
125 const message = rest.replace(/^\(([^)]+)\)\s*/, '').slice(0, LOG_WIDTH)
126
127 if (wanted !== null && !message.toLowerCase().includes(wanted)) {
128 continue
129 }
130
131 const told = `${time} ${LEVEL[type] === undefined || LEVEL[type] === '' ? '' : `${LEVEL[type]}: `}${message}`
132 const last = kept.at(-1)
133
134 if (last !== undefined && last.line.slice(9) === told.slice(9)) {
135 last.times += 1
136 } else {
137 kept.push({ line: told, times: 1 })
138 }
139 }
140
141 return kept.map(one => (one.times > 1 ? `${one.line} (×${one.times})` : one.line))
142}
143
144const SIMCTL_CHANGES = new Set([
145 'boot', 'shutdown', 'install', 'uninstall', 'launch', 'terminate', 'openurl', 'ui', 'status_bar', 'erase', 'addmedia', 'push', 'privacy', 'location',
146])
147
148/** What a Bash command does to a simulator: whether its screen may change, and the app it launches. */
149export const readCommand = (command: string): { changesScreen: boolean; launched: string | null } => {
150 let changesScreen = false
151 let launched: string | null = null
152
153 for (const one of commands(command)) {
154 const args = one.name === 'xcrun' ? one.args.filter(arg => !arg.startsWith('-')) : [one.name, ...one.args]
155 const [tool, verb] = args
156
157 if (tool === 'simctl' && verb !== undefined && SIMCTL_CHANGES.has(verb)) {
158 changesScreen = true
159
160 if (verb === 'launch') {
161 // `simctl launch [flags] <device> <bundle id> [arguments]`
162 launched = args.slice(2).filter(arg => !arg.startsWith('-'))[1] ?? launched
163 }
164 } else if (tool === 'xcodebuild' || one.name === 'xcodebuild') {
165 changesScreen ||= /simulator/i.test(command)
166 }
167 }
168
169 return { changesScreen, launched }
170}
171hooks/shell.ts 186 lines1export type Word = {
2 text: string
3 start: number
4 end: number
5 isRedirect: boolean
6 isDynamic: boolean
7}
8
9export type Command = {
10 /** The executable's name, any folder before it dropped. */
11 name: string
12 args: string[]
13 /** True when an argument is built at run time (`$VAR`, `$(...)`), so its text is not what runs. */
14 hasDynamicArgs: boolean
15}
16
17const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
18const WRAPPERS = new Set(['time', 'command', 'exec', 'env', 'nohup', 'sudo', 'caffeinate', '{', '!', 'if', 'then', 'else', 'do', 'while'])
19
20const split = (command: string): Word[][] => {
21 const segments: Word[][] = []
22 let words: Word[] = []
23 let word: Word | null = null
24 const open = (at: number): Word => {
25 word ??= { text: '', start: at, end: at, isRedirect: false, isDynamic: false }
26
27 return word
28 }
29 const push = (at: number) => {
30 if (word !== null) {
31 word.end = at
32 words.push(word)
33 word = null
34 }
35 }
36 const cut = (at: number) => {
37 push(at)
38
39 if (words.length > 0) {
40 segments.push(words)
41 }
42
43 words = []
44 }
45 const size = command.length
46 let i = 0
47
48 while (i < size) {
49 const c = command.charAt(i)
50 const following = command.charAt(i + 1)
51
52 if (c === '\\') {
53 if (following !== '\n') {
54 open(i).text += following
55 }
56
57 i += 2
58 } else if (c === "'") {
59 const close = command.indexOf("'", i + 1)
60 const stop = close < 0 ? size : close
61 open(i).text += command.slice(i + 1, stop)
62 i = stop + 1
63 } else if (c === '"') {
64 const quoted = open(i)
65 i += 1
66
67 while (i < size && command.charAt(i) !== '"') {
68 const inner = command.charAt(i)
69 const escaped = command.charAt(i + 1)
70
71 if (inner === '\\' && '\\"$`\n'.includes(escaped) && escaped !== '') {
72 quoted.text += escaped === '\n' ? '' : escaped
73 i += 2
74 } else {
75 quoted.isDynamic ||= inner === '$' || inner === '`'
76 quoted.text += inner
77 i += 1
78 }
79 }
80
81 i += 1
82 } else if (c === '$' && following === '(') {
83 const substituted = open(i)
84 let depth = 0
85 let stop = i + 1
86
87 for (; stop < size; stop += 1) {
88 const inner = command.charAt(stop)
89 depth += inner === '(' ? 1 : inner === ')' ? -1 : 0
90
91 if (depth === 0) {
92 break
93 }
94 }
95
96 substituted.isDynamic = true
97 substituted.text += command.slice(i, stop + 1)
98 i = stop + 1
99 } else if (c === '`') {
100 const close = command.indexOf('`', i + 1)
101 const stop = close < 0 ? size : close
102 const substituted = open(i)
103 substituted.isDynamic = true
104 substituted.text += command.slice(i, stop + 1)
105 i = stop + 1
106 } else if (c === '#' && word === null) {
107 const newline = command.indexOf('\n', i)
108 i = newline < 0 ? size : newline
109 } else if (c === ' ' || c === '\t') {
110 push(i)
111 i += 1
112 } else if (c === '>' || c === '<') {
113 const redirect = open(i)
114 redirect.isRedirect = true
115 redirect.text += c
116 i += 1
117 } else if (c === '&' && ('<>'.includes(command.charAt(i - 1) || ' ') || following === '>')) {
118 const redirect = open(i)
119 redirect.isRedirect = true
120 redirect.text += c
121 i += 1
122 } else if (';\n|&()'.includes(c)) {
123 cut(i)
124 i += 1
125 } else {
126 const plain = open(i)
127 plain.isDynamic ||= c === '$'
128 plain.text += c
129 i += 1
130 }
131 }
132
133 cut(size)
134
135 return segments
136}
137
138const analyse = (words: Word[]): Command | null => {
139 let i = 0
140
141 while (i < words.length) {
142 const text = words[i]?.text ?? ''
143
144 if (ASSIGNMENT.test(text)) {
145 i += 1
146 } else if (WRAPPERS.has(text)) {
147 i += 1
148
149 while (words[i]?.text.startsWith('-') === true) {
150 i += 1
151 }
152 } else {
153 break
154 }
155 }
156
157 const head = words[i]
158
159 if (head === undefined || head.isRedirect || head.isDynamic) {
160 return null
161 }
162
163 const rest = words.slice(i + 1)
164 const redirect = rest.findIndex(one => one.isRedirect)
165 const args = redirect < 0 ? rest : rest.slice(0, redirect)
166
167 return {
168 name: head.text.slice(head.text.lastIndexOf('/') + 1),
169 args: args.map(one => one.text),
170 hasDynamicArgs: args.some(one => one.isDynamic),
171 }
172}
173
174/**
175 * The commands a Bash command line runs, in order.
176 *
177 * Only a command standing at a command position counts: one inside a quoted
178 * string, a `$(...)` or a here-document is text, not something that runs here.
179 */
180export const commands = (command: string): Command[] =>
181 command.includes('<<')
182 ? []
183 : split(command)
184 .map(analyse)
185 .filter(one => one !== null)
186types/index.d.ts 54 lines1export type Device = {
2 udid: string
3 name: string
4 /** `iOS 27.0`, `watchOS 26.2`. */
5 runtime: string
6}
7
8/** A simulator that could be booted, offered when none is running. */
9export type Candidate = Device
10
11export type App = {
12 bundleId: string
13 name: string
14 /** The process name, which the device's log is filtered by. */
15 executable: string
16}
17
18export type Shot = {
19 /** The screenshot as the simulator took it. */
20 path: string
21 /** A copy scaled down for the pane and for Claude to read. */
22 smallPath: string
23 width: number
24 height: number
25 at: number
26 /** Changes with every capture, so a redraw reads the file again. */
27 generation: number
28}
29
30export type Screen = {
31 /** `absent`: no simulator is booted. `unavailable`: `xcrun simctl` could not be run on this machine. */
32 status: 'unknown' | 'ready' | 'absent' | 'unavailable'
33 device: Device | null
34 /** Every booted simulator, when more than one is. */
35 devices: Device[]
36 candidates: Candidate[]
37 shot: Shot | null
38 appearance: string
39 textSize: string
40 app: App | null
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'simpane': {
46 screen: Screen
47 isLive: boolean
48 /** The foreground app's own log lines, when the pane shows them. */
49 logs: string[] | null
50 isBusy: boolean
51 }
52 }
53}
54