SLOPSHOPPER

html-preview

Shows HTML files Claude writes (or /preview <file>) like a browser: terminal-browser when installed, else a built-in Chrome-rendered pane

newpaneguardcommandtoastprocess
v0.3.1MITupdated 2026-10-02JAICHANGPARK/html-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · html-preview
│ ┃ html-preview ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to preview yet. Run /preview │ ┃ file.html ⏺ 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 │ │ › /preview │ ⎿ html-preview: Usage: /preview <file.html | url> · /preview set │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · html-preview
Nothing to preview yet. Run /preview file.html
README

html-preview

A Claude Code mod that shows HTML like a browser. When Claude writes or edits a .html file, the page opens right away, so a generated report or plan reads like a page in a browser.

By default the page shows in a Claude Code pane: the terminal-browser plugin's pane when that is installed, otherwise a built-in renderer that takes a screenshot of the page with headless Chrome. Set mode to split to open terminal-browser in a terminal split instead.

Claude가 .html 파일을 만들거나 수정하면 브라우저처럼 바로 보여 주는 Claude Code mod입니다. 기본으로 Claude Code pane 안에 보여 줍니다: terminal-browser 플러그인이 있으면 그 pane, 없으면 내장 렌더러(headless Chrome 스크린샷). 터미널 분할 창의 terminal-browser로 열려면 mode를 split으로 바꾸세요.

Requirements

  • Claude Code v2.1.287 or later (claude --version). Mods are on by default.
  • A terminal with the kitty graphics protocol, such as Ghostty or kitty.
  • One of these:
  • Google Chrome, Chromium, Edge or Brave, for the built-in renderer. Nothing else to install.
  • terminal-browser, for a real, interactive browser.

Install

  1. Add the marketplace and install the plugin: ``bash claude plugin marketplace add JAICHANGPARK/html-preview claude plugin install html-preview@html-preview ` Or, inside Claude Code: /plugin marketplace add JAICHANGPARK/html-preview, then /plugin install html-preview@html-preview`.
  2. If a session is already open, run /reload-plugins in it. Otherwise the mod loads the next time you start Claude Code.
  3. Check that it loaded: /plugin shows mod active · html-preview under the tabs, and /preview prints Usage: /preview <file.html | url> · /preview setup.
  4. Optional, for an interactive browser instead of a screenshot: ``bash brew install terminal-browser # or: curl -fsSL https://terminal-browser.sh/install | bash # the pane inside Claude Code: claude plugin marketplace add zenbu-labs/terminal-browser claude plugin install terminal-browser@terminal-browser ``

Update and uninstall:

claude plugin marketplace update html-preview   # get the latest version
claude plugin uninstall html-preview@html-preview

Usage

Auto: Claude writes HTML

Ask Claude for a page, for example:

Write a one-page HTML report of this repo's structure to report.html.

When the Write or Edit tool saves a *.html / *.htm file, the page opens. Each later edit opens it again, so you see the new version. Set autoOpen to false to stop this.

Manual: /preview

CommandWhat it does
/preview report.htmlShows a file. A relative path resolves against the session's working directory.
/preview https://example.comShows a URL.
/previewShows the last page again. Before any page, it prints the usage.
/preview setupChecks for terminal-browser. If it is missing and Homebrew is installed, it asks before it runs brew install terminal-browser.

In the built-in pane

KeyButtonAction
k↑Scroll up 600px
j↓Scroll down 600px
ttopGo to the top
rreloadRender again
xcloseClose the pane

When terminal-browser is not installed

  • In auto mode, the built-in renderer's pane shows the page. Nothing is asked.
  • In split or pane mode, the transcript shows one line with the install command at session start. When a page should open, the mod asks if it can install terminal-browser with Homebrew. It never installs without asking. Without Homebrew, or if you say no, it shows the commands: ``bash brew install terminal-browser # or curl -fsSL https://terminal-browser.sh/install | bash ``

설치 방법 (한국어)

  1. 마켓플레이스를 추가하고 플러그인을 설치합니다: ``bash claude plugin marketplace add JAICHANGPARK/html-preview claude plugin install html-preview@html-preview ``
  2. 이미 열린 세션이 있으면 /reload-plugins를 실행합니다. 아니면 다음에 Claude Code를 시작할 때 로드됩니다.
  3. /plugin 화면에 mod active · html-preview가 보이고, /preview를 입력해 사용법 안내가 나오면 설치된 것입니다.
  4. (선택) 클릭·스크롤이 되는 실제 브라우저가 필요하면 brew install terminal-browser로 terminal-browser를 설치합니다.

