SLOPSHOPPER

porthole

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

newpaneguardcommandtoastprocess
★ 1v0.1.1MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/porthole
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · porthole
│ ┃ porthole ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀ No page yet. Type /porthole foll │ ┃ ▄██████▄ address. ⏺ Read(src/auth.ts) │ ┃ ██▀███▀███ ⎿ Read 6 lines │ ┃ █▀▀█▀▀█▀▀█ ⏺ Update(src/auth.ts) │ ┃ ▀██▀▀██▀ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /porthole │ ⎿ porthole: No dev server address seen yet. Open one with /porthol │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · porthole
▀▀ No page yet. Type /porthole followed by an addre ▄██████▄ ██▀███▀███ █▀▀█▀▀█▀▀█ ▀██▀▀██▀
README

<img src="../../brand/pixel-icons/svg/porthole.svg" width="64" height="64" alt="">

Porthole

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

Install

/plugin install porthole --marketplace modmyclaude/claude-code-mods

Use it

You typeWhat happens
/porthole http://localhost:5173Opens 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.
/portholeOpens the last dev-server address porthole saw (or the page already in the pane).
/porthole mobile / /porthole desktopSwitches the viewport to 390 px or 1280 px wide and shoots again.
/porthole 3000 or /porthole localhost:3000/appShorthands 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.

  • Dev-server detection. When a Bash result from Claude names a local server (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).
  • Auto refresh. While the pane is open, a file edit by Claude (Edit, Write, MultiEdit, NotebookEdit) refreshes the screenshot 2 seconds later. Edits inside that window share one refresh, so refreshes are never closer than 2 seconds apart, and the dev server gets a moment to rebuild first. Only local pages refresh this way; a remote page you opened refreshes when you press Refresh.
  • In the pane. A status line (address, viewport, shot number), a Refresh button and a Mobile / Desktop button. Close it with the pane's own close mark; that also deletes the picture.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

FieldDefaultMeaning
chromePathemptyFull 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).

What it can touch

  • Processes. For each screenshot: 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.
  • Browser profile. A brand new profile every shot, deleted as soon as Chrome stops: no cookies, no signed-in accounts, nothing shared with your own Chrome.
  • Files and environment. Checks that Chrome and the screenshot exist. Reads no project files. Reads the PATH and TMPDIR variables. The screenshot is read by the terminal itself to draw it.
  • Network. Porthole itself makes no network calls. Chrome loads the page you asked for and everything that page loads in turn (scripts, fonts, images, API calls, which can be on other sites), and runs its JavaScript, as any browser would, once per screenshot. Headless Chrome itself may also make background requests of its own (component updates, safe browsing and similar), even for a localhost page: the flags above switch off the ones Chrome lets you switch off, but this is not verified to be zero.
  • Who may open what. Porthole opens 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.
  • Model calls. None.

Limits

  • Terminal only. The picture is a terminal 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.
  • Desktop app, VS Code, mobile. The pane shows one line pointing you to the desktop app's built-in browser pane, and porthole does not start Chrome there. (Commands typed in the desktop app likely arrive as SDK turns, which count as not typed by you; with no terminal there is no screenshot either way.)
  • A screenshot, not a browser. You cannot click or scroll the page; you see its top. Pages behind a login show the logged-out view, by design of the throwaway profile.
  • The toast has no button. Toasts in this API carry text only, so the toast names the command to type.
  • Detection reads Bash results only. A server Claude starts in the background is caught when its log passes through a Bash result (for example cat of the log). Addresses like 0.0.0.0, [::] or a LAN IP are not detected; type them in full.
  • Sizing. The first shot after opening is 1280 x 800 (the pane has not been measured yet); later desktop shots match the pane's shape. Pictures are fitted assuming a terminal cell about twice as tall as it is wide.
  • macOS and Linux. Uses mktemp and rm; Windows is not supported.

Built from a prompt

This mod was built from prompts/porthole.md.

More mods: the full catalogue.

Source 6 files
hooks/register.tsx 510 lines
1// 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}
510
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/shot.ts 108 lines
1// 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}
108
hooks/url.ts 138 lines
1// 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}
138
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 38 lines
1// 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