A live preview of the app you are building, right beside the chat.

<img src="../../brand/pixel-icons/svg/porthole.svg" width="64" height="64" alt="">
A live preview of the app you are building, right beside the chat.
/plugin install porthole --marketplace modmyclaude/claude-code-mods
| You type | What happens |
|---|---|
/porthole http://localhost:5173 | Opens the Porthole pane with a screenshot of the page, sized to the pane. A site other than localhost opens only when you type the command yourself. |
/porthole | Opens the last dev-server address porthole saw (or the page already in the pane). |
/porthole mobile / /porthole desktop | Switches the viewport to 390 px or 1280 px wide and shoots again. |
/porthole 3000 or /porthole localhost:3000/app | Shorthands for a local address. |
/porthole runs at once even while Claude is working, so you can open or refresh the preview mid-turn. When it works it says so in a toast and adds nothing to the conversation; a refusal (an address it will not open, Chrome missing) stays visible as the command's output.
http://localhost:<port> or http://127.0.0.1:<port>), a toast says Ready to preview http://localhost:5173/ - type /porthole (Claude Code shows it under porthole's name). Each address is offered once per session. Only foreground Bash results are read: a server Claude starts in the background is offered once its log passes through a Bash result (for example cat of the log).Refresh.Refresh button and a Mobile / Desktop button. Close it with the pane's own close mark; that also deletes the picture.Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Field | Default | Meaning |
|---|---|---|
chromePath | empty | Full path to a Chrome or Chromium executable. Empty: porthole looks in /Applications (Google Chrome, Chromium) and then on PATH (google-chrome, google-chrome-stable, chromium, chromium-browser). |
mktemp -d to make a private run folder in $TMPDIR (or /tmp when TMPDIR is unset or not an absolute path), then the Chrome you already have, headless: --headless=new --disable-gpu --hide-scrollbars --no-first-run --no-default-browser-check --disable-extensions --disable-background-networking --disable-sync --use-mock-keychain --force-device-scale-factor=1 --timeout=15000 --user-data-dir=<run folder>/profile --window-size=W,H --screenshot=<run folder>/shot.png -- <url>. Arguments are passed as a list, never through a shell. Chrome is stopped as soon as it reports the PNG written (30 second hard stop). rm -rf runs only on porthole's own run folders ($TMPDIR/porthole.XXXXXX, or /tmp/porthole.XXXXXX without an absolute TMPDIR, and the profile inside), checked by an allow-list before every call.PATH and TMPDIR variables. The screenshot is read by the terminal itself to draw it.localhost and 127.0.0.1 on its own. Any other site opens only when you type /porthole <full http:// or https:// URL> yourself, at the terminal or through Remote Control. The same command arriving from anywhere else (another plugin, a peer session, a channel relay, a task notification, a schedule, an SDK script) gets localhost only and a one-line refusal. file:, javascript:, data:, chrome: and every other scheme are always refused.Image (kitty graphics protocol). The engine documents kitty and Ghostty; in a terminal it cannot draw in (or over ssh or tmux, where the terminal cannot read the file) the pane shows a text stand-in, a toast says so once, and auto refresh pauses. Manual refresh still works.cat of the log). Addresses like 0.0.0.0, [::] or a LAN IP are not detected; type them in full.mktemp and rm; Windows is not supported.This mod was built from prompts/porthole.md.
More mods: the full catalogue.
hooks/register.tsx 510 lines1// porthole: a live screenshot of the app you are building, in a pane beside the chat.
2//
3// /porthole [url] [mobile|desktop] opens the pane; headless Chrome takes the picture
4// with a throwaway profile; the terminal draws it as an Image (kitty graphics).
5// Bash output that names a local dev server raises a toast, and edits refresh the
6// open pane at most once every 2 seconds.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11import type { PortholeImages, PortholeMode, PortholeShot, PortholeView } from '../types'
12import { boltSprite } from './bolt-draw'
13import {
14 chromeArgv,
15 fitBox,
16 isRemovable,
17 isRunDir,
18 lastLine,
19 MAC_CHROMES,
20 PATH_CHROMES,
21 runTemplate,
22 shotSize,
23 tempRoot,
24 VIEWPORT_WIDTH,
25 WRITTEN,
26 type Room,
27} from './shot'
28import { checkTypedUrl, clean, displayUrl, findLocalAddresses, isLocalHost, parseArgs } from './url'
29
30type Engine = EngineInterface
31
32const PANE = 'porthole'
33/** Edits within this window share one refresh, so refreshes are at least this far apart. */
34const REFRESH_MS = 2000
35/** How long after a new picture to ask whether the terminal really drew it. */
36const PROBE_MS = 300
37/** Hard stop for a Chrome that never says it wrote the file and never exits. */
38const CHROME_DEADLINE_MS = 30_000
39/** How a blit refusal reads when the Image is showing its alt text instead of pixels. */
40const DRAWS_ALT = /\balt\b|placeholder|cannot read|can't read/i
41/** Narrower than this, the pane keeps every column for its line and Bolt steps out. */
42const BOLT_MIN_COLUMNS = 40
43
44/**
45 * The command origins that are the person typing: Enter at the terminal, or Remote
46 * Control. Only these may open a site other than localhost (spec: "unless I pass a
47 * full URL myself"). A peer session, channel, notification, schedule, SDK script or
48 * another plugin running /porthole gets localhost only.
49 */
50const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge'])
51const NOT_TYPED =
52 'Porthole opens a site other than localhost only when you type /porthole <url> yourself at the terminal or through Remote Control.'
53
54const NO_IMAGES =
55 "This terminal cannot draw images here. The desktop app's built-in browser pane can preview the page."
56
57const INITIAL_VIEW: PortholeView = { url: null, mode: 'desktop', phase: 'idle', shot: null, error: null }
58const view = atom({ plugin: 'porthole', key: 'view' } as const, INITIAL_VIEW)
59const lastSeen = atom({ plugin: 'porthole', key: 'lastSeen' } as const, null)
60const toasted = atom({ plugin: 'porthole', key: 'toasted' } as const, [])
61const images = atom({ plugin: 'porthole', key: 'images' } as const, 'unknown')
62
63type Outcome = { ok: true; shot: PortholeShot } | { ok: false; error: string }
64type ChromeEnd = { kind: 'written' | 'exited' | 'timeout' | 'failed'; detail: string }
65
66// Module memory: lost on a hot reload, which is fine (the engine drops timers then too).
67// What the pane draws lives in $.state above, so it survives a reload.
68let chromeOption = ''
69let chromeFound: string | undefined
70let refreshTimer: Timer | null = null
71let isScheduling = false
72let running: Promise<void> | null = null
73let runAgain = false
74let room: Room | undefined
75
76const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
77
78/** The one line every surface without an Image element draws (desktop, VS Code, mobile). */
79function elsewhereLine(url: string | null): string {
80 const page = url === null ? 'your page' : clean(url)
81 return `Porthole draws its preview in a terminal. In the desktop app, open ${page} in the built-in browser pane instead.`
82}
83
84const exists = ($: Engine, path: string): Promise<boolean> => $.fs.exists(path).catch(() => false)
85
86async function hasTerminal($: Engine): Promise<boolean> {
87 return (await $.session.surfaces()).includes('terminal')
88}
89
90async function onPath($: Engine, names: readonly string[]): Promise<string | undefined> {
91 const dirs = ((await $.env.get('PATH')) ?? '').split(':').filter(dir => dir.startsWith('/'))
92 for (const name of names) {
93 for (const dir of dirs) {
94 const path = `${dir.replace(/\/+$/, '')}/${name}`
95 if (await exists($, path)) return path
96 }
97 }
98 return undefined
99}
100
101/** The chromePath setting when given, else the usual install places. Only a hit is cached. */
102async function findChrome($: Engine): Promise<string | undefined> {
103 if (chromeFound !== undefined) return chromeFound
104 let found: string | undefined
105 if (chromeOption !== '') {
106 found = chromeOption.includes('/')
107 ? (await exists($, chromeOption)) ? chromeOption : undefined
108 : await onPath($, [chromeOption])
109 } else {
110 for (const path of MAC_CHROMES) {
111 if (await exists($, path)) {
112 found = path
113 break
114 }
115 }
116 found ??= await onPath($, PATH_CHROMES)
117 }
118 chromeFound = found
119 return found
120}
121
122function chromeMissing(): string {
123 return chromeOption === ''
124 ? 'No Chrome or Chromium found. Install one, or set the chromePath setting in /config.'
125 : `Chrome was not found at "${chromeOption}" (the chromePath setting).`
126}
127
128/** rm -rf, but only ever on a run folder (or its profile) under the temp root. */
129async function remove($: Engine, path: string): Promise<void> {
130 const root = tempRoot(await $.env.get('TMPDIR'))
131 if (!isRemovable(path, root)) return
132 await $.process.run(['rm', '-rf', path]).catch(() => undefined)
133}
134
135/**
136 * Runs Chrome and returns as soon as it says the PNG is written. Headless Chrome can
137 * stay alive after its screenshot (seen on macOS with Chrome 154), so the stream is
138 * closed, which ends the child, rather than waiting for an exit that may not come.
139 */
140async function runChrome($: Engine, argv: string[]): Promise<ChromeEnd> {
141 const child = $.process.spawn({ argv })
142 const ended = { code: null, signal: null }
143 let expire = (): void => undefined
144 const deadline = new Promise<'deadline'>(resolve => {
145 expire = () => resolve('deadline')
146 })
147 const timer = $.clock.after(CHROME_DEADLINE_MS, () => expire())
148 let tail = ''
149 try {
150 for (;;) {
151 const pulled = child.next()
152 pulled.catch(() => undefined)
153 const step = await Promise.race([pulled, deadline])
154 if (step === 'deadline') {
155 void child.return(ended).catch(() => undefined)
156 return { kind: 'timeout', detail: '' }
157 }
158 if (step.done === true) return { kind: 'exited', detail: lastLine(tail) }
159 tail = (tail + step.value.text).slice(-4000)
160 if (WRITTEN.test(tail)) {
161 await child.return(ended).catch(() => undefined)
162 return { kind: 'written', detail: '' }
163 }
164 }
165 } catch (error) {
166 return { kind: 'failed', detail: message(error) }
167 } finally {
168 timer.cancel()
169 }
170}
171
172/** One screenshot into a fresh run folder; the profile inside it is deleted straight after. */
173async function shoot($: Engine, url: string, mode: PortholeMode, seq: number): Promise<Outcome> {
174 const chrome = await findChrome($)
175 if (chrome === undefined) return { ok: false, error: chromeMissing() }
176 const root = tempRoot(await $.env.get('TMPDIR'))
177 const made = await $.process.run(['mktemp', '-d', runTemplate(root)])
178 const dir = made.stdout.trim()
179 if (made.exitCode !== 0 || !isRunDir(dir, root)) {
180 return { ok: false, error: `Could not make a temp folder: ${lastLine(made.stderr) || dir}` }
181 }
182 const size = shotSize(mode, room)
183 const ended = await runChrome($, chromeArgv(chrome, { dir, size, url }))
184 await remove($, `${dir}/profile`)
185 const file = `${dir}/shot.png`
186 if (await exists($, file)) {
187 return { ok: true, shot: { file, dir, width: size.width, height: size.height, url, mode, seq } }
188 }
189 await remove($, dir)
190 if (ended.kind === 'timeout') return { ok: false, error: 'Chrome did not finish within 30 seconds.' }
191 return { ok: false, error: `Chrome did not write a screenshot${ended.detail === '' ? '.' : `: ${ended.detail}`}` }
192}
193
194/** After a new picture: one blit of it tells whether the terminal draws pixels or alt text. */
195async function probe($: Engine, shot: PortholeShot): Promise<void> {
196 if ((await read($, images)) !== 'unknown') return
197 if ((await read($, view)).shot?.file !== shot.file) return
198 const answer = await $.ui.blit({ requestId: PANE, key: 'shot', source: { file: shot.file, format: 'png' } }).catch(() => undefined)
199 if (answer === undefined) return
200 if (answer.deny === undefined) {
201 await update($, images, (): PortholeImages => 'yes')
202 } else {
203 $.ui.log(`porthole: blit refused: ${answer.deny}`, { to: 'debug' }) // confirms the regex on a live terminal
204 }
205 if (answer.deny !== undefined && DRAWS_ALT.test(answer.deny)) {
206 let isFirst = false
207 await update($, images, (was): PortholeImages => {
208 isFirst = was !== 'no'
209 return 'no'
210 })
211 if (isFirst) $.ui.toast(NO_IMAGES, { timeoutMs: 8000 })
212 }
213}
214
215async function captureOnce($: Engine): Promise<void> {
216 const before = await read($, view)
217 if (before.url === null || before.phase === 'idle') return
218 if (!(await hasTerminal($))) return
219 const { url, mode } = before
220 await update($, view, (v): PortholeView => ({ ...v, phase: 'loading', error: null }))
221 const outcome = await shoot($, url, mode, (before.shot?.seq ?? 0) + 1).catch(
222 (error): Outcome => ({ ok: false, error: `Screenshot failed: ${message(error)}` }),
223 )
224 const now = await read($, view)
225 if (now.phase === 'idle' || now.url !== url || now.mode !== mode) {
226 // Closed, or the person moved on while Chrome ran: drop this picture.
227 if (outcome.ok) await remove($, outcome.shot.dir)
228 if (now.phase !== 'idle') runAgain = true
229 return
230 }
231 if (!outcome.ok) {
232 await update($, view, (v): PortholeView => ({ ...v, phase: 'error', error: outcome.error }))
233 return
234 }
235 await update($, view, (v): PortholeView => ({ ...v, phase: 'ready', error: null, shot: outcome.shot }))
236 if (now.shot !== null && now.shot.dir !== outcome.shot.dir) await remove($, now.shot.dir)
237 if ((await read($, images)) === 'unknown') {
238 $.clock.after(PROBE_MS, () => void probe($, outcome.shot).catch(() => undefined))
239 }
240}
241
242/** One capture at a time; a request during a capture runs once more after it. */
243function refresh($: Engine): Promise<void> {
244 if (running !== null) {
245 runAgain = true
246 return running
247 }
248 running = (async () => {
249 try {
250 do {
251 runAgain = false
252 await captureOnce($)
253 } while (runAgain)
254 } finally {
255 running = null
256 }
257 })()
258 return running
259}
260
261/** After an edit: one refresh REFRESH_MS later, every edit until then riding along. */
262async function scheduleRefresh($: Engine): Promise<void> {
263 if (refreshTimer !== null || isScheduling) return
264 isScheduling = true
265 try {
266 const current = await read($, view)
267 // A local edit cannot change a remote page, so only local pages refresh.
268 if (current.url === null || current.phase === 'idle' || !isLocalHost(current.url)) return
269 if ((await read($, images)) === 'no') return
270 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
271 refreshTimer = $.clock.after(REFRESH_MS, () => {
272 refreshTimer = null
273 void refresh($).catch(() => undefined)
274 })
275 } finally {
276 isScheduling = false
277 }
278}
279
280/** Pane gone: stop refreshing and delete the picture on disk. */
281async function shutDown($: Engine): Promise<void> {
282 refreshTimer?.cancel()
283 refreshTimer = null
284 const current = await read($, view)
285 await update($, view, (v): PortholeView => ({ ...v, phase: 'idle', shot: null, error: null }))
286 if (current.shot !== null) await remove($, current.shot.dir)
287}
288
289/** Spots local addresses in Bash output; offers each new one once. */
290async function noticeAddresses($: Engine, output: string): Promise<void> {
291 const found = findLocalAddresses(output)
292 if (found.length === 0) return
293 let fresh: string[] = []
294 await update($, toasted, list => {
295 fresh = found.filter(address => !list.includes(address))
296 return fresh.length === 0 ? list : [...list, ...fresh].slice(-50)
297 })
298 const address = fresh[0] ?? found[0]
299 if (address === undefined) return
300 await update($, lastSeen, () => address)
301 if (fresh[0] === undefined || !(await hasTerminal($))) return
302 const current = await read($, view)
303 const isShowing =
304 current.phase !== 'idle' && current.url !== null && new URL(current.url).origin === new URL(address).origin
305 if (!isShowing) $.ui.toast(clean(`Ready to preview ${address} - type /porthole`), { timeoutMs: 8000 })
306}
307
308/** What /porthole says: `done` lines go to a toast (the model need not read them), the rest stay as text. */
309type Said = { done: boolean; text: string }
310const refuse = (text: string): Said => ({ done: false, text })
311
312// Registered `immediate`, so this can run while a turn streams: it reads only porthole's
313// own state and never the transcript or a tool in flight.
314async function runCommand($: Engine, args: string, isPerson: boolean): Promise<Said> {
315 const parsed = parseArgs(args)
316 if ('error' in parsed) return refuse(parsed.error)
317 const current = await read($, view)
318 let url: string | null
319 if (parsed.target !== undefined) {
320 const verdict = checkTypedUrl(parsed.target)
321 if (!verdict.ok) return refuse(verdict.reason)
322 url = verdict.url
323 } else if (parsed.mode !== undefined) {
324 url = current.url ?? (await read($, lastSeen)) // a viewport switch keeps the page
325 } else {
326 url = (await read($, lastSeen)) ?? current.url // bare /porthole: the last dev server seen
327 }
328 if (url !== null && !isPerson && !isLocalHost(url)) {
329 // Typed in the desktop app it arrives as an SDK turn: say porthole is terminal-only
330 // there rather than "type it yourself". Nothing opens either way.
331 return refuse((await hasTerminal($)) ? NOT_TYPED : elsewhereLine(url))
332 }
333 const mode = parsed.mode ?? current.mode
334 const width = VIEWPORT_WIDTH[mode]
335 if (url === null) {
336 if (parsed.mode === undefined) {
337 return refuse('No dev server address seen yet. Open one with /porthole <url>, for example /porthole http://localhost:3000')
338 }
339 await update($, view, (v): PortholeView => ({ ...v, mode }))
340 return { done: true, text: `Viewport set to ${mode} (${width} px wide). Open a page with /porthole <url>.` }
341 }
342 const isTerminal = await hasTerminal($)
343 if (isTerminal && (await findChrome($)) === undefined) return refuse(chromeMissing())
344 const keepsShot = current.url === url && current.mode === mode
345 if (!keepsShot && current.shot !== null) await remove($, current.shot.dir)
346 await update($, view, (v): PortholeView => ({
347 url,
348 mode,
349 phase: 'loading',
350 error: null,
351 shot: keepsShot ? v.shot : null,
352 }))
353 const opened = await $.ui.open({ id: PANE, title: 'Porthole' }).catch(async (error: unknown) => {
354 await update($, view, (v): PortholeView => ({ ...v, phase: 'idle' })) // no pane, so nothing to refresh
355 throw error
356 })
357 if (!isTerminal) return refuse(elsewhereLine(url))
358 void refresh($).catch(() => undefined)
359 const waits = opened.isPlaced ? '' : ` The pane is waiting: ${opened.reason}`
360 return { done: true, text: `${url} at ${width} px (${mode}).${waits}` }
361}
362
363export const register: Register = (on, options) => {
364 chromeOption = typeof options.chromePath === 'string' ? options.chromePath.trim() : ''
365 chromeFound = undefined
366
367 on('session.start', async ($, e, next) => {
368 await $.command.register({
369 name: 'porthole',
370 description: 'Preview a local web page in a side pane (headless Chrome screenshot)',
371 argumentHint: '[url] [mobile|desktop]',
372 immediate: true, // open or refresh the preview while Claude is still working
373 })
374 return next(e)
375 })
376
377 on('command.run', { command: 'porthole' }, async ($, e) => {
378 try {
379 const said = await runCommand($, e.args, PERSON_ORIGINS.has(e.origin.kind))
380 if (!said.done) return { text: clean(said.text) } // refusals stay visible as the command's output
381 $.ui.toast(clean(said.text))
382 return {} // nothing the model needs to read
383 } catch (error) {
384 const text = message(error)
385 return { text: clean(text.startsWith('porthole:') ? text : `porthole: ${text}`) }
386 }
387 })
388
389 // Dev-server detection. An observer: it never changes or refuses the call.
390 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
391 const ran = await next(e)
392 if (ran.deny === undefined && typeof ran.text === 'string') {
393 await noticeAddresses($, ran.text).catch(() => undefined)
394 }
395 return ran
396 }).catch(($, e, next) => next(e))
397
398 // Auto refresh after Claude edits a file (MultiEdit is matched for builds that have it).
399 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
400 const ran = await next(e)
401 const result: unknown = ran.result
402 const isStaged = typeof result === 'object' && result !== null && 'staged' in result && result.staged === true
403 if (ran.deny === undefined && ran.isError !== true && !isStaged) {
404 await scheduleRefresh($).catch(() => undefined)
405 }
406 return ran
407 }).catch(($, e, next) => next(e))
408
409 on('ui.close', { id: PANE }, async ($, e, next) => {
410 const closed = await next(e)
411 await shutDown($).catch(() => undefined)
412 return closed
413 }).catch(($, e, next) => next(e))
414
415 on('session.end', async ($, e, next) => {
416 if (e.reason !== 'clear') {
417 const shot = (await read($, view)).shot
418 if (shot !== null) await remove($, shot.dir).catch(() => undefined)
419 }
420 return next(e)
421 })
422
423 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
424 const current = await read($, view)
425 if (e.surface !== 'terminal') {
426 const { Box, Text } = $.ui.resolve(e)
427 return (
428 <Box key="elsewhere">
429 <Text>{elsewhereLine(current.url)}</Text>
430 </Box>
431 )
432 }
433
434 const { Box, Button, Image, Text } = $.ui.resolve(e)
435 const columns = e.props.bodyColumns
436 const bodyRows = e.props.scroll.bodyRows
437 room = { columns, rows: Math.max(1, bodyRows - 1) } // the next shot's shape follows the pane
438
439 if (current.url === null) {
440 // Bolt waits for the first address: a static sprite, only where the
441 // pane has room for it.
442 const roomy = columns >= BOLT_MIN_COLUMNS
443 return (
444 <Box key="status" flexDirection="row" gap={2} alignItems="center">
445 {roomy ? boltSprite($.ui.resolve(e), e.surface, { size: 'small', pose: 'idle' }) : null}
446 <Text dimColor>No page yet. Type /porthole followed by an address.</Text>
447 </Box>
448 )
449 }
450 const imageSupport = await read($, images)
451 const shot = current.shot
452 const status = [
453 clean(displayUrl(current.url)),
454 `${current.mode} ${VIEWPORT_WIDTH[current.mode]}px`,
455 ...(current.phase === 'loading' ? ['refreshing...'] : shot !== null ? [`shot ${shot.seq}`] : []),
456 ].join(' - ')
457 const extraRows = (imageSupport === 'no' ? 1 : 0) + (current.phase === 'error' ? 1 : 0)
458 const box = shot === null ? null : fitBox(shot, { columns, rows: bodyRows - 1 - extraRows })
459
460 return (
461 <Box flexDirection="column">
462 <Box flexDirection="row">
463 <Box key="status" flexGrow={1} flexShrink={1}>
464 <Text wrap="truncate-end" dimColor>
465 {status}
466 </Text>
467 </Box>
468 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
469 <Button
470 key="mode"
471 label={current.mode === 'desktop' ? 'Mobile' : 'Desktop'}
472 onPress={() => toggleMode($)}
473 />
474 </Box>
475 {imageSupport === 'no' && (
476 <Box key="no-images">
477 <Text color="warning">{NO_IMAGES}</Text>
478 </Box>
479 )}
480 {current.phase === 'error' && current.error !== null && (
481 <Box key="error">
482 <Text color="error">{clean(current.error)}</Text>
483 </Box>
484 )}
485 {shot === null || box === null ? (
486 current.phase === 'loading' && (
487 <Box key="loading">
488 <Text dimColor>Taking a screenshot of {clean(current.url)} ...</Text>
489 </Box>
490 )
491 ) : (
492 <Image
493 key="shot"
494 source={{ file: shot.file, format: 'png' }}
495 columns={box.columns}
496 rows={box.rows}
497 alt={clean(`Screenshot of ${shot.url}. ${NO_IMAGES}`)}
498 />
499 )}
500 </Box>
501 )
502 })
503}
504
505/** The pane's Mobile/Desktop button: flip the viewport, then shoot again. */
506async function toggleMode($: Engine): Promise<void> {
507 await update($, view, (v): PortholeView => ({ ...v, mode: v.mode === 'desktop' ? 'mobile' : 'desktop' }))
508 await refresh($)
509}
510hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/shot.ts 108 lines1// Pure screenshot planning: the Chrome command line, viewport and picture sizes,
2// and which temp paths the mod may clean up. No `$` here; register.tsx does the I/O.
3
4import type { PortholeMode } from '../types'
5
6/** CSS viewport widths, per the spec: a phone and a laptop. */
7export const VIEWPORT_WIDTH: Record<PortholeMode, number> = { mobile: 390, desktop: 1280 }
8/** A phone screen's height, so a mobile shot keeps a phone's shape. */
9export const MOBILE_HEIGHT = 844
10/** Chrome stops loading and shoots anyway after this long (its own --timeout). */
11export const LOAD_TIMEOUT_MS = 15_000
12/** A terminal cell is about twice as tall as it is wide; used to keep pictures unstretched. */
13export const CELL_ASPECT = 2
14
15/** Where Chrome is usually installed. macOS app bundles first, then names looked up on PATH. */
16export const MAC_CHROMES = [
17 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
18 '/Applications/Chromium.app/Contents/MacOS/Chromium',
19]
20export const PATH_CHROMES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']
21
22export type Size = { width: number; height: number }
23export type Room = { columns: number; rows: number }
24
25const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value))
26
27/**
28 * The browser window for a shot. Mobile is a phone (390 x 844). Desktop is 1280 wide
29 * and as tall as the pane's shape asks, so the picture fills the pane it lands in.
30 */
31export function shotSize(mode: PortholeMode, room?: Room): Size {
32 if (mode === 'mobile') return { width: VIEWPORT_WIDTH.mobile, height: MOBILE_HEIGHT }
33 const width = VIEWPORT_WIDTH.desktop
34 if (room === undefined || room.columns < 1 || room.rows < 1) return { width, height: 800 }
35 return { width, height: clamp(Math.round((width * room.rows * CELL_ASPECT) / room.columns), 480, 4096) }
36}
37
38/** The largest box of cells inside `room` that shows a `px`-sized picture without stretching it. */
39export function fitBox(px: Size, room: Room): Room {
40 const columns = clamp(Math.floor(room.columns), 1, 255)
41 const rows = clamp(Math.floor(room.rows), 1, 255)
42 const rowsAtFullWidth = Math.round((columns * px.height) / px.width / CELL_ASPECT)
43 if (rowsAtFullWidth <= rows) return { columns, rows: Math.max(1, rowsAtFullWidth) }
44 const columnsAtFullHeight = Math.round((rows * CELL_ASPECT * px.width) / px.height)
45 return { columns: clamp(columnsAtFullHeight, 1, columns), rows }
46}
47
48/**
49 * The whole Chrome command line, as argv (no shell). A fresh profile inside the
50 * run folder every time: no cookies, no signed-in accounts, nothing shared with the
51 * person's own browser. The URL comes last, after `--`, and is always an http(s)
52 * href besides, so it can never be read as a switch.
53 */
54export function chromeArgv(chrome: string, run: { dir: string; size: Size; url: string }): string[] {
55 return [
56 chrome,
57 '--headless=new',
58 '--disable-gpu',
59 '--hide-scrollbars',
60 '--no-first-run',
61 '--no-default-browser-check',
62 '--disable-extensions',
63 '--disable-background-networking',
64 '--disable-sync',
65 '--use-mock-keychain',
66 '--force-device-scale-factor=1',
67 `--timeout=${LOAD_TIMEOUT_MS}`,
68 `--user-data-dir=${run.dir}/profile`,
69 `--window-size=${run.size.width},${run.size.height}`,
70 `--screenshot=${run.dir}/shot.png`,
71 '--', // ends the switches: the URL is never read as one
72 run.url,
73 ]
74}
75
76/** Chrome's own line once the PNG is on disk (`22337 bytes written to file ...`). */
77export const WRITTEN = /written to file/i
78
79/** The temp folder to make run folders in: $TMPDIR without its trailing slash, else /tmp. */
80export function tempRoot(tmpdir: string | undefined): string {
81 const root = (tmpdir ?? '').replace(/\/+$/, '')
82 return root.startsWith('/') ? root : '/tmp'
83}
84
85/** The mktemp template for one run folder. */
86export const runTemplate = (root: string): string => `${root}/porthole.XXXXXX`
87
88/** True only for a run folder mktemp made from runTemplate(root). */
89export function isRunDir(path: string, root: string): boolean {
90 if (!path.startsWith(`${root}/porthole.`)) return false
91 return /^[A-Za-z0-9]{6,}$/.test(path.slice(root.length + '/porthole.'.length))
92}
93
94/** The only paths the mod ever hands to `rm -rf`: a run folder, or the profile inside one. */
95export function isRemovable(path: string, root: string): boolean {
96 const dir = path.endsWith('/profile') ? path.slice(0, -'/profile'.length) : path
97 return isRunDir(dir, root)
98}
99
100/** The last line of Chrome's stderr, without its `[pid:tid:...]` prefix, short enough for one row. */
101export function lastLine(text: string): string {
102 const lines = text
103 .split('\n')
104 .map(line => line.replace(/^\[[^\]]*\]\s*/, '').trim())
105 .filter(line => line !== '')
106 return (lines.at(-1) ?? '').slice(0, 200)
107}
108hooks/url.ts 138 lines1// Pure URL rules: what porthole may open, and how it spots a dev server in shell output.
2// No `$` here, so every rule is table-tested on its own.
3
4import type { PortholeMode } from '../types'
5
6/** The only hosts porthole opens on its own (detected output, bare ports, shorthands). */
7const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
8
9export type UrlVerdict = { ok: true; url: string } | { ok: false; reason: string }
10
11/** True for an http(s) URL on localhost or 127.0.0.1 with an explicit port and no credentials. */
12export function isLocalAddress(href: string): boolean {
13 let url: URL
14 try {
15 url = new URL(href)
16 } catch {
17 return false
18 }
19 return (
20 (url.protocol === 'http:' || url.protocol === 'https:') &&
21 LOCAL_HOSTS.has(url.hostname) &&
22 url.port !== '' &&
23 url.username === '' &&
24 url.password === ''
25 )
26}
27
28/** True for an http(s) URL on localhost or 127.0.0.1 (any port, no credentials). */
29export function isLocalHost(href: string): boolean {
30 try {
31 const url = new URL(href)
32 return (
33 (url.protocol === 'http:' || url.protocol === 'https:') &&
34 LOCAL_HOSTS.has(url.hostname) &&
35 url.username === '' &&
36 url.password === ''
37 )
38 } catch {
39 return false
40 }
41}
42
43/**
44 * Judges what the person typed after /porthole. A full http(s) URL to any host is
45 * theirs to open; shorthands (`3000`, `localhost:5173/app`) only ever mean a local
46 * address; every other scheme (file:, javascript:, data:, chrome:, ...) is refused.
47 */
48export function checkTypedUrl(input: string): UrlVerdict {
49 const text = input.trim()
50 if (text === '') return { ok: false, reason: 'No address given.' }
51
52 let candidate = text
53 if (/^\d{1,5}$/.test(text)) candidate = `http://localhost:${text}/`
54 else if (/^(localhost|127\.0\.0\.1)(:\d{1,5})?([/?#]|$)/i.test(text)) candidate = `http://${text}`
55 // A dotted prefix (`example.com:3000`) is a host, not a scheme.
56 else if (!/^[a-z][a-z0-9+-]*:/i.test(text)) {
57 return {
58 ok: false,
59 reason: `"${text}" is not a full URL. Porthole opens localhost on its own; for another site type it in full (https://...).`,
60 }
61 }
62
63 let url: URL
64 try {
65 url = new URL(candidate)
66 } catch {
67 return { ok: false, reason: `"${text}" is not a valid URL.` }
68 }
69 if (url.protocol !== 'http:' && url.protocol !== 'https:') {
70 return { ok: false, reason: `Porthole only opens http and https pages, not ${url.protocol} addresses.` }
71 }
72 if (url.hostname === '') return { ok: false, reason: `"${text}" has no host.` }
73 return { ok: true, url: url.href }
74}
75
76// ANSI colour/cursor codes and OSC sequences (terminal hyperlinks): dev servers wrap
77// their URLs in these (Vite bolds the port), so they are removed before scanning.
78const ANSI = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-Z\\-_]/g
79const ADDRESS = /https?:\/\/(?:localhost|127\.0\.0\.1):\d{1,5}(?:[/?#][^\s'"`<>()[\]{}]*)?/gi
80
81/**
82 * Text safe to draw: the engine refuses a whole tree that holds a control character,
83 * and URLs, Chrome's stderr and shell output are not ours. ANSI and OSC sequences
84 * are removed, tabs and line breaks become spaces, and every other C0, DEL or C1
85 * character becomes U+FFFD.
86 */
87export function clean(text: string): string {
88 return text
89 .replace(ANSI, '')
90 .replace(/[\t\n\r]/g, ' ')
91 .replace(/[\u0000-\u001f\u007f-\u009f]/g, '\uFFFD')
92}
93
94/**
95 * The local dev-server addresses in a piece of shell output, as origin roots
96 * (`http://localhost:5173/`), each once, in the order they appear.
97 */
98export function findLocalAddresses(output: string): string[] {
99 const found: string[] = []
100 for (const match of output.replace(ANSI, '').matchAll(ADDRESS)) {
101 const href = match[0].replace(/[.,;:!?]+$/, '')
102 if (!isLocalAddress(href)) continue
103 const root = `${new URL(href).origin}/`
104 if (!found.includes(root)) found.push(root)
105 }
106 return found
107}
108
109/** The URL as the pane header shows it: host, path and query, no scheme or lone slash. */
110export function displayUrl(href: string): string {
111 try {
112 const url = new URL(href)
113 const path = url.pathname === '/' ? '' : url.pathname
114 return `${url.host}${path}${url.search}`
115 } catch {
116 return href
117 }
118}
119
120export type PortholeArgs = { mode?: PortholeMode; target?: string } | { error: string }
121
122/** Splits `/porthole` arguments: an optional `mobile`/`desktop` and an optional address, any order. */
123export function parseArgs(args: string): PortholeArgs {
124 const parsed: { mode?: PortholeMode; target?: string } = {}
125 for (const word of args.trim().split(/\s+/).filter(Boolean)) {
126 const lower = word.toLowerCase()
127 if (lower === 'mobile' || lower === 'desktop') {
128 if (parsed.mode !== undefined && parsed.mode !== lower) return { error: 'Pick one of mobile or desktop.' }
129 parsed.mode = lower
130 } else if (parsed.target === undefined) {
131 parsed.target = word
132 } else {
133 return { error: 'Give one address at a time: /porthole <url> [mobile|desktop].' }
134 }
135 }
136 return parsed
137}
138hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 38 lines1// The values porthole keeps in $.state for the session (they survive a hot reload).
2
3export type PortholeMode = 'desktop' | 'mobile'
4
5/** One finished screenshot: a PNG Chrome wrote into a run folder the mod owns. */
6export type PortholeShot = {
7 file: string
8 dir: string
9 width: number
10 height: number
11 url: string
12 mode: PortholeMode
13 seq: number
14}
15
16/** What the pane shows. */
17export type PortholeView = {
18 url: string | null
19 mode: PortholeMode
20 phase: 'idle' | 'loading' | 'ready' | 'error'
21 shot: PortholeShot | null
22 error: string | null
23}
24
25/** Whether this terminal draws images: learned from the first blit after a shot. */
26export type PortholeImages = 'unknown' | 'yes' | 'no'
27
28declare module 'claude-code' {
29 interface PluginState {
30 porthole: {
31 view: PortholeView
32 lastSeen: string | null
33 toasted: string[]
34 images: PortholeImages
35 }
36 }
37}
38