필요한 것: Claude Code v2.1.287 이상(mod는 기본으로 켜져 있음), kitty 그래픽 프로토콜을 지원하는 터미널(Ghostty, kitty)과 Chrome·Chromium·Edge·Brave 중 하나, 또는 terminal-browser.

업데이트: claude plugin marketplace update html-preview · 삭제: claude plugin uninstall html-preview@html-preview

사용 방법 (한국어)

  • 자동: Claude가 Write/Edit 도구로 .html/.htm 파일을 저장하면 페이지가 바로 열립니다. 다시 수정하면 새 버전으로 다시 열립니다. 예: "이 저장소 구조를 report.html 한 페이지 리포트로 만들어 줘."
  • 수동: /preview report.html (파일), /preview https://example.com (URL), /preview (마지막 페이지 다시 보기), /preview setup (terminal-browser 확인·설치. 설치 전에 먼저 물어봅니다)
  • 내장 pane: 마우스 휠·트랙패드로 페이지 스크롤(스크롤할 때마다 다시 렌더링해서 1초쯤 걸림), k/j 위·아래 스크롤, t 맨 위, r 다시 렌더링, x 닫기
  • 설정: /config에서 mode(auto, pane, split, builtin), split 방향, autoOpen, chromePath를 바꿀 수 있습니다.

auto 모드에서 terminal-browser가 없으면 내장 렌더러로 보여 줍니다. split/pane 모드에서는 설치 안내를 띄우고, Homebrew로 설치할지 먼저 물어봅니다. 묻지 않고 설치하지는 않습니다.

How it shows the page

modeBehaviour
auto (default)Keeps the page inside Claude Code. Tries, in order: the terminal-browser plugin's pane ($.browser.open), the built-in renderer's pane when Chrome is installed, then a terminal split with terminal-browser open <file> --split <dir>.
paneUses only the terminal-browser plugin's pane.
splitUses only the CLI split.
builtinUses only the built-in renderer, even when terminal-browser is installed.

The built-in renderer

  • Headless Chrome renders the page 1280px wide, at a height that fits the pane, and the pane draws the screenshot with the kitty graphics protocol.
  • The mouse wheel or trackpad over the pane scrolls the page. Each scroll renders again, so it takes about a second to catch up.
  • Buttons in the pane: ↑ (k) and ↓ (j) scroll by 600px, top (t), reload (r), close (x).
  • Each save of the file renders it again.
  • It is a picture: links, forms and scripts that need clicks do not work. Use terminal-browser for that.
  • Without Chrome, or on a surface that cannot draw images (the desktop app), the pane shows the page as text.
  • /preview with no argument shows the last page again.

Options

Set the options in /config, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "html-preview@html-preview": {
      "options": { "mode": "auto", "split": "right", "autoOpen": true, "chromePath": "" }
    }
  }
}
OptionValuesDefault
modeauto, pane, split, builtinauto
splitright, left, down, upright
autoOpentrue, falsetrue
chromePathpath to a Chrome/Chromium binaryempty: found automatically

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .      # load this checkout in a session

Mods need Claude Code v2.1.287 or later. If you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS during early access, remove it: Claude Code ignores it now.

License

MIT

