SLOPSHOPPER

simpane

iPhone and Android simulators live in a side pane: watch, tap, swipe and type while your agent tests the app

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-16XyndoX/simpane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · simpane
│ ┃ Simulator ✕ › fix the failing auth test and add an audit log call │ ┃ [ iPhone ] Android ↻ │ ┃ No iOS simulator found. Install Xcode, then: ⏺ Read(src/auth.ts) │ ┃ xcrun simctl list ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /simpane │ ┃ ⎿ simpane: simpane: no simulator running; boot one from the pane. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Simulator
[ iPhone ] Android ↻ No iOS simulator found. Install Xcode, then: xcrun simctl list
README

<img src="assets/logo.svg" width="160" alt="simpane logo: a phone screen docked in a terminal pane, with a tap ripple">

<h1 align="center">simpane</h1>

<em>Your phone simulator, live in Claude Code.</em>

<img src="https://img.shields.io/badge/Claude%20Code-mod-3ee6b4?style=flat-square" alt="Claude Code mod"> <img src="https://img.shields.io/badge/iOS%20%C2%B7%20Android-simulators-4fb6f0?style=flat-square" alt="iOS and Android"> <img src="https://img.shields.io/badge/dependencies-0-a9c6cc?style=flat-square" alt="Zero dependencies"> <img src="https://img.shields.io/github/license/XyndoX/simpane?style=flat-square&color=ff7a59" alt="MIT license">


A Claude Code mod that shows an iPhone simulator or Android emulator screen live in a side pane. You can tap, swipe and type on it while your agent builds and tests the app.

Install

claude plugin marketplace add XyndoX/simpane
claude plugin install simpane@simpane --scope user

Or from inside Claude Code: /plugin marketplace add XyndoX/simpane, then /plugin install simpane@simpane. Then:

/simpane            # whatever is running, iPhone first
/simpane android

Use

You doThe device gets
clicktap
dragswipe
mouse wheelscroll
click the pane, then typetext (letters are sent together)
return / backspace / tab / arrowsthe same key
esc, ⌂home
◁ ▢ (Android)back, app switcher

If nothing is running, the pane lists your simulators or AVDs. Click one to boot it headless, so it shows up only in the pane.

Needs

  • A terminal that draws pictures: Ghostty, cmux or kitty. tmux does not.
  • iPhone: Xcode, plus AXe for input and fast frames: brew install cameroncooke/axe/axe. Without AXe the screen still mirrors (via xcrun simctl, about 1 frame a second), but it takes no input. idb's input does not work with Xcode 27.
  • Android: adb and emulator on the PATH or in ~/Library/Android/sdk, for example from Android Studio's Device Manager or brew install --cask android-commandlinetools. Android support is written but not yet tested on a real emulator — reports welcome.

Letting the agent test

The agent drives the same device from Bash, and you watch it happen in the pane:

axe describe-ui --udid <udid>                 # the accessibility tree: what's on screen and where
axe tap --label "Sign in" --udid <udid>       # tap by label or id
axe type "hello" --udid <udid>
axe screenshot --udid <udid> --output s.png   # then read the image
adb shell input tap 500 1200                  # Android
adb exec-out screencap -p > s.png

xcrun simctl list devices booted gives the udid.

How it works

About five times a second the device writes a PNG, using axe screenshot or adb exec-out screencap -p, into one of two temp files in turn. The mod swaps the pane's picture with $.ui.blit, and the terminal reads the file itself, so no pixels go through Claude Code. Clicks land on an empty overlay that reports where the press started and ended as fractions of the screen. The mod turns those into device points (pixels on Android).

License

MIT

