Glanceable Claude Code on the iPhone Duo: a summary of what happened while you were away, and a desk monitor for the half-folded phone

It works alongside the duo mod, and you can also install it on its own.
When the Claude app on your phone leaves the session (you closed it or put the phone away), the mod starts counting what happens. When the phone opens the session again, a While you were away pane shows:
Its buttons are Review changes (Claude summarizes the changes file by file), Monitor and Got it. While you're away, a notification arrives whenever a turn finishes or something needs your OK. If nothing happened, no pane opens.
Run /glance monitor, or tap Monitor, then prop the Duo half-open next to your laptop. The monitor shows:
Folded, everything stacks in one column. Unfolded, it splits into status and gauges side by side.
/glance monitor · /glance away (starts counting now, useful for testing) · /glance status
At the prompt of a terminal session:
/plugin install duo-glance --marketplace jayozer/iphone_duo_mods_plugins
To try it from a clone, run claude --plugin-dir claude-code/mods/duo-glance --remote-control and open the session from the Claude app on your phone. Run claude plugin test claude-code/mods/duo-glance for the tests (12 tests).
hooks/register.tsx 456 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Away, Gauges, TreeSnapshot } from '../types'
5import {
6 BREAKPOINT,
7 baseName,
8 callInput,
9 changedBetween,
10 clock,
11 describeCall,
12 duration,
13 emptyAway,
14 field,
15 firstQuestion,
16 gaugeSvg,
17 dirtyPaths,
18 hasNews,
19 isTestCommand,
20 outputText,
21 readTestRun,
22 reviewPrompt,
23 testLine,
24} from './lib'
25
26const AWAY_PANE = 'duo-away'
27const MONITOR_PANE = 'duo-monitor'
28const WRITE_TOOLS = ['Edit', 'Write', 'NotebookEdit']
29const NO_GAUGES: Gauges = { contextPercent: null, ratePercent: null, costUsd: null }
30
31const isPhone = atom({ plugin: 'duo-glance', key: 'isPhone' } as const, false)
32const away = atom({ plugin: 'duo-glance', key: 'away' } as const, null)
33const summary = atom({ plugin: 'duo-glance', key: 'summary' } as const, null)
34const pending = atom({ plugin: 'duo-glance', key: 'pending' } as const, [])
35const steps = atom({ plugin: 'duo-glance', key: 'steps' } as const, [])
36const turnStartedAt = atom({ plugin: 'duo-glance', key: 'turnStartedAt' } as const, null)
37const lastTest = atom({ plugin: 'duo-glance', key: 'lastTest' } as const, null)
38const gauges = atom({ plugin: 'duo-glance', key: 'gauges' } as const, NO_GAUGES)
39const isMonitorOpen = atom({ plugin: 'duo-glance', key: 'isMonitorOpen' } as const, false)
40const now = atom({ plugin: 'duo-glance', key: 'now' } as const, 0)
41
42const openMonitor = async ($: EngineInterface) => {
43 const usage = await $.session.usage()
44 await update($, gauges, () => readGauges(usage))
45 const at = await $.clock.now()
46 await update($, now, () => at)
47 await update($, isMonitorOpen, () => true)
48 await $.ui.open({ id: MONITOR_PANE, title: 'Monitor' })
49}
50
51function readGauges(usage: {
52 context: { percent?: number }
53 rateLimits: readonly { percentUsed: number }[]
54 cost?: { usd: number }
55}): Gauges {
56 return {
57 contextPercent: usage.context.percent ?? null,
58 ratePercent: usage.rateLimits.length === 0 ? null : Math.max(...usage.rateLimits.map(limit => limit.percentUsed)),
59 costUsd: usage.cost?.usd ?? null,
60 }
61}
62
63const git = async ($: EngineInterface, args: string[], cwd?: string) => {
64 const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: 4000 })
65 return ran.exitCode === 0 ? ran.stdout : null
66}
67
68// The checkout now, or null outside a git checkout (or where commands can't run).
69const readTree = async ($: EngineInterface): Promise<TreeSnapshot | null> => {
70 try {
71 const top = await git($, ['rev-parse', '--show-toplevel'])
72 if (top === null) return null
73 const root = top.trim()
74 const status = await git($, ['status', '--porcelain', '-z', '--untracked-files=all'], root)
75 if (status === null) return null
76 const head = (await git($, ['rev-parse', 'HEAD'], root))?.trim() ?? null
77 const marks = await Promise.all(
78 dirtyPaths(status).map(async relative => {
79 const path = `${root}/${relative}`
80 try {
81 const stat = await $.fs.stat(path)
82 return [path, `${stat.size}:${stat.mtimeMs}`] as const
83 } catch {
84 return [path, 'gone'] as const
85 }
86 }),
87 )
88 return { root, head, marks: Object.fromEntries(marks) }
89 } catch {
90 return null
91 }
92}
93
94// Files that differ from the checkout as the phone left it: changed in the tree since,
95// or moved by a commit, checkout or reset (which can leave the tree clean).
96const changedSince = async ($: EngineInterface, then: TreeSnapshot): Promise<string[]> => {
97 const current = await readTree($)
98 if (current === null || current.root !== then.root) return []
99 const paths = changedBetween(then.marks, current.marks)
100 if (then.head !== null && current.head !== null && then.head !== current.head) {
101 const moved = await git($, ['diff', '--name-only', '-z', then.head, current.head], then.root).catch(() => null)
102 for (const relative of (moved ?? '').split('\0').filter(Boolean)) paths.push(`${then.root}/${relative}`)
103 }
104 return [...new Set(paths)]
105}
106
107// How many times the phone has left; a reconnect that sees this change mid-way leaves the
108// absence open.
109let departures = 0
110
111// The phone left: start counting what happens until it comes back.
112const leave = async ($: EngineInterface) => {
113 const id = ++departures
114 const since = await $.clock.now()
115 await update($, isPhone, () => false)
116 // Open the record before the checkout scan, so calls that finish during it still count.
117 const opened = { isNew: false }
118 await update($, away, current => {
119 if (current !== null) return current
120 opened.isNew = true
121 return emptyAway(since, id)
122 })
123 if (!opened.isNew) return
124 const tree = await readTree($)
125 if (tree !== null) await update($, away, held => (held?.id === id ? { ...held, tree } : held))
126}
127
128const notifyAway = async ($: EngineInterface, text: string) => {
129 if ((await read($, away)) === null) return
130 void $.ui.notify(text, { title: 'Claude Code' }).catch(() => undefined)
131}
132
133export const register: Register = on => {
134 let shellsRunning = 0
135
136 on('session.start', async ($, e, next) => {
137 await $.command.register({
138 name: 'glance',
139 description: 'iPhone Duo glance: /glance monitor, /glance away, or /glance status',
140 argumentHint: 'monitor | away | status',
141 })
142 const surfaces = await $.session.surfaces()
143 await update($, isPhone, () => surfaces.includes('mobile'))
144 const usage = await $.session.usage()
145 await update($, gauges, () => readGauges(usage))
146 $.clock.every(1000, () => {
147 void (async () => {
148 if ((await read($, isMonitorOpen)) && (await read($, turnStartedAt)) !== null) {
149 const at = await $.clock.now()
150 await update($, now, () => at)
151 }
152 })().catch(() => undefined)
153 })
154
155 return next(e)
156 })
157
158 on('session.attach', { surface: 'mobile' }, async ($, e, next) => {
159 const result = await next(e)
160 await update($, isPhone, () => true)
161 const seen = departures
162 const open = await read($, away)
163 // A shell command still running may already have written files: count them now,
164 // since once the absence ends its own check finds nothing to add to.
165 const touched = open?.tree && shellsRunning > 0 ? await changedSince($, open.tree) : []
166 // Close the record as it stands now, with whatever finished during the scan, unless
167 // the phone left again meanwhile: then the absence goes on.
168 const closed: { away: Away | null } = { away: null }
169 await update($, away, current => {
170 if (current === null || departures !== seen) return current
171 closed.away = { ...current, changed: [...new Set([...current.changed, ...touched])] }
172 return null
173 })
174 const held = closed.away
175 if (held !== null && (hasNews(held) || (await read($, pending)).length > 0)) {
176 const until = await $.clock.now()
177 await update($, summary, () => ({ ...held, until }))
178 await $.ui.open({ id: AWAY_PANE, title: 'While you were away' })
179 }
180
181 return result
182 })
183
184 on('session.detach', async ($, e, next) => {
185 const result = await next(e)
186 if (e.surface !== 'mobile' || e.reason === 'end') return result
187 const surfaces = await $.session.surfaces()
188 if (!surfaces.includes('mobile')) await leave($)
189
190 return result
191 })
192
193 on('command.run', { command: 'glance' }, async ($, e) => {
194 const word = e.args.trim().split(/\s+/)[0] || 'status'
195 if (word === 'monitor') {
196 await openMonitor($)
197 return { text: 'Desk monitor opened. Prop the phone half-open and it shows the session at a glance.' }
198 }
199 if (word === 'away') {
200 // Pretend the phone just left, so the next time it attaches you get a summary.
201 await leave($)
202 return { text: 'Counting from now: the next time the phone opens this session it gets a summary.' }
203 }
204 const held = await read($, away)
205 return {
206 text:
207 `Phone attached: ${(await read($, isPhone)) ? 'yes' : 'no'}. ` +
208 (held === null
209 ? 'Not counting an absence.'
210 : `Away for ${duration((await $.clock.now()) - held.since)}: ${held.turns} turns, ` +
211 `${held.changed.length} files changed, ${(await read($, pending)).length} waiting on you.`),
212 }
213 })
214
215 on('ui.close', async ($, e, next) => {
216 const result = await next(e)
217 if (e.id === MONITOR_PANE) await update($, isMonitorOpen, () => false)
218 if (e.id === AWAY_PANE) await update($, summary, () => null)
219
220 return result
221 })
222
223 on('turn.start', async ($, e, next) => {
224 if (e.agentId === undefined) {
225 const at = await $.clock.now()
226 await update($, turnStartedAt, () => at)
227 await update($, now, () => at)
228 }
229
230 return next(e)
231 })
232
233 on('turn.complete', async ($, e, next) => {
234 if (e.agentId !== undefined) return next(e)
235 await update($, turnStartedAt, () => null)
236 await update($, steps, () => [])
237 // Nothing outlives its turn, even a call the engine never reported back on.
238 await update($, pending, () => [])
239 await update($, away, held => (held === null ? null : { ...held, turns: held.turns + 1 }))
240 const usage = await $.session.usage()
241 await update($, gauges, () => readGauges(usage))
242 await notifyAway($, e.isAborted ? 'Claude stopped.' : 'Claude finished a turn.')
243
244 return next(e)
245 })
246
247 on('session.measure', async ($, e, next) => {
248 await update($, gauges, () => readGauges(e))
249
250 return next(e)
251 })
252
253 on('tool.check', async ($, e, next) => {
254 const verdict = await next(e)
255 // Only a real call carries an id; tracked while you are here too, so an absence that
256 // starts mid-approval still lists it.
257 const id = e.tool_use_id
258 if (verdict.decision === 'ask' && id !== undefined) {
259 const item = { id, text: `Approve ${describeCall(e.tool, e.input)}` }
260 await update($, pending, list => [...list.filter(one => one.id !== id), item])
261 await notifyAway($, `Waiting for your OK: ${describeCall(e.tool, e.input)}`)
262 }
263
264 return verdict
265 })
266
267 on('tool.call', async ($, e, next) => {
268 const input = callInput(e)
269 const id = e.tool_use_id
270 const isMainLoop = e.agentId === undefined
271 if (isMainLoop) await update($, steps, list => [...list, { id, text: describeCall(e.tool, input) }])
272 if (e.tool === 'AskUserQuestion') {
273 const item = { id, text: firstQuestion(input) }
274 await update($, pending, list => [...list, item])
275 await notifyAway($, item.text)
276 }
277 const isShell = e.tool === 'Bash'
278 if (isShell) shellsRunning++
279 // Counted until its checkout scan below is done, so a phone coming back meanwhile reconciles.
280 try {
281 const ran = await next(e)
282 // Finished, answered, approved or denied (here or on another surface): no longer
283 // running and no longer waiting on you.
284 if (isMainLoop) await update($, steps, list => list.filter(one => one.id !== id))
285 await update($, pending, list => list.filter(one => one.id !== id))
286 const isDenied = 'deny' in ran && ran.deny !== undefined
287 if (isDenied) return ran
288 const isFailed = ran.isError === true
289
290 const command = field(input, 'command')
291 if (e.tool === 'Bash' && command !== undefined && isTestCommand(command)) {
292 const run = readTestRun(command, outputText('result' in ran ? ran.result : undefined), isFailed)
293 await update($, lastTest, () => run)
294 await update($, away, held => (held === null ? null : { ...held, tests: [...held.tests, run] }))
295 } else if (isFailed) {
296 await update($, away, held => (held === null ? null : { ...held, errors: held.errors + 1 }))
297 }
298
299 const path = field(input, 'file_path') ?? field(input, 'notebook_path')
300 if (!isFailed && path !== undefined && WRITE_TOOLS.includes(e.tool)) {
301 await update($, away, held =>
302 held === null || held.changed.includes(path) ? held : { ...held, changed: [...held.changed, path] },
303 )
304 }
305 // A shell command can change files too (sed -i, a formatter, a generator, a commit):
306 // compare the checkout with how it was when the phone left, whenever the call began.
307 const then = e.tool === 'Bash' ? (await read($, away))?.tree : null
308 if (then !== null && then !== undefined) {
309 const touched = await changedSince($, then)
310 await update($, away, held => {
311 if (held === null) return null
312 const added = touched.filter(one => !held.changed.includes(one))
313 return added.length === 0 ? held : { ...held, changed: [...held.changed, ...added] }
314 })
315 }
316
317 return ran
318 } finally {
319 if (isShell) shellsRunning--
320 }
321 })
322
323 on('ui.render', { component: 'Pane', requestId: AWAY_PANE }, async ($, e) => {
324 const { Box, Text, Button } = $.ui.resolve(e)
325 const seen = await read($, summary)
326 if (seen === null) return <Text dimColor>Nothing happened while you were away.</Text>
327 const waiting = await read($, pending)
328 const isWide = e.props.bodyColumns >= BREAKPOINT
329 const latestTest = seen.tests[seen.tests.length - 1]
330 const shown = seen.changed.slice(isWide ? -10 : -4)
331
332 return (
333 <Box flexDirection="column" gap="sm">
334 <Text bold>Away {duration(seen.until - seen.since)}</Text>
335 <Text>
336 {seen.turns} turn{seen.turns === 1 ? '' : 's'} finished · {seen.changed.length} file
337 {seen.changed.length === 1 ? '' : 's'} changed
338 {seen.errors > 0 ? ` · ${seen.errors} error${seen.errors === 1 ? '' : 's'}` : ''}
339 </Text>
340 {latestTest !== undefined && (
341 <Text color={latestTest.isPassing ? 'green' : 'red'}>{testLine(latestTest)}</Text>
342 )}
343 {waiting.length > 0 && (
344 <Box key="waiting" flexDirection="column" borderStyle="round" paddingX="sm">
345 <Text bold>Waiting on you</Text>
346 {waiting.map(item => (
347 <Text key={item.id} wrap="truncate-end">
348 {item.text}
349 </Text>
350 ))}
351 </Box>
352 )}
353 {shown.length > 0 && (
354 <Box key="files" flexDirection="column">
355 {shown.map(path => (
356 <Text key={path} dimColor wrap="truncate-start">
357 {isWide ? path : baseName(path)}
358 </Text>
359 ))}
360 {seen.changed.length > shown.length && (
361 <Text dimColor>and {seen.changed.length - shown.length} more</Text>
362 )}
363 </Box>
364 )}
365 <Box key="actions" flexDirection="row" flexWrap="wrap" gap="sm">
366 <Button
367 key="review"
368 label="Review changes"
369 variant="primary"
370 onPress={() =>
371 void $.prompt
372 .submit({ text: reviewPrompt(seen.changed, seen.until - seen.since) })
373 .catch(() => undefined)
374 }
375 />
376 <Button key="monitor" label="Monitor" onPress={() => openMonitor($)} />
377 <Button
378 key="dismiss"
379 label="Got it"
380 role="dismiss"
381 onPress={async () => {
382 await update($, summary, () => null)
383 await $.ui.close({ id: AWAY_PANE })
384 }}
385 />
386 </Box>
387 </Box>
388 )
389 })
390
391 on('ui.render', { component: 'Pane', requestId: MONITOR_PANE }, async ($, e) => {
392 const { Box, Text } = $.ui.resolve(e)
393 const columns = e.props.bodyColumns
394 const isWide = columns >= BREAKPOINT
395 const startedAt = await read($, turnStartedAt)
396 const current = (await read($, steps)).slice(-1)[0]?.text
397 const test = await read($, lastTest)
398 const meters = await read($, gauges)
399 const at = await read($, now)
400 // About 7 px a column; unfolded, the gauges sit in the 45% column beside the status.
401 const meterColumns = isWide ? Math.floor(columns * 0.45) - 2 : columns
402 const width = Math.max(120, Math.min(320, meterColumns * 7))
403 // An SVG bar where the surface draws one (not the terminal), else the figure as text.
404 const gauge = (key: string, label: string, percent: number) => {
405 const alt = `${label} ${Math.round(percent)}%`
406 if (e.surface !== 'terminal') {
407 const { Svg } = $.ui.resolve(e)
408 return <Svg key={key} source={gaugeSvg(label, percent, width)} alt={alt} />
409 }
410 return (
411 <Text key={key} dimColor>
412 {alt}
413 </Text>
414 )
415 }
416
417 const status = (
418 <Box key="status" flexDirection="column">
419 <Text size="title" bold>
420 {startedAt === null ? 'Idle' : clock(at - startedAt)}
421 </Text>
422 <Text dimColor wrap="truncate-end">
423 {startedAt === null ? 'Waiting for your next prompt' : (current ?? 'Thinking')}
424 </Text>
425 {test === null ? (
426 <Text dimColor>No test run yet</Text>
427 ) : (
428 <Text size="heading" color={test.isPassing ? 'green' : 'red'}>
429 {testLine(test)}
430 </Text>
431 )}
432 </Box>
433 )
434
435 const meterBox = (
436 <Box key="meters" flexDirection="column" gap="sm">
437 {meters.contextPercent !== null && gauge('context', 'Context', meters.contextPercent)}
438 {meters.ratePercent !== null && gauge('rate', 'Rate limit', meters.ratePercent)}
439 {meters.costUsd !== null && <Text dimColor>Cost so far ${meters.costUsd.toFixed(2)}</Text>}
440 </Box>
441 )
442
443 return isWide ? (
444 <Box flexDirection="row" gap="md">
445 <Box width="55%">{status}</Box>
446 <Box width="45%">{meterBox}</Box>
447 </Box>
448 ) : (
449 <Box flexDirection="column" gap="md">
450 {status}
451 {meterBox}
452 </Box>
453 )
454 })
455}
456hooks/lib.ts 147 lines1import type { Away, TestRun, TreeSnapshot } from '../types'
2
3// Below this many columns the phone is folded; above it, unfolded.
4export const BREAKPOINT = 56
5
6const TEST_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(pytest|jest|vitest|mocha|rspec|phpunit)\b|\b(go|cargo|swift|dotnet|mix)\s+test\b|\bplugin test\b|\bmake\s+(test|check)\b/
7
8export const isTestCommand = (command: string) => TEST_COMMAND.test(command)
9
10const count = (text: string, pattern: RegExp): number | null => {
11 let total: number | null = null
12 for (const match of text.matchAll(pattern)) total = (total ?? 0) + Number(match[1])
13 return total
14}
15
16// Count from the runner's test summary line when it prints one (Jest's `Tests:`), so suite
17// totals (`Test Suites: 1 passed`) are not added in; otherwise from every line but suite lines.
18const countedText = (output: string) => {
19 const lines = output.split('\n')
20 const summary = lines.filter(line => /^\s*Tests?:/i.test(line))
21 return (summary.length > 0 ? summary : lines.filter(line => !/\bsuites?\b/i.test(line))).join('\n')
22}
23
24export const readTestRun = (command: string, output: string, isError: boolean): TestRun => {
25 const text = countedText(output)
26 const passed = count(text, /(\d+)\s+(?:pass(?:ed|ing)?|✓)/gi)
27 const failed = count(text, /(\d+)\s+(?:fail(?:ed|ing|ures?)?|✗)/gi)
28 return { command, isPassing: !isError && (failed ?? 0) === 0, passed, failed }
29}
30
31export const field = (input: unknown, name: string): string | undefined => {
32 if (typeof input !== 'object' || input === null) return undefined
33 const value = (input as Record<string, unknown>)[name]
34 return typeof value === 'string' ? value : undefined
35}
36
37// A tool.call's arguments arrive under `input`; fall back to the event itself.
38export const callInput = (e: unknown): unknown => {
39 const input = typeof e === 'object' && e !== null ? (e as { input?: unknown }).input : undefined
40 return typeof input === 'object' && input !== null ? input : e
41}
42
43export const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
44
45export const describeCall = (tool: string, input: unknown): string => {
46 const command = field(input, 'command')
47 if (command !== undefined) return `${tool} · ${command.split('\n')[0] ?? ''}`
48 const path = field(input, 'file_path') ?? field(input, 'notebook_path')
49 if (path !== undefined) return `${tool} · ${baseName(path)}`
50 const pattern = field(input, 'pattern') ?? field(input, 'description')
51 return pattern === undefined ? tool : `${tool} · ${pattern}`
52}
53
54export const firstQuestion = (input: unknown): string => {
55 const questions = (input as { questions?: unknown })?.questions
56 if (Array.isArray(questions)) {
57 const text = field(questions[0], 'question')
58 if (text !== undefined) return text
59 }
60 return 'Claude has a question for you'
61}
62
63export const outputText = (result: unknown): string => {
64 if (typeof result === 'string') return result
65 return [field(result, 'stdout'), field(result, 'stderr')].filter(Boolean).join('\n')
66}
67
68export const emptyAway = (since: number, id = 0, tree: TreeSnapshot | null = null): Away => ({
69 id,
70 since,
71 turns: 0,
72 changed: [],
73 tests: [],
74 errors: 0,
75 tree,
76})
77
78// `git status --porcelain -z` entries as repo-relative paths; a rename or copy carries its old name next.
79export const dirtyPaths = (porcelain: string): string[] => {
80 const fields = porcelain.split('\0').filter(Boolean)
81 const paths: string[] = []
82 for (let i = 0; i < fields.length; i++) {
83 const entry = fields[i] ?? ''
84 paths.push(entry.slice(3))
85 if (/[RC]/.test(entry.slice(0, 2))) i++
86 }
87 return paths
88}
89
90export const hasNews = (away: Away) =>
91 away.turns > 0 || away.changed.length > 0 || away.tests.length > 0 || away.errors > 0
92
93// Names the files the absence recorded, so other edits in the tree are not mixed in.
94export const reviewPrompt = (changed: readonly string[], awayMs: number) => {
95 const head = `I was away for ${duration(awayMs)}.`
96 const ask = 'then anything that needs my decision.'
97 if (changed.length === 0) return `${head} Summarize what you did in that time in short bullets, ${ask}`
98 const listed = changed.slice(0, 30).map(path => `- ${path}`)
99 if (changed.length > 30) listed.push(`- and ${changed.length - 30} more`)
100 return `${head} You changed these files in that time:\n${listed.join('\n')}\nSummarize the change to each in one short bullet, ${ask}`
101}
102
103// Paths whose mark differs between two snapshots, including ones in only one: a file that
104// was changed and then committed or reverted counts as well as a newly changed one.
105export const changedBetween = (before: Record<string, string>, after: Record<string, string>) =>
106 [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(path => before[path] !== after[path])
107
108export const duration = (ms: number) => {
109 const minutes = Math.max(0, Math.round(ms / 60_000))
110 if (minutes < 1) return 'under a minute'
111 if (minutes < 60) return `${minutes} min`
112 const hours = Math.floor(minutes / 60)
113 return `${hours} h ${minutes % 60} min`
114}
115
116export const clock = (ms: number) => {
117 const seconds = Math.max(0, Math.floor(ms / 1000))
118 const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
119 const ss = String(seconds % 60).padStart(2, '0')
120 return `${mm}:${ss}`
121}
122
123export const testLine = (run: TestRun) => {
124 const counts = [
125 run.passed === null ? null : `${run.passed} passed`,
126 run.failed === null ? null : `${run.failed} failed`,
127 ].filter(Boolean)
128 const verdict = run.isPassing ? 'Tests green' : 'Tests failing'
129 return counts.length === 0 ? verdict : `${verdict} · ${counts.join(', ')}`
130}
131
132const gaugeColor = (percent: number) => (percent < 60 ? '#2e9e5b' : percent < 85 ? '#d18b00' : '#d23f31')
133
134// A labelled bar: the track is a neutral grey that reads in light and dark.
135export const gaugeSvg = (label: string, percent: number, width: number) => {
136 const clamped = Math.max(0, Math.min(100, percent))
137 const fill = Math.round((clamped / 100) * width)
138 return (
139 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="34" viewBox="0 0 ${width} 34">` +
140 `<text x="0" y="12" font-family="-apple-system, system-ui, sans-serif" font-size="12" fill="#8a8a8e">${label}</text>` +
141 `<text x="${width}" y="12" text-anchor="end" font-family="-apple-system, system-ui, sans-serif" font-size="12" fill="#8a8a8e">${Math.round(clamped)}%</text>` +
142 `<rect x="0" y="18" width="${width}" height="12" rx="6" fill="#8a8a8e" fill-opacity="0.25"/>` +
143 `<rect x="0" y="18" width="${fill}" height="12" rx="6" fill="${gaugeColor(clamped)}"/>` +
144 `</svg>`
145 )
146}
147types/index.d.ts 40 lines1export type TestRun = { command: string; isPassing: boolean; passed: number | null; failed: number | null }
2
3// One line about a tool call, keyed by its id: something waiting on you, or a step in progress.
4export type CallLine = { id: string; text: string }
5
6// The checkout when the phone left: HEAD, and each changed file marked by size and mtime.
7export type TreeSnapshot = { root: string; head: string | null; marks: Record<string, string> }
8
9export type Away = {
10 // Which departure opened it, so a reconnect closes only the absence it saw.
11 id: number
12 since: number
13 turns: number
14 changed: string[]
15 tests: TestRun[]
16 errors: number
17 tree: TreeSnapshot | null
18}
19
20export type Gauges = { contextPercent: number | null; ratePercent: number | null; costUsd: number | null }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'duo-glance': {
25 isPhone: boolean
26 away: Away | null
27 summary: (Away & { until: number }) | null
28 // Approvals and questions still open, kept whether or not the phone is away.
29 pending: CallLine[]
30 // Main-loop tool calls running now, oldest first.
31 steps: CallLine[]
32 turnStartedAt: number | null
33 lastTest: TestRun | null
34 gauges: Gauges
35 isMonitorOpen: boolean
36 now: number
37 }
38 }
39}
40