Source 3 files
hooks/register.tsx 361 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Page } from '../types'
5import { CELL_RATIO, CHROMES, FIT_SLACK, SCHEME, VIEW, fileUrl, fitHeight, htmlToMarkdown, wrap } from './builtin'
6
7// The noun the terminal-browser Claude Code plugin adds to `$`
8// (zenbu-labs/terminal-browser, claude-code-plugin/hooks/browser.d.ts).
9// Declared here rather than as a dependency so this mod still loads, and
10// falls back, when that plugin is not installed.
11type BrowserOpenResult = { ok: true; url: string } | { ok: false; error: string }
12type Browser = { open: (input: { url?: string }) => Promise<BrowserOpenResult> }
13type WithBrowser = EngineInterface & { browser: Browser }
14
15const HTML = /\.html?$/i
16const PANE = 'html-preview'
17const STEP = 600
18// CSS pixels per wheel tick.
19const WHEEL = 120
20const BREW = 'Install with Homebrew'
21const INSTALL_HELP = [
22  'terminal-browser is not installed. Install it, then try again:',
23  '  brew install terminal-browser',
24  '  # or: curl -fsSL https://terminal-browser.sh/install | bash',
25  'Or run /preview setup, or set the mode option to builtin.',
26].join('\n')
27const PLUGIN_HELP =
28  'The terminal-browser plugin is not installed: claude plugin marketplace add zenbu-labs/terminal-browser && claude plugin install terminal-browser@terminal-browser'
29
30const page = atom({ plugin: 'html-preview', key: 'page' } as const, null)
31
32async function has($: EngineInterface, command: string): Promise<boolean> {
33  const run = await $.process.run(['/bin/sh', '-c', `command -v '${command}'`]).catch(() => undefined)
34  return run?.exitCode === 0
35}
36
37async function findChrome($: EngineInterface, configured: string): Promise<string | undefined> {
38  for (const candidate of configured ? [configured] : CHROMES) {
39    if (candidate.startsWith('/')) {
40      const stat = await $.fs.stat(candidate).catch(() => undefined)
41      if (stat?.kind === 'file') return candidate
42    } else if (await has($, candidate)) {
43      return candidate
44    }
45  }
46
47  return undefined
48}
49
50async function workDir($: EngineInterface): Promise<string> {
51  const tmp = (await $.env.get('TMPDIR')) || '/tmp'
52
53  return `${tmp.replace(/\/$/, '')}/claude-html-preview`
54}
55
56/**
57 * Renders `target` (an absolute path or a URL) to a PNG and answers its path.
58 * Chrome can stay up after it writes the screenshot, so the run ends as soon
59 * as Chrome reports the file, and a timer stops a Chrome that never does.
60 */
61async function screenshot(
62  $: EngineInterface,
63  chrome: string,
64  target: string,
65  scrollY: number,
66  height: number,
67  generation: number,
68): Promise<string> {
69  const dir = await workDir($)
70  const png = `${dir}/shot-${generation}.png`
71  const profile = `${dir}/profile`
72  let url = target
73  if (!SCHEME.test(target)) {
74    const page = `${dir}/page.html`
75    await $.fs.write(page, wrap(await $.fs.read(target), target, scrollY))
76    url = fileUrl(page)
77  }
78
79  const child = $.process.spawn({
80    argv: [
81      chrome,
82      '--headless',
83      '--disable-gpu',
84      '--hide-scrollbars',
85      '--no-first-run',
86      '--no-default-browser-check',
87      `--user-data-dir=${profile}`,
88      `--screenshot=${png}`,
89      `--window-size=${VIEW.width},${height}`,
90      '--virtual-time-budget=1500',
91      url,
92    ],
93  })
94  const stop = () => void $.process.run(['pkill', '-f', `--user-data-dir=${profile}`]).catch(() => undefined)
95  const watchdog = $.clock.after(20000, stop)
96  try {
97    for await (const { text } of child) {
98      if (/written to file/i.test(text)) break
99    }
100  } finally {
101    watchdog.cancel()
102    stop()
103  }
104
105  const stat = await $.fs.stat(png).catch(() => undefined)
106  if (stat?.kind !== 'file' || stat.size === 0) throw new Error('Chrome did not write a screenshot')
107  if (generation > 1) void $.process.run(['rm', '-f', `${dir}/shot-${generation - 1}.png`]).catch(() => undefined)
108
109  return png
110}
111
112// Undefined when the terminal-browser plugin is not loaded (no `browser` noun)
113// or its noun throws, so the caller falls back.
114async function openInPane($: EngineInterface, url: string): Promise<BrowserOpenResult | undefined> {
115  try {
116    return await ($ as WithBrowser).browser.open({ url })
117  } catch {
118    return undefined
119  }
120}
121
122async function resolvePath($: EngineInterface, target: string): Promise<string> {
123  if (SCHEME.test(target) || target.startsWith('/')) return target
124  const home = target.startsWith('~/') ? await $.env.get('HOME') : undefined
125  if (home) return `${home}/${target.slice(2)}`
126  return `${await $.session.cwd()}/${target.replace(/^\.\//, '')}`
127}
128
129// Offers a Homebrew install when brew is there; never runs the curl installer
130// on the person's behalf. Answers the line to show either way.
131async function setup($: EngineInterface): Promise<string> {
132  if (await has($, 'terminal-browser')) return 'terminal-browser is installed.'
133  if (!(await has($, 'brew'))) return INSTALL_HELP
134
135  const choice = await $.ui
136    .ask('terminal-browser is not installed. Install it now with Homebrew?', [BREW, 'Not now'])
137    .catch(() => undefined)
138  if (choice !== BREW) return INSTALL_HELP
139
140  $.ui.toast('Installing terminal-browser with Homebrew...')
141  const run = await $.process
142    .run(['brew', 'install', 'terminal-browser'], { timeoutMs: 600000 })
143    .catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
144  if (run.exitCode === 0) return 'Installed terminal-browser.'
145
146  return `brew install terminal-browser failed:\n${(run.stderr || run.stdout).trim().split('\n').slice(-5).join('\n')}`
147}
148
149async function openInSplit($: EngineInterface, target: string, split: string): Promise<string> {
150  try {
151    const run = await $.process.run(['terminal-browser', 'open', target, '--split', split], { timeoutMs: 10000 })
152    if (run.exitCode === 0) return `Opened ${target} in terminal-browser (split ${split})`
153    return `terminal-browser failed: ${(run.stderr || run.stdout).trim() || `exit ${run.exitCode}`}`
154  } catch (err) {
155    return `terminal-browser could not start (${err})`
156  }
157}
158
159// One render at a time: they share Chrome's profile folder. A reload of the
160// module starts these over, as it drops the timers that ran a render.
161let isRendering = false
162let isStale = false
163
164async function render($: EngineInterface, options: PluginOptions): Promise<void> {
165  if (isRendering) {
166    isStale = true
167    return
168  }
169  isRendering = true
170  try {
171    do {
172      isStale = false
173      await renderOnce($, options)
174    } while (isStale)
175  } finally {
176    isRendering = false
177  }
178}
179
180async function renderOnce($: EngineInterface, options: PluginOptions): Promise<void> {
181  const current = await read($, page)
182  if (!current) return
183  const generation = current.generation + 1
184  const land = (fields: Partial<Page>) =>
185    update($, page, shown => (shown?.target === current.target ? { ...shown, ...fields } : shown))
186  try {
187    const text = SCHEME.test(current.target) ? undefined : htmlToMarkdown(await $.fs.read(current.target))
188    const chrome = await findChrome($, String(options.chromePath ?? ''))
189    if (!chrome) {
190      await land({ text, status: 'error', error: 'Chrome/Chromium not found: showing the page as text' })
191      return
192    }
193    const height = current.height ?? VIEW.height
194    const png = await screenshot($, chrome, current.target, current.scrollY, height, generation)
195    await land({ png, generation, rendered: height, text, status: 'ready', error: undefined })
196  } catch (err) {
197    await land({ status: 'error', error: String(err) })
198  }
199}
200
201// Renders off the calling dispatch, which may end before Chrome does.
202function renderLater($: EngineInterface, options: PluginOptions): void {
203  $.clock.after(0, () => void render($, options))
204}
205
206async function showBuiltin($: EngineInterface, target: string, options: PluginOptions): Promise<string> {
207  await update($, page, shown =>
208    shown?.target === target
209      ? { ...shown, status: 'rendering' as const }
210      : { target, generation: shown?.generation ?? 0, scrollY: 0, status: 'rendering' as const },
211  )
212  renderLater($, options)
213  const opened = await $.ui.open({ id: PANE, title: 'HTML preview' }).catch(() => undefined)
214  if (opened && !opened.isPlaced) return `Preview of ${target} is ready: run /preview ${target} to show it`
215
216  return `Showing ${target} in the preview pane`
217}
218
219async function preview($: EngineInterface, raw: string, options: PluginOptions): Promise<string> {
220  const target = await resolvePath($, raw)
221  if (!SCHEME.test(target) && !(await $.fs.stat(target).catch(() => undefined))) return `No such file: ${target}`
222  const mode = String(options.mode)
223  const split = String(options.split)
224  if (mode === 'builtin') return showBuiltin($, target, options)
225
226  if (mode !== 'split') {
227    const opened = await openInPane($, SCHEME.test(target) ? target : fileUrl(target))
228    if (opened?.ok) return `Opened ${target} in terminal-browser`
229    if (mode === 'pane') return opened ? opened.error : PLUGIN_HELP
230  }
231
232  // auto keeps the page in a Claude Code pane: the built-in renderer when
233  // Chrome is there, the CLI split only when it is not.
234  if (mode === 'auto' && (await findChrome($, String(options.chromePath ?? '')))) return showBuiltin($, target, options)
235  if (await has($, 'terminal-browser')) return openInSplit($, target, split)
236  if (mode === 'auto') return showBuiltin($, target, options)
237
238  const setUp = await setup($)
239  return setUp.startsWith('Installed') ? openInSplit($, target, split) : setUp
240}
241
242export const register: Register = (on, options: PluginOptions) => {
243  on('session.start', async ($, e, next) => {
244    const started = await next(e)
245    await $.command.register({
246      name: 'preview',
247      description: 'Show an HTML file (or URL) like a browser; /preview setup installs terminal-browser',
248      argumentHint: '<file.html | url | setup>',
249    })
250    const mode = String(options.mode)
251    if ((mode === 'split' || mode === 'pane') && !(await has($, 'terminal-browser'))) {
252      $.ui.log('html-preview: terminal-browser is not installed. Run /preview setup, or: brew install terminal-browser')
253    }
254
255    return started
256  })
257
258  on('command.run', { command: 'preview' }, async ($, e) => {
259    const arg = e.args.trim()
260    if (arg === 'setup') return { text: await setup($) }
261    if (!arg) {
262      const shown = await read($, page)
263      if (!shown) return { text: 'Usage: /preview <file.html | url>  ·  /preview setup' }
264      await $.ui.open({ id: PANE, title: 'HTML preview' })
265      return { text: `Showing ${shown.target}` }
266    }
267
268    return { text: await preview($, arg, options) }
269  })
270
271  on('tool.call', async ($, e, next) => {
272    const ran = await next(e)
273    if (!options.autoOpen || ran.deny !== undefined || ran.isError) return ran
274    if (e.tool !== 'Write' && e.tool !== 'Edit') return ran
275    if (!HTML.test(e.file_path) || e.agentId) return ran
276
277    $.ui.toast(await preview($, e.file_path, options))
278
279    return ran
280  })
281
282  // The wheel scrolls the page, not the pane: each tick moves the screenshot
283  // and renders again. Bursts of ticks fold into one render.
284  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
285    const shown = await read($, page)
286    if (!shown?.png || e.by === 0) return {}
287    await update($, page, p => (p ? { ...p, scrollY: Math.max(0, p.scrollY + e.by * WHEEL), status: 'rendering' as const } : p))
288    renderLater($, options)
289
290    return {}
291  })
292
293  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
294    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
295    const shown = await read($, page)
296    if (!shown) return <Text dimColor>Nothing to preview yet. Run /preview file.html</Text>
297
298    const scrollBy = (delta: number) => async () => {
299      await update($, page, p => (p ? { ...p, scrollY: delta === 0 ? 0 : Math.max(0, p.scrollY + delta), status: 'rendering' as const } : p))
300      renderLater($, options)
301    }
302    const reload = async () => {
303      await update($, page, p => (p ? { ...p, status: 'rendering' as const } : p))
304      renderLater($, options)
305    }
306    const name = shown.target.split('/').pop() || shown.target
307    const state = shown.status === 'rendering' ? 'rendering…' : shown.status === 'error' ? shown.error : `scroll ${shown.scrollY}px`
308    const controls = (
309      <Box flexDirection="row">
310        <Text bold wrap="truncate">{name} </Text>
311        <Text dimColor wrap="truncate">{state} </Text>
312        <Button label="↑" hotkey="k" onPress={scrollBy(-STEP)} />
313        <Button label="↓" hotkey="j" onPress={scrollBy(STEP)} />
314        <Button label="top" hotkey="t" onPress={scrollBy(0)} />
315        <Button label="reload" hotkey="r" onPress={reload} />
316        <Button label="close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
317      </Box>
318    )
319
320    if (e.surface === 'terminal' && shown.png) {
321      const { Image } = $.ui.resolve(e)
322      const room = Math.max(4, Math.min(255, e.props.scroll.bodyRows - 2))
323      let columns = Math.max(1, Math.min(255, e.props.bodyColumns))
324      // Render the page at the pane's shape, so the picture fills it.
325      const fitted = fitHeight(columns, room)
326      if (Math.abs(fitted - (shown.height ?? VIEW.height)) >= FIT_SLACK) {
327        $.clock.after(0, async () => {
328          await update($, page, p => (p ? { ...p, height: fitted, status: 'rendering' as const } : p))
329          void render($, options)
330        })
331      }
332      const ratio = ((shown.rendered ?? VIEW.height) / VIEW.width) * CELL_RATIO
333      let rows = Math.max(1, Math.round(columns * ratio))
334      if (rows > room) {
335        rows = room
336        columns = Math.max(1, Math.min(columns, Math.round(rows / ratio)))
337      }
338
339      return (
340        <Box flexDirection="column">
341          {controls}
342          <Image
343            key="view"
344            source={{ file: shown.png, format: 'png', generation: shown.generation }}
345            columns={columns}
346            rows={rows}
347            alt={`${name} (this terminal cannot draw images)`}
348          />
349        </Box>
350      )
351    }
352
353    return (
354      <Box flexDirection="column">
355        {controls}
356        {shown.text ? <Markdown text={shown.text} /> : <Text dimColor>rendering…</Text>}
357      </Box>
358    )
359  })
360}
361
hooks/builtin.ts 69 lines
1// Pure helpers of the built-in renderer (headless Chrome screenshots drawn
2// as an Image). Everything that calls `$` lives in register.tsx.
3
4export const VIEW = { width: 1280, height: 800 }
5// A terminal cell is about half as wide as it is tall.
6export const CELL_RATIO = 0.5
7// Viewport heights the pane may ask for, and the change worth a new render.
8const MIN_HEIGHT = 400
9const MAX_HEIGHT = 4000
10export const FIT_SLACK = 50
11
12/** The viewport height whose screenshot fills `columns` x `rows` cells. */
13export function fitHeight(columns: number, rows: number): number {
14  const height = (VIEW.width * rows) / (columns * CELL_RATIO)
15
16  return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(height / FIT_SLACK) * FIT_SLACK))
17}
18
19export const SCHEME = /^[a-z][a-z0-9+.-]*:/i
20
21export const CHROMES = [
22  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
23  '/Applications/Chromium.app/Contents/MacOS/Chromium',
24  '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
25  '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
26  'google-chrome',
27  'google-chrome-stable',
28  'chromium',
29  'chromium-browser',
30  'microsoft-edge',
31]
32
33export function fileUrl(path: string): string {
34  return 'file://' + path.split('/').map(encodeURIComponent).join('/')
35}
36
37// Relative links keep resolving against the original file through <base>;
38// the scroll is a transform, since a headless screenshot ignores scrollTo.
39export function wrap(html: string, path: string, scrollY: number): string {
40  const dir = path.slice(0, path.lastIndexOf('/') + 1)
41  const scroll = scrollY > 0 ? `<style>html{transform:translateY(-${scrollY}px)}</style>` : ''
42  const inject = `<base href="${fileUrl(dir)}">${scroll}`
43  const head = html.match(/<head[^>]*>/i)
44
45  return head ? html.replace(head[0], head[0] + inject) : inject + html
46}
47
48const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
49
50/** The page as rough markdown: for surfaces without Image, or without Chrome. */
51export function htmlToMarkdown(html: string): string {
52  return html
53    .replace(/<(script|style|head|svg|noscript)[\s\S]*?<\/\1>/gi, '')
54    .replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level: string, text: string) => `\n\n${'#'.repeat(Number(level))} ${text}\n\n`)
55    .replace(/<li[^>]*>/gi, '\n- ')
56    .replace(/<a [^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
57    .replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**')
58    .replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*')
59    .replace(/<br\s*\/?>/gi, '\n')
60    .replace(/<\/(p|div|section|article|tr|table|ul|ol|pre|blockquote)>/gi, '\n\n')
61    .replace(/<[^>]+>/g, '')
62    .replace(/&(#\d+|[a-z]+);/gi, (m, name: string) =>
63      name.startsWith('#') ? String.fromCodePoint(Number(name.slice(1))) : (ENTITIES[name.toLowerCase()] ?? m),
64    )
65    .replace(/[ \t]+\n/g, '\n')
66    .replace(/\n{3,}/g, '\n\n')
67    .trim()
68}
69
types/index.d.ts 26 lines
1/** The page the built-in renderer shows in the pane. */
2export type Page = {
3  /** Absolute file path or http(s)/file URL. */
4  target: string
5  /** The latest screenshot (PNG); absent until the first render lands. */
6  png?: string
7  /** Bumped per render, so the terminal reads the new PNG. */
8  generation: number
9  /** Scroll offset in CSS pixels. */
10  scrollY: number
11  /** Viewport height in CSS pixels, fitted to the pane; the width is fixed. */
12  height?: number
13  /** The viewport height the current `png` was taken at. */
14  rendered?: number
15  /** The page as markdown, for surfaces without Image or without Chrome. */
16  text?: string
17  status: 'rendering' | 'ready' | 'error'
18  error?: string
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'html-preview': { page: Page | null }
24  }
25}
26