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

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으로 바꾸세요.
claude --version). Mods are on by default.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`./reload-plugins in it. Otherwise the mod loads the next time you start Claude Code./plugin shows mod active · html-preview under the tabs, and /preview prints Usage: /preview <file.html | url> · /preview setup.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
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.
/preview| Command | What it does |
|---|---|
/preview report.html | Shows a file. A relative path resolves against the session's working directory. |
/preview https://example.com | Shows a URL. |
/preview | Shows the last page again. Before any page, it prints the usage. |
/preview setup | Checks for terminal-browser. If it is missing and Homebrew is installed, it asks before it runs brew install terminal-browser. |
| Key | Button | Action |
|---|---|---|
k | ↑ | Scroll up 600px |
j | ↓ | Scroll down 600px |
t | top | Go to the top |
r | reload | Render again |
x | close | Close the pane |
auto mode, the built-in renderer's pane shows the page. Nothing is asked.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 ``bash claude plugin marketplace add JAICHANGPARK/html-preview claude plugin install html-preview@html-preview ``/reload-plugins를 실행합니다. 아니면 다음에 Claude Code를 시작할 때 로드됩니다./plugin 화면에 mod active · html-preview가 보이고, /preview를 입력해 사용법 안내가 나오면 설치된 것입니다.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
Write/Edit 도구로 .html/.htm 파일을 저장하면 페이지가 바로 열립니다. 다시 수정하면 새 버전으로 다시 열립니다. 예: "이 저장소 구조를 report.html 한 페이지 리포트로 만들어 줘."/preview report.html (파일), /preview https://example.com (URL), /preview (마지막 페이지 다시 보기), /preview setup (terminal-browser 확인·설치. 설치 전에 먼저 물어봅니다)k/j 위·아래 스크롤, t 맨 위, r 다시 렌더링, x 닫기/config에서 mode(auto, pane, split, builtin), split 방향, autoOpen, chromePath를 바꿀 수 있습니다.auto 모드에서 terminal-browser가 없으면 내장 렌더러로 보여 줍니다. split/pane 모드에서는 설치 안내를 띄우고, Homebrew로 설치할지 먼저 물어봅니다. 묻지 않고 설치하지는 않습니다.
mode | Behaviour |
|---|---|
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>. |
pane | Uses only the terminal-browser plugin's pane. |
split | Uses only the CLI split. |
builtin | Uses only the built-in renderer, even when terminal-browser is installed. |
↑ (k) and ↓ (j) scroll by 600px, top (t), reload (r), close (x)./preview with no argument shows the last page again.Set the options in /config, or in ~/.claude/settings.json:
{
"pluginConfigs": {
"html-preview@html-preview": {
"options": { "mode": "auto", "split": "right", "autoOpen": true, "chromePath": "" }
}
}
}
| Option | Values | Default |
|---|---|---|
mode | auto, pane, split, builtin | auto |
split | right, left, down, up | right |
autoOpen | true, false | true |
chromePath | path to a Chrome/Chromium binary | empty: found automatically |
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.
MIT
hooks/register.tsx 361 lines1import { 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}
361hooks/builtin.ts 69 lines1// 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}
69types/index.d.ts 26 lines1/** 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