Source 3 files
hooks/register.tsx 299 lines
1import type { EngineInterface, Register } from 'claude-code'
2import * as dev from './devices'
3import type { Choice, Device, Key, Platform } from './devices'
4import type { OverlayProps } from './overlay'
5
6const PANE = 'simpane'
7const FRAME_MS = 200 // a frame is taken as soon as the last one is in, at most this often
8const C = { fg: '#dadada', muted: '#6b6b6b', hot: '#a882ff', err: '#e06c75' }
9const KEYS: Record<string, Key> = { return: 'return', enter: 'return', backspace: 'backspace', delete: 'backspace', tab: 'tab', up: 'up', down: 'down', left: 'left', right: 'right', escape: 'home' }
10
11const S = {
12  platform: 'ios' as Platform,
13  found: { ios: { booted: [] as Device[], off: [] as Choice[] }, android: { booted: [] as Device[], off: [] as Choice[] } },
14  device: null as Device | null,
15  size: null as { width: number; height: number } | null, // the frame, in pixels
16  density: 1, // pixels per input unit: points on iOS, 1 on Android
17  axe: true,
18  frame: null as { file: string; generation: number } | null,
19  box: { cols: 0, rows: 0 },
20  down: null as [number, number] | null,
21  typed: '',
22  status: '',
23  error: '',
24  loop: null as { cancel: () => void } | null,
25  busy: false,
26  ticks: 0,
27  tmp: '/tmp',
28}
29const RUN_ID = Math.random().toString(36).slice(2, 8)
30const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
31
32async function run($: EngineInterface, argv: string[], timeoutMs = 10_000) {
33  return $.process.run(argv, { timeoutMs })
34}
35
36async function discover($: EngineInterface) {
37  try {
38    const ios = await run($, dev.listIos)
39    if (ios.exitCode === 0) S.found.ios = dev.parseIos(ios.stdout)
40  } catch { /* no Xcode */ }
41  try {
42    S.found.android = dev.parseAndroid((await run($, dev.listAndroid)).stdout)
43  } catch { /* no Android SDK */ }
44  S.axe = (await run($, dev.hasAxe).catch(() => ({ exitCode: 1 }))).exitCode === 0
45  pick(S.platform)
46}
47
48function pick(p: Platform) {
49  S.platform = p
50  const next = S.found[p].booted[0] ?? null
51  if (next?.id !== S.device?.id) {
52    S.device = next
53    S.size = null
54    S.frame = null
55    S.density = 1
56  }
57}
58
59// One frame: the device writes a PNG, the terminal reads it. Two files take
60// turns, so the terminal never reads one half-written.
61async function tick($: EngineInterface) {
62  if (S.busy) return
63  S.busy = true
64  try {
65    if (++S.ticks % 25 === 0 && !(await $.ui.panes()).some(p => p.id === PANE)) return stop()
66    const d = S.device
67    if (!d) return
68    const gen = (S.frame?.generation ?? 0) + 1
69    const file = `${S.tmp}/simpane-${RUN_ID}-${gen % 2}.png`
70    const r = await run($, dev.capture(d, file))
71    if (d !== S.device) return
72    if (r.exitCode !== 0) {
73      S.error = `no frame from ${d.name}: is it still running? (↻)`
74      return redraw($)
75    }
76    const size = dev.pngSize((await run($, dev.pngHead(file))).stdout)
77    if (!size) return
78    const resized = !S.size || S.size.width !== size.width || S.size.height !== size.height
79    S.size = size
80    S.frame = { file, generation: gen }
81    if (d.platform === 'ios' && S.axe && S.density === 1) {
82      const r = await run($, dev.describeIos(d.id)).catch(() => null)
83      S.density = r?.exitCode === 0 ? dev.densityOf(r.stdout, size.width) : 3
84    }
85    if (S.error) { S.error = ''; return redraw($) }
86    if (resized || !(await blit($))) redraw($)
87  } catch (err) {
88    S.error = String(err)
89    redraw($)
90  } finally {
91    S.busy = false
92  }
93}
94
95async function blit($: EngineInterface): Promise<boolean> {
96  if (!S.frame) return false
97  const r = await $.ui.blit({ requestId: PANE, key: 'screen', source: { file: S.frame.file, format: 'png', generation: S.frame.generation } })
98  return !('deny' in r && r.deny)
99}
100
101function stop() {
102  S.loop?.cancel()
103  S.loop = null
104}
105
106async function openPane($: EngineInterface, p?: Platform) {
107  await $.ui.open({ id: PANE, title: 'Simulator', focus: true, rows: 40, columns: 48 })
108  S.tmp = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/$/, '')
109  await refresh($, p)
110  S.loop ??= $.clock.every(FRAME_MS, () => void tick($))
111}
112
113// Finds what's running and picks a device on platform `p` (or wherever one runs).
114async function refresh($: EngineInterface, p?: Platform) {
115  await discover($)
116  if (p) pick(p)
117  else if (!S.device) pick(S.found.ios.booted.length || !S.found.android.booted.length ? 'ios' : 'android')
118  redraw($)
119}
120
121// Input goes out in the order it came; the next frame shows what it did.
122let queue = Promise.resolve()
123function send($: EngineInterface, argv: string[] | null, what: string) {
124  if (!argv) return
125  if (S.platform === 'ios' && !S.axe) {
126    S.error = 'iOS input needs AXe: brew install cameroncooke/axe/axe'
127    return redraw($)
128  }
129  queue = queue.then(async () => {
130    const r = await run($, argv).catch(err => ({ exitCode: 1, stderr: String(err) }))
131    S.status = r.exitCode === 0 ? what : ''
132    S.error = r.exitCode === 0 ? '' : `${what} failed: ${String(r.stderr).trim().split('\n').pop()}`
133    redraw($)
134  })
135}
136
137const toDevice = (at: [number, number]): [number, number] => [
138  (Math.min(1, Math.max(0, at[0])) * S.size!.width) / S.density,
139  (Math.min(1, Math.max(0, at[1])) * S.size!.height) / S.density,
140]
141
142function release($: EngineInterface, up: [number, number]) {
143  const down = S.down
144  S.down = null
145  if (!down || !S.device || !S.size) return
146  const a = toDevice(down)
147  const b = toDevice(up)
148  const cell = 1 / Math.max(1, S.box.cols) // a move under about a cell is a tap
149  if (Math.hypot(up[0] - down[0], (up[1] - down[1]) / 2) < cell) send($, dev.tap(S.device, ...a), `tap ${a.map(Math.round).join(',')}`)
150  else send($, dev.swipe(S.device, a, b, 250), 'swipe')
151}
152
153// Typed letters gather for a moment, then go as one text input.
154let typing: { cancel: () => void } | null = null
155function flush($: EngineInterface) {
156  typing?.cancel()
157  typing = null
158  if (S.typed && S.device) send($, dev.text(S.device, S.typed), `typed “${S.typed}”`)
159  S.typed = ''
160}
161
162function keyIn($: EngineInterface, k: string) {
163  if (!S.device) return
164  const ch = k === 'space' ? ' ' : k
165  if ([...ch].length === 1) {
166    S.typed += ch
167    typing?.cancel()
168    typing = $.clock.after(150, () => flush($))
169    return
170  }
171  flush($)
172  const named = KEYS[k]
173  if (named) send($, dev.key(S.device, named), named)
174}
175
176let wheel = 0
177let wheelTimer: { cancel: () => void } | null = null
178function scroll($: EngineInterface, by: number) {
179  if (!S.device || !S.size) return
180  wheel += by
181  wheelTimer ??= $.clock.after(120, () => {
182    wheelTimer = null
183    const d = S.device
184    if (!d || !S.size) return
185    // Wheel down reads further: the finger moves up.
186    const dy = Math.max(-0.35, Math.min(0.35, -wheel * 0.06))
187    wheel = 0
188    send($, dev.swipe(d, toDevice([0.5, 0.5 - dy / 2]), toDevice([0.5, 0.5 + dy / 2]), 150), 'scroll')
189  })
190}
191
192export const register: Register = on => {
193  on('session.start', async ($, e, next) => {
194    const r = await next(e)
195    await $.command.register({ name: 'simpane', description: 'iPhone / Android simulator screen in a pane: tap, swipe, type; args: [ios|android]' })
196    return r
197  })
198
199  on('command.run', { command: 'simpane' }, async ($, e) => {
200    const arg = (e.args ?? '').trim().toLowerCase()
201    await openPane($, arg === 'android' || arg === 'ios' ? arg : undefined)
202    const d = S.device
203    return { text: d ? `simpane: ${d.name}. Click to tap, drag to swipe, click the pane and type to type.` : `simpane: no ${S.platform === 'ios' ? 'simulator' : 'emulator'} running; boot one from the pane.` }
204  })
205
206  on('ui.message', async ($, e, next) => {
207    if (e.requestId !== PANE || e.element !== 'touch' || !e.data || typeof e.data !== 'object') return next(e)
208    const data = e.data as { down?: [number, number]; up?: [number, number]; key?: string }
209    if (data.down) S.down = data.down
210    else if (data.up) release($, data.up)
211    else if (typeof data.key === 'string') keyIn($, data.key)
212    return {}
213  })
214
215  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
216    scroll($, e.by)
217    return {}
218  })
219
220  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
221    if (e.surface !== 'terminal') return next(e)
222    const { Box, Text, Button, Client, Image } = $.ui.resolve(e)
223    const cols = Math.max(20, (e.props?.bodyColumns ?? 48) - 1)
224    const height = Math.max(10, e.props?.scroll?.bodyRows ?? 36)
225    const d = S.device
226    const tab = (p: Platform, label: string) => (
227      <Button key={`tab-${p}`} plain dimColor={S.platform !== p} label={S.platform === p ? `[ ${label} ]` : `  ${label}  `} onPress={() => { pick(p); void refresh($, p) }} />
228    )
229    const tool = (id: string, label: string, k: Key) => <Button key={id} plain dimColor label={label} onPress={() => { if (d) send($, dev.key(d, k), k) }} />
230    const header = (
231      <Box flexDirection="row">
232        {tab('ios', 'iPhone')}
233        {tab('android', 'Android')}
234        <Box flexGrow={1} />
235        {d && tool('home', ' ⌂ ', 'home')}
236        {d?.platform === 'android' && tool('back', ' ◁ ', 'back')}
237        {d?.platform === 'android' && tool('apps', ' ▢ ', 'apps')}
238        <Button key="refresh" plain dimColor label=" ↻" onPress={() => void refresh($, S.platform)} />
239      </Box>
240    )
241    const status = <Text color={S.error ? C.err : C.muted} wrap="truncate-end">{S.error || S.status || ' '}</Text>
242
243    if (!d) {
244      const off = S.found[S.platform].off
245      const none = S.platform === 'ios'
246        ? 'No iOS simulator found. Install Xcode, then: xcrun simctl list'
247        : 'No Android emulator found. brew install --cask android-commandlinetools, then sdkmanager + avdmanager (or Android Studio → Device Manager).'
248      return (
249        <Box flexDirection="column" minHeight={height}>
250          {header}
251          <Text color={C.fg}>{off.length ? `Nothing running. Boot one (headless, it shows here):` : none}</Text>
252          {off.slice(0, Math.max(1, height - 5)).map(c => (
253            <Button key={`boot-${c.id}`} plain label={`  ▸ ${c.name}`} onPress={() => {
254              S.status = `booting ${c.name}…`
255              redraw($)
256              void run($, dev.boot(S.platform, c.id), 60_000).then(async r => {
257                S.error = r.exitCode === 0 ? '' : `boot failed: ${r.stderr.trim()}`
258                // The emulator takes a while to show up in adb.
259                for (let i = 0; i < 30 && !S.device && !S.error; i++) {
260                  await new Promise(res => $.clock.after(2000, () => res(null)))
261                  await refresh($, S.platform)
262                }
263                S.status = ''
264                redraw($)
265              })
266            }} />
267          ))}
268          <Box flexGrow={1} />
269          {status}
270        </Box>
271      )
272    }
273
274    const box = S.size ? dev.fit(S.size.width, S.size.height, cols, height - 4) : { cols: Math.min(cols, 24), rows: height - 4 }
275    S.box = box
276    const screen = S.frame
277      ? <Image key="screen" source={{ file: S.frame.file, format: 'png', generation: S.frame.generation }} columns={box.cols} rows={box.rows} alt={d.name} />
278      : <Box width={box.cols} height={box.rows}><Text color={C.muted}>waiting for the first frame…</Text></Box>
279    return (
280      <Box flexDirection="column" minHeight={height}>
281        {header}
282        <Text color={C.hot} bold wrap="truncate-end">{`${d.name}${S.typed ? `  ⌨ ${S.typed}` : ''}`}</Text>
283        <Box flexDirection="row">
284          <Box flexGrow={1} />
285          <Box key="stage" position="relative" width={box.cols} height={box.rows}>
286            {screen}
287            <Box key="hit" position="absolute" top={0} left={0}>
288              <Client key="touch" module="./overlay.tsx" props={{ cols: box.cols, rows: box.rows } satisfies OverlayProps} />
289            </Box>
290          </Box>
291          <Box flexGrow={1} />
292        </Box>
293        {status}
294        <Text color={C.muted} wrap="truncate-end">click tap · drag swipe · wheel scroll · type into the focused pane · esc home</Text>
295      </Box>
296    )
297  })
298}
299
hooks/devices.ts 110 lines
1// What simpane runs: finding devices, taking a frame, and sending a tap, swipe,
2// text or key. Pure command builders and parsers, so they're easy to test.
3// iOS: AXe (frames, input; github.com/cameroncooke/AXe) and xcrun simctl (boot,
4// frames without AXe). Android: adb and emulator.
5
6export type Platform = 'ios' | 'android'
7export type Device = { platform: Platform; id: string; name: string }
8export type Choice = { id: string; name: string } // a device to boot
9export type Argv = string[]
10
11// The tools live outside the engine's PATH more often than not.
12const EXTRA = '$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator'
13export const sh = (script: string, ...args: string[]): Argv => ['/bin/sh', '-c', `PATH="$PATH:${EXTRA}"; ${script}`, 'sh', ...args]
14
15export const listIos = sh('xcrun simctl list devices available -j')
16export const listAndroid = sh('adb devices 2>/dev/null; echo ---; emulator -list-avds 2>/dev/null')
17export const hasAxe = sh('command -v axe >/dev/null')
18
19export function parseIos(json: string): { booted: Device[]; off: Choice[] } {
20  const booted: Device[] = []
21  const off: Choice[] = []
22  const all = (JSON.parse(json) as { devices: Record<string, { udid: string; name: string; state: string }[]> }).devices
23  for (const [runtime, list] of Object.entries(all)) {
24    if (!/iOS/.test(runtime)) continue
25    for (const d of list) {
26      if (d.state === 'Booted') booted.push({ platform: 'ios', id: d.udid, name: d.name })
27      else if (!off.some(o => o.name === d.name)) off.push({ id: d.udid, name: d.name })
28    }
29  }
30  const phonesFirst = (a: { name: string }, b: { name: string }) => Number(b.name.startsWith('iPhone')) - Number(a.name.startsWith('iPhone'))
31  return { booted: booted.sort(phonesFirst), off: off.sort(phonesFirst) }
32}
33
34export function parseAndroid(out: string): { booted: Device[]; off: Choice[] } {
35  const [devs = '', avds = ''] = out.split('---')
36  const booted = devs.split('\n').map(l => l.trim().split(/\s+/)).filter(p => p[1] === 'device').map(p => ({ platform: 'android' as const, id: p[0]!, name: p[0]! }))
37  const off = avds.split('\n').map(l => l.trim()).filter(Boolean).map(n => ({ id: n, name: n.replace(/_/g, ' ') }))
38  return { booted, off }
39}
40
41// Boots headless: the screen lives in the pane, not in a window.
42export const boot = (p: Platform, id: string): Argv =>
43  p === 'ios'
44    ? sh('xcrun simctl boot "$1"', id)
45    : sh('nohup emulator -avd "$1" -no-window -no-audio -no-boot-anim >/dev/null 2>&1 &', id)
46
47export const capture = (d: Device, file: string): Argv =>
48  d.platform === 'ios'
49    ? sh('if command -v axe >/dev/null; then axe screenshot --udid "$1" --output "$2"; else xcrun simctl io "$1" screenshot --type=png "$2"; fi >/dev/null 2>&1', d.id, file)
50    : sh('adb -s "$1" exec-out screencap -p > "$2"', d.id, file)
51
52// The first 24 bytes of a PNG hold its width and height (big-endian, at 16 and 20).
53export const pngHead = (file: string): Argv => sh('head -c 24 "$1" | od -An -tu1', file)
54export function pngSize(od: string): { width: number; height: number } | null {
55  const b = od.trim().split(/\s+/).map(Number)
56  if (b.length < 24 || b[1] !== 80 || b[2] !== 78 || b[3] !== 71) return null // \x89PNG
57  const u32 = (i: number) => ((b[i]! << 24) >>> 0) + (b[i + 1]! << 16) + (b[i + 2]! << 8) + b[i + 3]!
58  return { width: u32(16), height: u32(20) }
59}
60
61// iOS input is in points; the frame is in pixels. The app's frame is in points.
62export const describeIos = (id: string): Argv => sh('axe describe-ui --udid "$1"', id)
63export function densityOf(json: string, pixelWidth: number): number {
64  const points = Number((JSON.parse(json) as { frame?: { width?: number } }[])[0]?.frame?.width)
65  return points > 0 ? pixelWidth / points : 3
66}
67
68const r = Math.round
69export function tap(d: Device, x: number, y: number): Argv {
70  return d.platform === 'ios'
71    ? sh('axe tap -x "$2" -y "$3" --udid "$1"', d.id, String(r(x)), String(r(y)))
72    : sh('adb -s "$1" shell input tap "$2" "$3"', d.id, String(r(x)), String(r(y)))
73}
74
75export function swipe(d: Device, a: [number, number], b: [number, number], ms: number): Argv {
76  const n = [a[0], a[1], b[0], b[1]].map(v => String(r(v)))
77  return d.platform === 'ios'
78    ? sh('axe swipe --start-x "$2" --start-y "$3" --end-x "$4" --end-y "$5" --duration "$6" --udid "$1"', d.id, ...n, String(ms / 1000))
79    : sh('adb -s "$1" shell input swipe "$2" "$3" "$4" "$5" "$6"', d.id, ...n, String(r(ms)))
80}
81
82// `adb shell input text` goes through the device's shell: spaces become %s and
83// the whole thing is single-quoted there.
84export function text(d: Device, s: string): Argv {
85  if (d.platform === 'ios') return sh('printf %s "$2" | axe type --stdin --udid "$1"', d.id, s)
86  const quoted = "'" + s.replace(/%/g, '\\%').replace(/ /g, '%s').replace(/'/g, `'\\''`) + "'"
87  return sh('adb -s "$1" shell input text "$2"', d.id, quoted)
88}
89
90export type Key = 'home' | 'back' | 'lock' | 'return' | 'backspace' | 'tab' | 'up' | 'down' | 'left' | 'right' | 'apps'
91// iOS: HID usage codes for keys, AXe buttons for hardware. Android: keyevents.
92const IOS_KEY: Partial<Record<Key, number>> = { return: 40, backspace: 42, tab: 43, right: 79, left: 80, down: 81, up: 82 }
93const IOS_BUTTON: Partial<Record<Key, string>> = { home: 'home', lock: 'lock' }
94const ANDROID_KEY: Record<Key, number> = { home: 3, back: 4, lock: 26, return: 66, backspace: 67, tab: 61, up: 19, down: 20, left: 21, right: 22, apps: 187 }
95
96export function key(d: Device, k: Key): Argv | null {
97  if (d.platform === 'android') return sh('adb -s "$1" shell input keyevent "$2"', d.id, String(ANDROID_KEY[k]))
98  if (IOS_BUTTON[k]) return sh('axe button "$2" --udid "$1"', d.id, IOS_BUTTON[k]!)
99  if (IOS_KEY[k]) return sh('axe key "$2" --udid "$1"', d.id, String(IOS_KEY[k]))
100  return null // no back or app switcher button on iOS
101}
102
103// The biggest box of cells that keeps the frame's shape; a cell is about twice
104// as tall as wide.
105export function fit(width: number, height: number, cols: number, rows: number): { cols: number; rows: number } {
106  const a = height / width
107  const byRows = Math.max(4, Math.min(rows, Math.floor((cols * a) / 2)))
108  return { cols: Math.max(4, Math.min(cols, Math.round((byRows * 2) / a))), rows: byRows }
109}
110
hooks/overlay.tsx 19 lines
1import type { ClientModule } from 'claude-code'
2
3// An empty box over the device screen: it reports where a press starts and
4// ends (as fractions of the box), and the keys, so the mod can tap or swipe.
5export type OverlayProps = { cols: number; rows: number }
6
7const Overlay: ClientModule<OverlayProps> = (props, surface) => {
8  const { Box } = surface.elements
9  surface.onPointer(e => {
10    const at: [number, number] = [(e.fine?.x ?? e.x + 0.5) / props.cols, (e.fine?.y ?? e.y + 0.5) / props.rows]
11    if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ down: at })
12    else if (e.type === 'up') surface.post({ up: at })
13  })
14  surface.onKey(e => surface.post({ key: e.key }))
15  return <Box width={props.cols} height={props.rows} />
16}
17
18export default Overlay
19