Phone mode for Claude Code on the iPhone Duo: a fold-aware pane, one-tap prompts and thumb approvals

A Claude Code mod for cloud sessions opened from the Claude iOS app.
mobile client attaches and opens a Duo pane. Use /duo on|off|auto to override; the choice is saved across sessions.✓ Bash · npm test)./duo status · /duo on · /duo off · /duo auto · /duo breakpoint <columns> (default 56)
At the prompt of a terminal session:
/plugin install duo --marketplace jayozer/iphone_duo_mods_plugins
To try it from a clone, run claude --plugin-dir claude-code/mods/duo. Then open the session on your phone through Remote Control (claude --plugin-dir claude-code/mods/duo --remote-control).
To check it, run claude plugin validate claude-code/mods/duo and claude plugin test claude-code/mods/duo (4 tests).
/duo status folded and unfolded on a real Duo and set the breakpoint between the two widths.hooks/register.tsx 301 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, DuoMode } from '../types'
5import {
6 DEFAULT_BREAKPOINT,
7 PHONE_STYLE,
8 QUICK_PROMPTS,
9 READ_TOOLS,
10 RISK_LABEL,
11 baseName,
12 callInput,
13 clip,
14 filePathOf,
15 isWide,
16 riskOf,
17 summarize,
18} from './lib'
19
20const PANE = 'duo'
21const MODES: readonly DuoMode[] = ['auto', 'on', 'off']
22
23const mode = atom({ plugin: 'duo', key: 'mode' } as const, 'auto')
24const isPhone = atom({ plugin: 'duo', key: 'isPhone' } as const, false)
25const breakpoint = atom({ plugin: 'duo', key: 'breakpoint' } as const, DEFAULT_BREAKPOINT)
26const turnId = atom({ plugin: 'duo', key: 'turnId' } as const, null)
27const activity = atom({ plugin: 'duo', key: 'activity' } as const, [])
28const changed = atom({ plugin: 'duo', key: 'changed' } as const, [])
29const pending = atom({ plugin: 'duo', key: 'pending' } as const, null)
30const isAutoReads = atom({ plugin: 'duo', key: 'isAutoReads' } as const, false)
31
32const isActive = async ($: EngineInterface) => {
33 const current = await read($, mode)
34 return current === 'on' || (current === 'auto' && (await read($, isPhone)))
35}
36
37const refreshPhone = async ($: EngineInterface) => {
38 const surfaces = await $.session.surfaces()
39 const hasPhone = surfaces.includes('mobile')
40 await update($, isPhone, () => hasPhone)
41 if (await isActive($)) await $.ui.open({ id: PANE, title: 'Duo' })
42}
43
44const STATUS_MARK: Record<Activity['status'], string> = { running: '…', done: '✓', error: '✗' }
45
46export const register: Register = on => {
47 let startedAt = 0
48
49 on('session.start', async ($, e, next) => {
50 await $.command.register({
51 name: 'duo',
52 description: 'Phone mode: /duo on, off, auto, status, or breakpoint <columns>',
53 argumentHint: 'on | off | auto | status | breakpoint <n>',
54 })
55 const stored = await $.store.get('mode')
56 if (typeof stored === 'string' && MODES.includes(stored as DuoMode)) {
57 await update($, mode, () => stored as DuoMode)
58 }
59 const storedBreakpoint = await $.store.get('breakpoint')
60 if (typeof storedBreakpoint === 'number') await update($, breakpoint, () => storedBreakpoint)
61 await update($, isAutoReads, () => false)
62 await refreshPhone($)
63
64 return next(e)
65 })
66
67 on('session.attach', { surface: 'mobile' }, async ($, e, next) => {
68 await update($, isPhone, () => true)
69 if (await isActive($)) await $.ui.open({ id: PANE, title: 'Duo' })
70
71 return next(e)
72 })
73
74 on('session.detach', async ($, e, next) => {
75 const result = await next(e)
76 if (e.surface === 'mobile' && e.reason !== 'end') await refreshPhone($)
77
78 return result
79 })
80
81 on('command.run', { command: 'duo' }, async ($, e) => {
82 const [word = 'status', arg] = e.args.trim().split(/\s+/)
83 if (word === 'on' || word === 'off' || word === 'auto') {
84 await update($, mode, () => word)
85 await $.store.set('mode', word)
86 if (word === 'off') {
87 await $.ui.close({ id: PANE })
88 } else if (await isActive($)) {
89 await $.ui.open({ id: PANE, title: 'Duo' })
90 }
91 return { text: `Duo mode is ${word}.` }
92 }
93 if (word === 'breakpoint') {
94 const columns = Number(arg)
95 if (!Number.isInteger(columns) || columns < 20 || columns > 200) {
96 return { text: 'Give a width in columns between 20 and 200, like /duo breakpoint 56.' }
97 }
98 await update($, breakpoint, () => columns)
99 await $.store.set('breakpoint', columns)
100 return { text: `Duo switches to the two-column layout from ${columns} columns.` }
101 }
102 const active = await isActive($)
103 const phone = await read($, isPhone)
104 return {
105 text:
106 `Duo mode: ${await read($, mode)}, ${active ? 'active' : 'inactive'}. ` +
107 `Phone attached: ${phone ? 'yes' : 'no'}. Two-column layout from ${await read($, breakpoint)} columns. ` +
108 `Auto-allow reads: ${(await read($, isAutoReads)) ? 'on' : 'off'}.`,
109 }
110 })
111
112 on('turn.start', async ($, e, next) => {
113 if (e.agentId === undefined) {
114 startedAt = await $.clock.now()
115 await update($, turnId, () => e.turnId)
116 await update($, changed, () => [])
117 }
118
119 return next(e)
120 })
121
122 on('turn.complete', async ($, e, next) => {
123 if (e.agentId !== undefined) return next(e)
124 await update($, turnId, () => null)
125 await update($, pending, () => null)
126 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
127 if (seconds >= 30 && !e.isAborted && (await isActive($))) {
128 const files = (await read($, changed)).length
129 const what = files > 0 ? `${files} file${files === 1 ? '' : 's'} changed` : 'no files changed'
130 void $.ui.notify(`Finished in ${seconds}s, ${what}.`, { title: 'Claude Code' }).catch(() => undefined)
131 }
132
133 return next(e)
134 })
135
136 on('tool.check', async ($, e, next) => {
137 const verdict = await next(e)
138 if (verdict.decision !== 'ask' || !(await isActive($))) return verdict
139 if (READ_TOOLS.includes(e.tool) && (await read($, isAutoReads))) {
140 return { ...verdict, decision: 'allow', reason: 'Allowed by Duo: a read while on the phone' }
141 }
142 const id = e.tool_use_id
143 if (id !== undefined) {
144 const ask = { id, tool: e.tool, summary: summarize(e.tool, e.input), risk: riskOf(e.tool, e.input) }
145 await update($, pending, () => ask)
146 }
147
148 return verdict
149 })
150
151 on('tool.call', async ($, e, next) => {
152 const id = e.tool_use_id
153 const row: Activity = { id, tool: e.tool, summary: summarize(e.tool, callInput(e)), status: 'running' }
154 await update($, activity, list => [...list, row].slice(-30))
155 const ran = await next(e)
156 const isFailed = 'deny' in ran && ran.deny !== undefined ? true : ran.isError === true
157 await update($, activity, list =>
158 list.map(one => (one.id === id ? { ...one, status: isFailed ? 'error' : 'done' } : one)),
159 )
160 await update($, pending, ask => (ask?.id === id ? null : ask))
161 const path = filePathOf(callInput(e))
162 if (!isFailed && path !== undefined && riskOf(e.tool, callInput(e)) === 'write') {
163 await update($, changed, list => (list.includes(path) ? list : [...list, path]))
164 }
165
166 return ran
167 })
168
169 on('prompt.compose', async ($, e, next) => {
170 const composed = await next(e)
171 const current = await read($, mode)
172 const isOn = current === 'on' || (current === 'auto' && e.surfaces.includes('mobile'))
173 if (!isOn) return composed
174
175 return { sections: [...composed.sections, { id: 'duo:phone-style', text: PHONE_STYLE, scope: 'session' }] }
176 })
177
178 // Folded on the phone, each tool row shrinks to one line.
179 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
180 if (e.surface !== 'mobile' || !(await isActive($))) return next(e)
181 if (isWide(e.viewport?.columns, await read($, breakpoint))) return next(e)
182 const { Text } = $.ui.resolve(e)
183 const { tool, input, isRunning, isErrored } = e.props
184 const mark = isRunning ? '…' : isErrored ? '✗' : '✓'
185 const columns = e.viewport?.columns ?? 40
186
187 return (
188 <Text dimColor={!isRunning} wrap="truncate-end">
189 {clip(`${mark} ${tool} · ${summarize(tool, input)}`, columns)}
190 </Text>
191 )
192 })
193
194 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195 const { Box, Text, Button } = $.ui.resolve(e)
196 const columns = e.props.bodyColumns
197 const isTwoColumns = isWide(columns, await read($, breakpoint))
198 const running = await read($, turnId)
199 const ask = await read($, pending)
200 const rows = await read($, activity)
201 const files = await read($, changed)
202 const autoReads = await read($, isAutoReads)
203 const current = rows.filter(row => row.status === 'running').pop() ?? rows[rows.length - 1]
204
205 const header = (
206 <Box key="header" flexDirection="column">
207 <Text bold>{running === null ? 'Idle' : 'Working'}</Text>
208 <Text dimColor>
209 {columns} columns · {isTwoColumns ? 'unfolded' : 'folded'}
210 </Text>
211 {current !== undefined && (
212 <Text dimColor wrap="truncate-end">
213 {clip(`${STATUS_MARK[current.status]} ${current.tool} · ${current.summary}`, columns)}
214 </Text>
215 )}
216 </Box>
217 )
218
219 const approval = ask !== null && (
220 <Box key="approval" flexDirection="column" borderStyle="round" paddingX="sm">
221 <Text bold>Needs your OK: {ask.tool}</Text>
222 <Text wrap="truncate-end">{clip(ask.summary, columns - 4)}</Text>
223 <Text dimColor>Risk: {RISK_LABEL[ask.risk]}. Answer in the prompt below.</Text>
224 </Box>
225 )
226
227 const deck = (
228 <Box key="deck" flexDirection="row" flexWrap="wrap" gap="sm">
229 {running !== null ? (
230 <Button
231 key="stop"
232 label="Stop"
233 variant="primary"
234 onPress={() => void $.turn.abort({ turnId: running }).catch(() => undefined)}
235 />
236 ) : (
237 QUICK_PROMPTS.map(quick => (
238 <Button
239 key={quick.key}
240 label={quick.label}
241 onPress={() => void $.prompt.submit({ text: quick.text }).catch(() => undefined)}
242 />
243 ))
244 )}
245 <Button
246 key="reads"
247 label={autoReads ? 'Reads: auto' : 'Reads: ask'}
248 onPress={() => update($, isAutoReads, isOn => !isOn)}
249 />
250 </Box>
251 )
252
253 const recent = (
254 <Box key="recent" flexDirection="column">
255 <Text bold>Recent</Text>
256 {rows.length === 0 && <Text dimColor>No tool calls yet.</Text>}
257 {rows.slice(-8).map(row => (
258 <Text key={row.id} dimColor={row.status !== 'running'} wrap="truncate-end">
259 {STATUS_MARK[row.status]} {row.tool} · {row.summary}
260 </Text>
261 ))}
262 </Box>
263 )
264
265 const changes = (
266 <Box key="changes" flexDirection="column">
267 <Text bold>Changed this turn</Text>
268 {files.length === 0 && <Text dimColor>Nothing yet.</Text>}
269 {files.slice(-8).map(path => (
270 <Text key={path} wrap="truncate-start">
271 {baseName(path)}
272 </Text>
273 ))}
274 </Box>
275 )
276
277 if (!isTwoColumns) {
278 return (
279 <Box flexDirection="column" gap="sm">
280 {header}
281 {approval}
282 {deck}
283 {files.length > 0 && <Text dimColor>{files.length} changed this turn</Text>}
284 </Box>
285 )
286 }
287
288 return (
289 <Box flexDirection="column" gap="sm">
290 {header}
291 {approval}
292 <Box flexDirection="row" gap="md">
293 <Box width="50%">{recent}</Box>
294 <Box width="50%">{changes}</Box>
295 </Box>
296 {deck}
297 </Box>
298 )
299 })
300}
301hooks/lib.ts 67 lines1import type { Risk } from '../types'
2
3// Below this many columns the phone is folded (the 5.4" outer screen) or
4// held narrow; above it, unfolded on the 7.6" inner screen.
5export const DEFAULT_BREAKPOINT = 56
6
7export const READ_TOOLS = ['Read', 'Glob', 'Grep', 'LS', 'NotebookRead']
8
9export const QUICK_PROMPTS = [
10 { key: 'continue', label: 'Continue', text: 'Continue.' },
11 { key: 'tests', label: 'Run tests', text: 'Run the test suite and fix anything that fails.' },
12 { key: 'fix', label: 'Fix error', text: 'Fix the last error you hit.' },
13 { key: 'explain', label: 'Explain', text: 'Explain what you just did in three short bullet points.' },
14 { key: 'commit', label: 'Commit', text: 'Commit the current changes with a clear message.' },
15] as const
16
17export const PHONE_STYLE =
18 'The person is reading this session on a phone. Keep replies short and lead with the answer. ' +
19 'Avoid wide tables and long lines; keep code blocks narrow and only include the lines that matter.'
20
21export const isWide = (columns: number | undefined, breakpoint: number) =>
22 columns !== undefined && columns >= breakpoint
23
24const field = (input: unknown, name: string): string | undefined => {
25 if (typeof input !== 'object' || input === null) return undefined
26 const value = (input as Record<string, unknown>)[name]
27 return typeof value === 'string' ? value : undefined
28}
29
30export const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
31
32export const filePathOf = (input: unknown) => field(input, 'file_path') ?? field(input, 'notebook_path')
33
34export const summarize = (tool: string, input: unknown): string => {
35 const command = field(input, 'command')
36 if (command !== undefined) return command.split('\n')[0] ?? ''
37 const path = filePathOf(input)
38 if (path !== undefined) return baseName(path)
39 return field(input, 'pattern') ?? field(input, 'url') ?? field(input, 'description') ?? ''
40}
41
42const DANGER = /\b(rm|sudo|chmod|chown|dd|mkfs|curl|wget|ssh|scp)\b|git\s+(push|reset|clean|rebase)|--force|>\s*\//
43
44export const riskOf = (tool: string, input: unknown): Risk => {
45 if (READ_TOOLS.includes(tool)) return 'read'
46 if (tool === 'Bash') return DANGER.test(field(input, 'command') ?? '') ? 'danger' : 'other'
47 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'write'
48 if (tool === 'WebFetch') return 'danger'
49 return 'other'
50}
51
52export const RISK_LABEL: Record<Risk, string> = {
53 read: 'reads only',
54 write: 'writes files',
55 danger: 'destructive or network',
56 other: 'runs a command',
57}
58
59export const clip = (text: string, columns: number) =>
60 text.length <= columns ? text : `${text.slice(0, Math.max(1, columns - 1))}…`
61
62// A tool.call's arguments arrive under `input`; fall back to the event itself.
63export const callInput = (e: unknown): unknown => {
64 const input = typeof e === 'object' && e !== null ? (e as { input?: unknown }).input : undefined
65 return typeof input === 'object' && input !== null ? input : e
66}
67types/index.d.ts 25 lines1export type DuoMode = 'auto' | 'on' | 'off'
2export type Risk = 'read' | 'write' | 'danger' | 'other'
3export type Activity = {
4 id: string
5 tool: string
6 summary: string
7 status: 'running' | 'done' | 'error'
8}
9export type Pending = { id: string; tool: string; summary: string; risk: Risk }
10
11declare module 'claude-code' {
12 interface PluginState {
13 duo: {
14 mode: DuoMode
15 isPhone: boolean
16 breakpoint: number
17 turnId: string | null
18 activity: Activity[]
19 changed: string[]
20 pending: Pending | null
21 isAutoReads: boolean
22 }
23 }
24}
25