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

hooks/register.tsx 304 lines1import { 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 <target></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 <target></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}
304hooks/overlay.tsx 71 lines1import 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
71types/index.d.ts 37 lines1export 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