SLOPSHOPPER

phone

Watch and drive a phone, emulator or simulator in a pane, through the phone CLI

newpanecommandstatuspromptprocess
★ 51v0.1.0MITupdated 2026-10-08luisnquin/nixos-config/pkgs/phone/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · phone
│ ┃ phone ✕ › fix the failing auth test and add an audit log call │ ┃ /phone <target> │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /phone │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · phone
/phone <target>
Source 3 files
hooks/register.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { ImageSource, Register } from 'claude-code'
3
4import type { Cells, Device, Element, Input, Panel, View } from '../types'
5
6const PANE = 'phone'
7const KEY = 'screen'
8const WIDTH = 432
9const FRAME = 66
10const TALLEST = 1024
11
12const NAV = [
13  ['back', 'back'],
14  ['home', 'home'],
15  ['app_switch', 'recents'],
16] as const
17
18const view = atom({ plugin: 'phone', key: 'view' } as const, {
19  target: null,
20  panel: null,
21  cells: null,
22  error: null,
23  back: false,
24  grabbed: false,
25} as View)
26
27const BLANK: ImageSource = { rgba: 'AAAA/w==', width: 1, height: 1 }
28
29let loop = 0
30let latest: ImageSource = BLANK
31let asked = 0
32let control: { dock: (columns: number) => void; resize: (cells: Cells) => void } | null = null
33let running = ''
34let grabbed = false
35
36export function ready(devices: Device[]): string {
37  const up = devices.filter(d => d.reach.startsWith('attached') || d.reach === 'online')
38  if (!up.length) return 'nothing running; `phone boot <name>` first'
39  return up.map(d => (d.host ? `${d.label} (${d.host})` : d.label)).join(', ')
40}
41
42const DESCRIPTION = 'Watch and drive a device beside the transcript; bare /phone picks a running device or closes the open one, /phone list shows them all'
43
44export function fit(panel: Panel, rows: number): Cells {
45  return { columns: Math.max(4, Math.round((2 * rows * panel.width) / panel.height)), rows }
46}
47
48export function within(panel: Panel, columns: number, rows: number): Cells {
49  const tall = fit(panel, Math.max(4, Math.min(255, rows)))
50  if (tall.columns <= columns) return tall
51  return { columns: Math.max(4, columns), rows: Math.max(4, Math.floor((columns * panel.height) / (2 * panel.width))) }
52}
53
54export function pixels(panel: Panel): Panel {
55  const even = (v: number) => Math.max(2, 2 * Math.round(v / 2))
56  const width = Math.min(WIDTH, even((TALLEST * panel.width) / panel.height))
57  return { width, height: Math.min(TALLEST, even((width * panel.height) / panel.width)) }
58}
59
60export function lastLine(buffer: string): { line: string | null; rest: string } {
61  const end = buffer.lastIndexOf('\n')
62  if (end < 0) return { line: null, rest: buffer }
63  const start = buffer.lastIndexOf('\n', end - 1) + 1
64  return { line: buffer.slice(start, end), rest: buffer.slice(end + 1) }
65}
66
67export function toDevice(panel: Panel, input: Input): string[] {
68  const px = (v: number) => String(Math.round(v * panel.width))
69  const py = (v: number) => String(Math.round(v * panel.height))
70  switch (input.kind) {
71    case 'tap':
72      return ['tap', `${px(input.x)},${py(input.y)}`]
73    case 'swipe':
74      return ['swipe', `${px(input.x)},${py(input.y)}`, `${px(input.toX)},${py(input.toY)}`]
75    case 'key':
76      return ['key', input.name]
77    case 'type':
78      return ['type', input.text]
79  }
80}
81
82export function choices(devices: Device[]): { label: string; target: string }[] {
83  return devices
84    .filter(d => d.reach.startsWith('attached') || d.reach === 'online')
85    .slice(0, 4)
86    .map(d => ({
87      label: `${d.label} · ${d.os} ${d.kind}${d.host ? ` · ${d.host}` : ''}${d.hold ? ` · held by ${d.hold.project}` : ''}`,
88      target: d.label,
89    }))
90}
91
92export function backTap(elements: Element[]): string[] | null {
93  const back = elements.find(e => e.id === 'BackButton')
94  return back ? ['tap', `${back.at[0]},${back.at[1]}`] : null
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    $.ui.status(undefined)
100    await $.command.register({ name: 'phone', description: DESCRIPTION, argumentHint: '[device] | stop' })
101    void $.process.run(['phone', 'device', 'list', '--json']).then(list => {
102      if (list.exitCode !== 0) return
103      running = ready(JSON.parse(list.stdout) as Device[])
104      $.ui.invalidate('command.describe')
105    })
106
107    return next(e)
108  })
109
110  on('command.describe', { command: 'phone' }, async ($, e, next) =>
111    next(running ? { ...e, description: `${DESCRIPTION}. Running: ${running}` } : e),
112  )
113
114  on('command.run', { command: 'phone' }, async ($, e) => {
115    const arg = e.args.trim()
116
117    if (arg === 'list') {
118      const list = await $.process.run(['phone', 'device', 'list'])
119      return { text: (list.stdout || list.stderr).trimEnd() }
120    }
121
122    if (arg === 'stop' || (arg === '' && control)) {
123      loop++
124      control = null
125      await $.ui.close({ id: PANE })
126      return { text: `closed; /phone ${(await read($, view)).target} reopens it.` }
127    }
128
129    let chosen = arg
130    if (!chosen) {
131      const list = await $.process.run(['phone', 'device', 'list', '--json'])
132      const options = list.exitCode === 0 ? choices(JSON.parse(list.stdout) as Device[]) : []
133      if (!options.length) return { text: 'nothing running; `phone boot <name>` first, or /phone list' }
134      const only = options.length === 1 ? options[0] : undefined
135      const answer = only
136        ? only.label
137        : await $.ui.ask('Which device?', { header: 'phone', options: options.map(o => o.label) })
138      chosen = options.find(o => o.label === answer)?.target ?? answer.trim()
139    }
140    const target = chosen
141
142    const size = await $.process.run(['phone', 'size', '-t', target])
143    const m = size.stdout.match(/(\d+)x(\d+)/)
144    if (size.exitCode !== 0 || !m) {
145      return { text: `${target} is not drivable: ${(size.stderr || size.stdout).trim()}` }
146    }
147
148    const list = await $.process.run(['phone', 'device', 'list', '--json'])
149    const devices = list.exitCode === 0 ? (JSON.parse(list.stdout) as Device[]) : []
150    const back = devices.find(d => d.label === target || d.id === target)?.os !== 'ios'
151
152    const panel = { width: Number(m[1]), height: Number(m[2]) }
153    const { width, height } = pixels(panel)
154
155    let shown: Cells | null = null
156
157    const watch = async () => {
158      const token = ++loop
159      latest = BLANK
160      const name = `pane-${Math.round(await $.clock.now())}-${token}`
161      const argv = ['phone', 'stream', '-t', target, '--size', `${width}x${height}`, '--shm', name]
162      let buffer = ''
163      let error = ''
164      let due = false
165
166      const flush = (tries = 0) => {
167        if (due) return
168        due = true
169        void $.clock.sleep(FRAME).then(async () => {
170          due = false
171          if (token !== loop || !shown) return
172          const sent = await $.ui.blit({ requestId: PANE, key: KEY, source: latest, columns: shown.columns, rows: shown.rows })
173          if (sent.deny && tries < 15) flush(tries + 1)
174        })
175      }
176
177      for await (const { stream, text } of $.process.spawn({ argv })) {
178        if (token !== loop) break
179        if (stream === 'stderr') {
180          error += text
181          continue
182        }
183
184        const { line, rest } = lastLine(buffer + text)
185        buffer = rest
186        if (!line) continue
187
188        latest = { file: `/dev/shm${line}`, format: 'rgb', width, height }
189        flush()
190      }
191
192      if (token !== loop) return
193      const reason = error.trim().split('\n').pop() || 'the stream ended'
194      await update($, view, v => ({ ...v, error: reason }))
195    }
196
197    const open = (columns?: number) =>
198      $.ui.open({ id: PANE, title: target, holdToasts: true, ...(columns ? { columns } : {}) })
199
200    control = {
201      dock: columns => void open(columns),
202      resize: cells => {
203        const first = !shown
204        shown = cells
205        void update($, view, v => ({ ...v, cells }))
206        if (first) void watch()
207      },
208    }
209    asked = 0
210    grabbed = false
211    await update($, view, () => ({ target, panel, cells: null, error: null, back, grabbed }))
212    await open()
213
214    return { text: `streaming ${target}.` }
215  })
216
217  on('ui.message', async ($, e, next) => {
218    if (e.requestId !== PANE) return next(e)
219    const v = await read($, view)
220    if (!v.target || !v.panel) return {}
221    if (!grabbed) {
222      grabbed = true
223      void update($, view, s => ({ ...s, grabbed }))
224    }
225    void $.process.run(['phone', '-t', v.target, ...toDevice(v.panel, e.data as Input)])
226    return {}
227  })
228
229  on('prompt.edit', async ($, e, next) => {
230    if (grabbed) {
231      grabbed = false
232      void update($, view, s => ({ ...s, grabbed }))
233    }
234    return next(e)
235  })
236
237  on('ui.close', async ($, e, next) => {
238    if (e.id === PANE) {
239      loop++
240      control = null
241    }
242    return next(e)
243  })
244
245  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
246    if (e.surface !== 'terminal') return next(e)
247    const { Box, Text, Image, Client, Button } = $.ui.resolve(e)
248    const v = await read($, view)
249    if (!v.panel) return <Text dimColor>/phone &lt;target&gt;</Text>
250    if (v.error) return <Text color="red">{`${v.error}\n\n/phone ${v.target} restarts it`}</Text>
251    if (!control) return <Text dimColor>{`stream stopped on reload; /phone ${v.target} restarts it`}</Text>
252
253    const rows = e.props.scroll.bodyRows - 1
254    const tall = fit(v.panel, Math.min(255, rows))
255    if (tall.columns !== e.props.bodyColumns && asked !== tall.columns) {
256      asked = tall.columns
257      control?.dock(tall.columns)
258    }
259
260    const want = within(v.panel, e.props.bodyColumns, rows)
261    if (!v.cells || want.columns !== v.cells.columns || want.rows !== v.cells.rows) control?.resize(want)
262    if (!v.cells) return <Text dimColor>starting…</Text>
263
264    const { columns, rows: high } = v.cells
265    const target = v.target
266    if (!target) return <Text dimColor>/phone &lt;target&gt;</Text>
267
268    const press = async (name: string) => {
269      if (name !== 'back' || v.back) return $.process.run(['phone', '-t', target, 'key', name])
270      const shot = await $.process.run(['phone', '-t', target, 'snapshot', '--json'])
271      const tap = shot.exitCode === 0 ? backTap(JSON.parse(shot.stdout) as Element[]) : null
272      if (tap) return $.process.run(['phone', '-t', target, ...tap])
273    }
274
275    return (
276      <Box flexDirection="column">
277        <Box position="relative" width={columns} height={high}>
278          <Image key={KEY} source={latest} columns={columns} rows={high} alt="no kitty graphics here" />
279          <Box position="absolute">
280            <Client key="input" module="./overlay.tsx" width={columns} height={high} props={v.cells} />
281          </Box>
282        </Box>
283        <Box
284          flexDirection="row"
285          gap={3}
286          width={columns}
287          {...(e.props.isFocused || v.grabbed ? { backgroundColor: 'green' } : {})}
288        >
289          {NAV.map(([name, label]) => (
290            <Button key={`nav-${name}`} onPress={() => void press(name)}>
291              {label}
292            </Button>
293          ))}
294          {(e.props.isFocused || v.grabbed) && (
295            <Text color="black" bold wrap="truncate-end">
296              esc
297            </Text>
298          )}
299        </Box>
300      </Box>
301    )
302  })
303}
304
hooks/overlay.tsx 71 lines
1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3import type { Input } from '../types'
4
5type Size = { columns: number; rows: number }
6type Local = { ready: true }
7
8let latest: Size = { columns: 1, rows: 1 }
9
10const KEYS: Record<string, string> = {
11  return: 'enter',
12  backspace: 'del',
13  delete: 'forward_del',
14  tab: 'tab',
15  up: 'dpad_up',
16  down: 'dpad_down',
17  left: 'dpad_left',
18  right: 'dpad_right',
19  home: 'home',
20  pageup: 'page_up',
21  pagedown: 'page_down',
22}
23
24export function keyInput(k: ClientKeyEvent): Input | null {
25  if (k.ctrl && k.key === 'b') return { kind: 'key', name: 'back' }
26  if (k.ctrl && k.key === 'h') return { kind: 'key', name: 'home' }
27  if (k.ctrl && k.key === 'r') return { kind: 'key', name: 'app_switch' }
28  if (k.ctrl || k.meta) return null
29  const name = KEYS[k.key]
30  if (name) return { kind: 'key', name }
31  return [...k.key].length === 1 ? { kind: 'type', text: k.key } : null
32}
33
34const Overlay: ClientModule<Size, Local> = (box, surface) => {
35  const { Box } = surface.elements
36  latest = box
37
38  if (surface.state === undefined) {
39    let down: { x: number; y: number } | null = null
40
41    surface.onPointer(p => {
42      const pos = p.fine ?? { x: p.x + 0.5, y: p.y + 0.5 }
43      const x = pos.x / latest.columns
44      const y = pos.y / latest.rows
45      if (p.type === 'down' && p.button === 'left') down = { x, y }
46      if (p.type === 'up' && down) {
47        const moved = Math.hypot(x - down.x, (y - down.y) / 2) > 0.02
48        surface.post(
49          moved
50            ? { kind: 'swipe', x: down.x, y: down.y, toX: clamp(x), toY: clamp(y) }
51            : { kind: 'tap', x: down.x, y: down.y },
52        )
53        down = null
54      }
55    })
56    surface.onKey(k => {
57      const input = keyInput(k)
58      if (input) surface.post(input)
59    })
60    surface.setState({ ready: true })
61  }
62
63  return <Box width={box.columns} height={box.rows} />
64}
65
66function clamp(v: number) {
67  return Math.min(1, Math.max(0, v))
68}
69
70export default Overlay
71
types/index.d.ts 37 lines
1export type Panel = { width: number; height: number }
2
3export type Cells = { columns: number; rows: number }
4
5export type View = {
6  target: string | null
7  panel: Panel | null
8  cells: Cells | null
9  error: string | null
10  back: boolean
11  grabbed: boolean
12}
13
14export type Element = { id: string; at: [number, number] }
15
16export type Device = {
17  id: string
18  label: string
19  host: string | null
20  reach: string
21  os: string
22  kind: string
23  hold: { project: string } | null
24}
25
26export type Input =
27  | { kind: 'tap'; x: number; y: number }
28  | { kind: 'swipe'; x: number; y: number; toX: number; toY: number }
29  | { kind: 'key'; name: string }
30  | { kind: 'type'; text: string }
31
32declare module 'claude-code' {
33  interface PluginState {
34    phone: { view: View }
35  }
36}
37