Runs the app in this project (a plumber API first; also npm run dev, Shiny for R or Python, Streamlit) on 127.0.0.1 and shows it in a pane: the plumber routes…

Install: /plugin install ui-preview --marketplace samuelbfernandes/moddeck
Runs the app in your project on 127.0.0.1 and shows it in a pane next to the chat. For a plumber API the pane lists the routes. It also shows the app's last output lines and a screenshot, and has a button that drafts a review prompt for Claude. Nothing starts when the mod loads: only a command or a button starts anything.
/ui-preview open the pane (and say where things stand) /ui-preview start [file] [--port N] start the app (detected, or the file you name) /ui-preview stop stop it /ui-preview shot take a screenshot /ui-preview status one-line status
The pane has buttons for Start (or Stop), Screenshot, Ask Claude to review and Refresh.
The first match wins, unless you name a file:
#* @get, @post, @put, @delete, @patch, @assets or @plumber, looking in the root folder and one level below it. entrypoint.R, plumber.R and api.R are preferred, in that order. Rscript -e "pr <- plumber::plumb('<file>'); plumber::pr_run(pr, host = '127.0.0.1', port = <port>)". An entrypoint.R (one with annotations, or one that builds a router with pr(...)) is started with plumber::plumb(dir = '<its folder>'), which is how plumber reads an entrypoint.package.json with a scripts.dev, in the root or in frontend/, client/, web/, ui/ or app/. It runs npm run dev -- --host 127.0.0.1 --port <port> in that folder. This assumes a Vite-style dev script that accepts --host and --port.app.R that contains shinyApp or shiny::. Rscript -e "shiny::runApp('<dir>', host = '127.0.0.1', port = <port>, launch.browser = FALSE)".app.py with from shiny or import shiny. shiny run --host 127.0.0.1 --port <port> <file>..py file with import streamlit. streamlit run <file> --server.headless true --server.address 127.0.0.1 --server.port <port>.The default port is 8000 (5173 for npm). If something already answers on that port, it tries the next one (up to 20 tries). If the app prints a different http://127.0.0.1:N or http://localhost:N while it starts, that URL is used. It checks the URL once a second until the app answers, for up to 60 s. After that the pane says silent, and the app keeps running.
<url>/openapi.json (method, path, summary), and the root URL. A plumber API that declares @assets serves its JS front end from there. The Swagger page is at <url>/__docs__/.Image element: pixels on kitty or Ghostty, and the file's path as text on other terminals. The desktop app shows the path.token=, password:, api_key=, Bearer ..., sk-..., ghp_..., AWS keys, JWTs) are replaced with ***.It runs headless Chrome, Chromium or Edge. It tries, in order:
/Applications/Google Chrome.app/.../Applications/Chromium.app/...google-chrome, chromium and chromium-browser on PATHchrome.exe / msedge.exe under Program Files (and %LOCALAPPDATA%)The command is --headless=new --disable-gpu --hide-scrollbars --window-size=1280,800 --user-data-dir=<project>/.claude/ui-preview/chrome-profile --no-first-run --no-default-browser-check --screenshot=<png> <url>. The separate profile means your own browser and its logins are never touched or opened.
For a plumber API whose root answers 404 (no front end), it takes the screenshot at /__docs__/ instead. The PNG is saved to <project>/.claude/ui-preview/shot.png. The mod creates that folder and puts a .gitignore containing * in it, so the screenshot and the browser profile stay out of git. That folder should stay git-ignored. If no browser is found, it says so.
This puts a draft in the prompt box, added after anything you have already typed, and does not send it: Review the running app at <url>. Screenshot: <png>. Check layout, spacing, contrast and broken requests; if the chrome-devtools MCP is connected, open the page with it and read the console and network errors. If the prompt box cannot take the draft (a dialog is open), the draft is copied to the clipboard and the pane says so. Nothing is sent to the model until you press Enter.
http://127.0.0.1:<port> and http://localhost:<port>, to check whether the app answers and to read openapi.json. This is enforced in code: any other URL is refused before the request is made.Rscript, npm (npm.cmd on Windows), shiny, streamlit, and a browser for screenshots. They are always started as argument lists, never through a shell./ui-preview stop, when the session ends, or when the mod is reloaded.{ plugin: 'ui-preview', key: 'status' } ('stopped' | 'starting' | 'running' | 'silent' | 'exited') and { plugin: 'ui-preview', key: 'url' } (string). The pane also reads app, lines, routes, shot and note.
/ui-preview stop stops the app. /plugin disable ui-preview turns off the mod.
claude plugin test, 33 tests). The tests cover detection, the start commands, the localhost-only rule, the port step, picking up a printed URL, parsing the routes, scrubbing output, the screenshot command, the pane on the terminal and the desktop, the review draft, and starting and stopping.running, both routes were read from openapi.json, shot wrote a 1280x800 PNG, and stop ended the R process.npm.cmd without a shell is UNVERIFIED.npm run dev starts its own child process (node/vite). Stopping kills npm; whether the dev server underneath also stops depends on npm and the platform (UNVERIFIED).hooks/register.tsx 491 lines1// ui-preview: runs the app in this project on 127.0.0.1 (a plumber API first; also `npm run dev`, Shiny for R, Shiny for
2// Python, Streamlit) and shows it in a pane: plumber's routes, the child's last output lines, a headless-browser
3// screenshot, and a button that drafts a review prompt in the prompt box.
4// Nothing runs on load: only on a command or a button. The network is used only for http://127.0.0.1:N and
5// http://localhost:N (enforced in `localFetch`). The person's own browser is never opened.
6// FAIL OPEN: every hook here only answers its own command or draws its own pane; any error becomes a line of text,
7// and no tool call, prompt or other plugin is ever touched.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, HttpResponse, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderSurface } from 'claude-code'
10
11import type { UiPreviewApp, UiPreviewStatus } from '../types'
12import {
13 DEFAULT_PORT,
14 addOutput,
15 argvFor,
16 browserCandidates,
17 classify,
18 detect,
19 isLocalUrl,
20 localUrl,
21 originOf,
22 parseArgs,
23 parseRoutes,
24 relPathOf,
25 reviewText,
26 shotArgv,
27 urlIn,
28} from './lib'
29
30type Dollar = EngineInterface
31
32const COMMAND = 'ui-preview'
33const PANE = 'ui-preview'
34const TITLE = 'UI preview'
35const POLL_MS = 1000
36const WAIT_MS = 60_000
37const PORT_TRIES = 20
38const SHOT_MS = 45_000
39const SHOWN_OUTPUT = 15
40const SHOWN_ROUTES = 60
41
42const status = atom({ plugin: 'ui-preview', key: 'status' } as const, 'stopped')
43const url = atom({ plugin: 'ui-preview', key: 'url' } as const, '')
44const app = atom({ plugin: 'ui-preview', key: 'app' } as const, null)
45const lines = atom({ plugin: 'ui-preview', key: 'lines' } as const, [])
46const routes = atom({ plugin: 'ui-preview', key: 'routes' } as const, [])
47const shot = atom({ plugin: 'ui-preview', key: 'shot' } as const, null)
48const note = atom({ plugin: 'ui-preview', key: 'note' } as const, '')
49
50type Stream = AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult> & { readonly result: Promise<ProcessSpawnResult> }
51
52// The running child and its timers are not drawn state and die with the module (a reload kills the child), so module
53// variables are the right place. `generation` tells a loop that has been replaced or stopped to end.
54let child: { id: number; stream: Stream } | undefined
55let generation = 0
56let partial = ''
57let pollTimer: { cancel: () => void } | undefined
58let isPolling = false
59let browser: Promise<string | undefined> | undefined
60
61// ---------------------------------------------------------------- small pieces
62
63const quiet = async (work: () => unknown): Promise<void> => {
64 try {
65 await work()
66 } catch {
67 /* fail-open */
68 }
69}
70
71const say = ($: Dollar, text: string) => quiet(() => update($, note, () => text.slice(0, 300)))
72
73/** The only door to the network: http://127.0.0.1:N or http://localhost:N, nothing else. */
74async function localFetch($: Dollar, target: string): Promise<HttpResponse> {
75 if (!isLocalUrl(target)) throw new Error(`ui-preview only fetches http://127.0.0.1 or http://localhost on a port, not ${target.slice(0, 80)}`)
76 return $.http.fetch(target)
77}
78
79const answers = async ($: Dollar, target: string): Promise<boolean> => {
80 try {
81 await localFetch($, target)
82 return true
83 } catch {
84 return false
85 }
86}
87
88async function rootOf($: Dollar): Promise<string> {
89 return (await $.session.cwd()).replace(/[\\/]+$/, '')
90}
91
92const sepOf = (root: string): string => (root.includes('\\') && !root.includes('/') ? '\\' : '/')
93const isWindows = (root: string): boolean => /^[A-Za-z]:[\\/]/.test(root) || sepOf(root) === '\\'
94const join = (root: string, rel: string): string => (rel === '' ? root : `${root}${sepOf(root)}${rel.split('/').join(sepOf(root))}`)
95
96async function isPaneOpen($: Dollar): Promise<boolean> {
97 try {
98 return (await $.ui.panes()).some(p => p.id === PANE)
99 } catch {
100 return false
101 }
102}
103
104async function openPane($: Dollar): Promise<void> {
105 if (!(await isPaneOpen($))) await quiet(() => $.ui.open({ id: PANE, title: TITLE, columns: 64 }))
106}
107
108// ---------------------------------------------------------------- starting and stopping
109
110async function findApp($: Dollar, root: string, file: string): Promise<UiPreviewApp | string> {
111 if (file === '') {
112 const found = await detect({
113 list: async rel => (await $.fs.list(join(root, rel))).map(e => ({ name: e.name, kind: e.kind })),
114 read: async rel => String(await $.fs.read(join(root, rel))),
115 })
116 return found ?? 'no app found: no plumber annotations (#* @get ...), no package.json "dev" script, no Shiny app.R or app.py, no Streamlit script. Name one: /ui-preview start <file>'
117 }
118 const rel = relPathOf(root, file)
119 if (rel === undefined) return `"${file.slice(0, 80)}" is outside this project`
120 let target = rel
121 try {
122 if ((await $.fs.stat(join(root, rel))).kind === 'dir') target = `${rel}/package.json`
123 } catch {
124 return `"${rel.slice(0, 80)}" was not found`
125 }
126 let text = ''
127 try {
128 text = String(await $.fs.read(join(root, target)))
129 } catch {
130 return `could not read "${target.slice(0, 80)}"`
131 }
132 return classify(target, text) ?? `"${target.slice(0, 80)}" is not a plumber, Shiny, Streamlit or npm-dev app`
133}
134
135/** The wanted port, or the next one that nobody answers on. */
136async function freePort($: Dollar, wanted: number): Promise<number | undefined> {
137 for (let port = wanted; port < wanted + PORT_TRIES && port <= 65535; port++) if (!(await answers($, localUrl(port)))) return port
138 return undefined
139}
140
141async function start($: Dollar, file: string, port: number | undefined): Promise<string> {
142 if (child !== undefined) return `ui-preview · already running at ${await read($, url)} · /ui-preview stop first`
143 const root = await rootOf($)
144 const found = await findApp($, root, file)
145 if (typeof found === 'string') {
146 await say($, found)
147 return `ui-preview · ${found}`
148 }
149 const wanted = port ?? DEFAULT_PORT[found.kind]
150 const free = await freePort($, wanted)
151 if (free === undefined) return `ui-preview · ports ${wanted} to ${wanted + PORT_TRIES - 1} all answer already · pick one with --port`
152 const { argv, cwd } = argvFor(found, free, isWindows(root) ? 'npm.cmd' : 'npm')
153 const at = localUrl(free)
154 await update($, app, () => found)
155 await update($, url, () => at)
156 await update($, status, () => 'starting')
157 await update($, lines, () => [])
158 await update($, routes, () => [])
159 await say($, `started: ${argv[0]} ${argv.slice(1).join(' ')}`.slice(0, 300))
160 partial = ''
161 runChild($, argv, join(root, cwd))
162 startPoll($)
163 await openPane($)
164 return `ui-preview · starting ${found.label} at ${at}${free !== wanted ? ` (port ${wanted} was taken)` : ''}`
165}
166
167/** Starts the child and reads it in a loop that outlives this dispatch; the loop's end is the child's. */
168function runChild($: Dollar, argv: string[], cwd: string): void {
169 const id = ++generation
170 const stream = $.process.spawn({ argv, cwd }) as Stream
171 child = { id, stream }
172 void (async () => {
173 let ending = ''
174 try {
175 for await (const chunk of stream) {
176 if (generation !== id) break
177 await onOutput($, chunk.text)
178 }
179 if (generation === id) {
180 const r = await stream.result.catch(() => undefined)
181 ending = r === undefined ? 'the app ended' : `the app ended (${r.signal ?? `exit ${r.code}`})`
182 }
183 } catch (error) {
184 ending = `could not start ${argv[0]}: ${(error instanceof Error ? error.message : String(error)).split('\n')[0]!.slice(0, 160)}`
185 } finally {
186 if (generation === id) {
187 child = undefined
188 stopPoll()
189 await quiet(() => update($, status, () => 'exited'))
190 await say($, ending || 'the app ended')
191 }
192 }
193 })()
194}
195
196async function onOutput($: Dollar, text: string): Promise<void> {
197 const before = partial
198 let kept: string[] = []
199 await update($, lines, old => {
200 const next = addOutput(old, before, text)
201 kept = next.lines
202 partial = next.partial
203 return next.lines
204 })
205 // The URL the app itself prints wins over the one we asked for (a dev server that moved to another port).
206 const printed = urlIn(`${before}${text}`) ?? kept.map(l => urlIn(l)).find(u => u !== undefined)
207 const current = await read($, url)
208 if (printed !== undefined && originOf(printed) !== originOf(current) && (await read($, status)) === 'starting') {
209 await update($, url, () => printed)
210 }
211}
212
213async function stop($: Dollar): Promise<string> {
214 const was = child
215 generation++
216 child = undefined
217 stopPoll()
218 await update($, status, () => 'stopped')
219 if (was === undefined) return 'ui-preview · nothing is running'
220 // Ending the loop is what kills the child: return() closes the stream.
221 await quiet(() => was.stream.return(undefined as never))
222 await say($, 'stopped')
223 return 'ui-preview · stopped'
224}
225
226// ---------------------------------------------------------------- waiting for it to answer
227
228function startPoll($: Dollar): void {
229 stopPoll()
230 let ticks = 0
231 pollTimer = $.clock.every(POLL_MS, () => {
232 ticks++
233 void pollOnce($, ticks * POLL_MS).catch(() => undefined)
234 })
235}
236
237function stopPoll(): void {
238 pollTimer?.cancel()
239 pollTimer = undefined
240 isPolling = false
241}
242
243async function pollOnce($: Dollar, waited: number): Promise<void> {
244 if (isPolling || child === undefined) return
245 isPolling = true
246 try {
247 const target = await read($, url)
248 if (await answers($, target)) {
249 stopPoll()
250 await update($, status, () => 'running')
251 await say($, `answering at ${target}`)
252 await loadRoutes($)
253 return
254 }
255 if (waited >= WAIT_MS) {
256 stopPoll()
257 await update($, status, () => 'silent')
258 await say($, `no answer at ${target} after ${Math.round(WAIT_MS / 1000)} s; the output below may say why`)
259 }
260 } finally {
261 isPolling = false
262 }
263}
264
265async function loadRoutes($: Dollar): Promise<void> {
266 const found = await read($, app)
267 if (found?.kind !== 'plumber') return
268 try {
269 const r = await localFetch($, `${originOf(await read($, url))}/openapi.json`)
270 if (!r.ok) return say($, `openapi.json answered ${r.status}`)
271 const list = parseRoutes(r.text.length > 4_000_000 ? '' : r.text)
272 await update($, routes, () => list)
273 } catch {
274 await say($, 'could not read openapi.json')
275 }
276}
277
278// ---------------------------------------------------------------- screenshot and review
279
280async function findBrowser($: Dollar): Promise<string | undefined> {
281 const env = {
282 localAppData: await $.env.get('LOCALAPPDATA').catch(() => undefined),
283 programFiles: await $.env.get('ProgramFiles').catch(() => undefined),
284 programFilesX86: await $.env.get('ProgramFiles(x86)').catch(() => undefined),
285 }
286 for (const c of browserCandidates(env)) {
287 if (!c.isName) {
288 if (await $.fs.exists(c.path).catch(() => false)) return c.path
289 continue
290 }
291 const ok = await $.process
292 .run([c.path, '--version'], { timeoutMs: 5000 })
293 .then(r => r.exitCode === 0)
294 .catch(() => false)
295 if (ok) return c.path
296 }
297 return undefined
298}
299
300async function takeShot($: Dollar): Promise<string> {
301 const target = await read($, url)
302 if (child === undefined || target === '' || !isLocalUrl(target)) return 'ui-preview · start the app first (/ui-preview start)'
303 browser ??= findBrowser($)
304 const found = await browser
305 if (found === undefined) {
306 browser = undefined // look again next time: one may have been installed
307 await say($, 'no Chrome, Chromium or Edge found for screenshots')
308 return 'ui-preview · no Chrome, Chromium or Edge found for screenshots'
309 }
310 const root = await rootOf($)
311 const dir = join(root, '.claude/ui-preview')
312 const png = join(root, '.claude/ui-preview/shot.png')
313 // Writing this creates the folder and keeps everything in it (shot, browser profile) out of git.
314 await $.fs.write(join(root, '.claude/ui-preview/.gitignore'), '*\n')
315 const before = await $.fs.stat(png).then(s => s.mtimeMs).catch(() => 0)
316 // A plumber API with no @assets front end answers 404 at its root: its docs page is the useful picture then.
317 let page = target
318 if ((await read($, app))?.kind === 'plumber') {
319 const rootAnswer = await localFetch($, target).catch(() => undefined)
320 if (rootAnswer?.status === 404) page = `${originOf(target)}/__docs__/`
321 }
322 const argv = shotArgv(found, png, join(root, '.claude/ui-preview/chrome-profile'), page)
323 try {
324 await $.process.run(argv, { cwd: dir, timeoutMs: SHOT_MS })
325 } catch (error) {
326 const why = (error instanceof Error ? error.message : String(error)).split('\n')[0]!.slice(0, 120)
327 await say($, `screenshot failed: ${why}`)
328 return `ui-preview · screenshot failed: ${why}`
329 }
330 const after = await $.fs.stat(png).catch(() => undefined)
331 if (after === undefined || after.kind !== 'file' || after.mtimeMs === before) {
332 await say($, 'the browser ran but wrote no screenshot')
333 return 'ui-preview · the browser ran but wrote no screenshot'
334 }
335 await update($, shot, () => ({ path: png, generation: Math.floor(after.mtimeMs) }))
336 await say($, `screenshot saved: ${png}`)
337 await openPane($)
338 return `ui-preview · screenshot saved: ${png}`
339}
340
341async function review($: Dollar, surface?: RenderSurface): Promise<string> {
342 const target = await read($, url)
343 if (target === '') return 'ui-preview · start the app first'
344 const text = reviewText(target, (await read($, shot))?.path)
345 try {
346 const filled = await $.prompt.fill({ text, mode: 'append' })
347 if (filled.isFilled) {
348 await say($, 'review draft is in the prompt box: edit it, then press Enter')
349 return 'ui-preview · the review draft is in the prompt box'
350 }
351 } catch {
352 // fall through to the clipboard
353 }
354 const copied = await $.ui.copy({ text, ...(surface === undefined ? {} : { surface }) }).catch(() => ({ isCopied: false as const }))
355 const words = copied.isCopied ? 'prompt box busy: review draft copied to the clipboard' : 'could not place the review draft; it is below'
356 await say($, words)
357 return copied.isCopied ? `ui-preview · ${words}` : `ui-preview · ${words}\n${text}`
358}
359
360async function refresh($: Dollar): Promise<void> {
361 const target = await read($, url)
362 if (child === undefined || target === '') return
363 if (await answers($, target)) {
364 stopPoll()
365 await update($, status, () => 'running')
366 await loadRoutes($)
367 }
368}
369
370async function statusText($: Dollar): Promise<string> {
371 const s = shownStatus(await read($, status))
372 const a = await read($, app)
373 const u = await read($, url)
374 const r = await read($, routes)
375 const sh = await read($, shot)
376 const n = await read($, note)
377 const head = `ui-preview · ${s}${a === null ? '' : ` · ${a.label}`}${u === '' || s === 'stopped' ? '' : ` · ${u}`}`
378 const out = [head]
379 if (a?.kind === 'plumber' && r.length > 0) out.push(`${r.length} routes (see /ui-preview for the list)`)
380 if (sh !== null) out.push(`screenshot: ${sh.path}`)
381 if (n !== '') out.push(n)
382 return out.join('\n')
383}
384
385/** A status the module no longer backs (a reload killed the child) reads as stopped. */
386function shownStatus(s: UiPreviewStatus): UiPreviewStatus {
387 return child === undefined && (s === 'starting' || s === 'running' || s === 'silent') ? 'stopped' : s
388}
389
390// ---------------------------------------------------------------- the hooks
391
392export const register: Register = on => {
393 on('session.start', async ($, e, next) => {
394 await quiet(async () => {
395 await update($, status, () => 'stopped')
396 await update($, note, () => '')
397 })
398 await quiet(() =>
399 $.command.register({
400 name: COMMAND,
401 description: 'Run this project’s app on 127.0.0.1 and show it in a pane: /ui-preview [start [file] [--port N] | stop | shot | status]',
402 argumentHint: '[start [file] [--port N] | stop | shot | status]',
403 }),
404 )
405 return next(e)
406 })
407
408 on('session.end', async ($, e, next) => {
409 if (child !== undefined) await quiet(() => stop($))
410 return next(e)
411 }).catch(($, e, next) => next(e))
412
413 on('command.run', { command: COMMAND }, async ($, e) => {
414 const p = parseArgs(e.args)
415 switch (p.kind) {
416 case 'open':
417 await openPane($)
418 return { text: await statusText($) }
419 case 'status':
420 return { text: await statusText($) }
421 case 'start':
422 if (p.error !== '') return { text: `ui-preview · ${p.error}` }
423 return { text: await start($, p.file, p.port) }
424 case 'stop':
425 return { text: await stop($) }
426 case 'shot':
427 return { text: await takeShot($) }
428 default:
429 return { text: `ui-preview · unknown option "${p.word}"\nUse /ui-preview, or start [file] [--port N], stop, shot, status.` }
430 }
431 }).catch(() => ({ text: 'ui-preview · that did not work' }))
432
433 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
434 const { Box, Button, Text } = $.ui.resolve(e)
435 const width = Math.max(20, e.props.bodyColumns)
436 const cut = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
437 const s = shownStatus(await read($, status))
438 const a = await read($, app)
439 const u = await read($, url)
440 const r = await read($, routes)
441 const sh = await read($, shot)
442 const n = await read($, note)
443 const out = await read($, lines)
444 const isUp = s === 'starting' || s === 'running' || s === 'silent'
445 const color = s === 'running' ? 'green' : s === 'exited' ? 'red' : s === 'silent' ? 'yellow' : undefined
446 const press = (work: () => Promise<unknown>) => () => work().catch(() => say($, 'that did not work'))
447
448 // The picture where the surface draws one (the terminal's Image: kitty or Ghostty show pixels, others its alt);
449 // elsewhere the path.
450 let picture = sh === null ? null : <Text dimColor>{cut(`screenshot: ${sh.path}`)}</Text>
451 if (sh !== null && e.surface === 'terminal') {
452 const { Image } = $.ui.resolve(e)
453 const columns = Math.max(10, Math.min(width, 120))
454 const rows = Math.max(4, Math.min(255, Math.round((columns * 800) / 1280 / 2)))
455 picture = <Image key="shot" source={{ file: sh.path, format: 'png', generation: sh.generation }} columns={columns} rows={rows} alt={cut(`screenshot: ${sh.path}`)} />
456 }
457
458 return (
459 <Box flexDirection="column">
460 <Text bold {...(color === undefined ? {} : { color })}>
461 {cut(`${s}${a === null ? '' : ` · ${a.label}`}`)}
462 </Text>
463 {u !== '' && isUp && <Text>{cut(u)}</Text>}
464 <Box flexDirection="row" flexWrap="wrap">
465 {isUp ? <Button key="stop" label="Stop" onPress={press(() => stop($))} /> : <Button key="start" label="Start" variant="primary" onPress={press(() => start($, '', undefined))} />}
466 <Button key="shot" label="Screenshot" onPress={press(() => takeShot($))} />
467 <Button key="review" label="Ask Claude to review" onPress={p => press(() => review($, p.surface))()} />
468 <Button key="refresh" label="Refresh" onPress={press(() => refresh($))} />
469 </Box>
470 {n !== '' && <Text dimColor>{cut(n)}</Text>}
471 {a?.kind === 'plumber' && (
472 <Box flexDirection="column">
473 <Text bold>{cut(`Routes${r.length > 0 ? ` (${r.length})` : ''} · root ${u === '' ? '-' : `${originOf(u)}/`} (serves the @assets front end, if any)`)}</Text>
474 {r.length === 0 && <Text dimColor>{s === 'running' ? 'none read from /openapi.json' : 'listed once the API answers'}</Text>}
475 {r.slice(0, SHOWN_ROUTES).map(route => (
476 <Text wrap="truncate-end">{cut(`${route.method.padEnd(6)} ${route.path}${route.summary === '' ? '' : ` ${route.summary}`}`)}</Text>
477 ))}
478 {r.length > SHOWN_ROUTES && <Text dimColor>+{r.length - SHOWN_ROUTES} more</Text>}
479 {u !== '' && <Text dimColor>{cut(`API docs: ${originOf(u)}/__docs__/`)}</Text>}
480 </Box>
481 )}
482 {picture}
483 {out.length > 0 && <Text bold>Output</Text>}
484 {out.slice(-SHOWN_OUTPUT).map(line => (
485 <Text dimColor wrap="truncate-end">{cut(line)}</Text>
486 ))}
487 </Box>
488 )
489 })
490}
491hooks/lib.ts 329 lines1// Pure helpers: which app a project holds, the argv that starts it, the localhost-only rule, the child's output,
2// plumber's routes, the browser candidates and the review draft. Nothing here touches `$`.
3
4import type { UiPreviewApp, UiPreviewKind, UiPreviewRoute } from '../types'
5
6export type { UiPreviewApp, UiPreviewKind, UiPreviewRoute }
7
8/** What detection may look at: names in a folder (relative to the project root, '' for the root) and a file's text. */
9export type Probe = {
10 list: (relDir: string) => Promise<{ name: string; kind: 'file' | 'dir' | 'other' }[]>
11 read: (relPath: string) => Promise<string>
12}
13
14export const DEFAULT_PORT: Record<UiPreviewKind, number> = { plumber: 8000, npm: 5173, 'shiny-r': 8000, 'shiny-py': 8000, streamlit: 8000 }
15export const KIND_LABEL: Record<UiPreviewKind, string> = {
16 plumber: 'plumber API',
17 npm: 'npm run dev',
18 'shiny-r': 'Shiny for R',
19 'shiny-py': 'Shiny for Python',
20 streamlit: 'Streamlit',
21}
22
23const SKIP_DIRS = new Set(['node_modules', '.git', 'renv', '.venv', 'venv', 'dist', 'build', '__pycache__', '.Rproj.user', 'target'])
24const JS_DIRS = ['', 'frontend', 'client', 'web', 'ui', 'app']
25const MAX_FILES = 400
26
27const PLUMBER_TAG = /^[ \t]*#\*[ \t]*@(get|post|put|delete|patch|assets|plumber)\b/m
28const PLUMBER_CALL = /\bplumb(er)?\s*(::|\()|\bpr\s*\(/
29const SHINY_R = /\bshinyApp\b|\bshiny::/
30const SHINY_PY = /^[ \t]*(from[ \t]+shiny\b|import[ \t]+shiny\b)/m
31const STREAMLIT = /^[ \t]*(import[ \t]+streamlit\b|from[ \t]+streamlit\b)/m
32
33const baseName = (rel: string): string => rel.slice(rel.lastIndexOf('/') + 1)
34const dirName = (rel: string): string => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '')
35const extOf = (rel: string): string => {
36 const name = baseName(rel)
37 const dot = name.lastIndexOf('.')
38 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
39}
40
41async function safe<T>(work: () => Promise<T>, fallback: T): Promise<T> {
42 try {
43 return await work()
44 } catch {
45 return fallback
46 }
47}
48
49/** Files at the root and one folder down (vendored and hidden folders skipped), at most MAX_FILES, as 'dir/name'. */
50async function filesOf(p: Probe): Promise<string[]> {
51 const root = await safe(() => p.list(''), [])
52 const out = root.filter(e => e.kind === 'file').map(e => e.name)
53 const dirs = root.filter(e => e.kind === 'dir' && !SKIP_DIRS.has(e.name) && !e.name.startsWith('.')).map(e => e.name).sort()
54 for (const d of dirs) {
55 if (out.length >= MAX_FILES) break
56 const inner = await safe(() => p.list(d), [])
57 for (const e of inner) if (e.kind === 'file') out.push(`${d}/${e.name}`)
58 }
59 return out.slice(0, MAX_FILES)
60}
61
62/** Root files before deeper ones, the preferred names first in that order, then the rest by name. */
63function ranked(files: string[], preferred: string[]): string[] {
64 const score = (f: string) => {
65 const i = preferred.indexOf(baseName(f))
66 return (f.includes('/') ? 1000 : 0) + (i < 0 ? 100 : i)
67 }
68 return [...files].sort((a, b) => score(a) - score(b) || a.localeCompare(b))
69}
70
71export const isPlumberText = (rel: string, text: string): boolean =>
72 PLUMBER_TAG.test(text) || (baseName(rel) === 'entrypoint.R' && PLUMBER_CALL.test(text))
73
74/** The app in a project: plumber, then an npm dev script, Shiny for R, Shiny for Python, Streamlit. First match wins. */
75export async function detect(p: Probe): Promise<UiPreviewApp | undefined> {
76 const files = await filesOf(p)
77 const text = (f: string) => safe(() => p.read(f), '')
78
79 const rFiles = ranked(files.filter(f => extOf(f) === 'r'), ['entrypoint.R', 'plumber.R', 'api.R'])
80 for (const f of rFiles) if (isPlumberText(f, await text(f))) return appOf('plumber', f)
81
82 for (const d of JS_DIRS) {
83 const pkg = d === '' ? 'package.json' : `${d}/package.json`
84 if (files.includes(pkg) && hasDevScript(await text(pkg))) return appOf('npm', pkg)
85 }
86
87 for (const f of ranked(files.filter(f => baseName(f) === 'app.R'), [])) if (SHINY_R.test(await text(f))) return appOf('shiny-r', f)
88
89 const py = files.filter(f => extOf(f) === 'py')
90 for (const f of ranked(py.filter(f => baseName(f) === 'app.py'), [])) if (SHINY_PY.test(await text(f))) return appOf('shiny-py', f)
91 for (const f of ranked(py, ['streamlit_app.py', 'app.py', 'main.py', 'Home.py'])) if (STREAMLIT.test(await text(f))) return appOf('streamlit', f)
92 return undefined
93}
94
95/** What a file the person named is, from its name and text; undefined when it is none of the five. */
96export function classify(rel: string, text: string): UiPreviewApp | undefined {
97 if (baseName(rel) === 'package.json') return hasDevScript(text) ? appOf('npm', rel) : undefined
98 const ext = extOf(rel)
99 if (ext === 'r') {
100 if (isPlumberText(rel, text)) return appOf('plumber', rel)
101 if (SHINY_R.test(text)) return appOf('shiny-r', rel)
102 }
103 if (ext === 'py') {
104 if (SHINY_PY.test(text)) return appOf('shiny-py', rel)
105 if (STREAMLIT.test(text)) return appOf('streamlit', rel)
106 }
107 return undefined
108}
109
110export const appOf = (kind: UiPreviewKind, file: string): UiPreviewApp => ({ kind, file, dir: dirName(file), label: `${KIND_LABEL[kind]} · ${file}` })
111
112export function hasDevScript(packageJson: string): boolean {
113 try {
114 const dev = (JSON.parse(packageJson) as { scripts?: Record<string, unknown> }).scripts?.dev
115 return typeof dev === 'string' && dev.trim() !== ''
116 } catch {
117 return false
118 }
119}
120
121/** A path for R code inside single quotes: forward slashes, quotes and backslashes escaped. */
122export const rString = (path: string): string => path.replace(/\\/g, '/').replace(/'/g, "\\'")
123
124/**
125 * The program and arguments that start the app, run with no shell from the project root (npm: from its folder).
126 * `npm` is the program name to use for npm (`npm.cmd` on Windows).
127 */
128export function argvFor(app: UiPreviewApp, port: number, npm = 'npm'): { argv: string[]; cwd: string } {
129 const p = String(port)
130 switch (app.kind) {
131 case 'plumber': {
132 const plumb = baseName(app.file) === 'entrypoint.R' ? `plumber::plumb(dir = '${rString(app.dir || '.')}')` : `plumber::plumb('${rString(app.file)}')`
133 return { argv: ['Rscript', '-e', `pr <- ${plumb}; plumber::pr_run(pr, host = '127.0.0.1', port = ${p})`], cwd: '' }
134 }
135 case 'npm':
136 return { argv: [npm, 'run', 'dev', '--', '--host', '127.0.0.1', '--port', p], cwd: app.dir }
137 case 'shiny-r': {
138 const target = baseName(app.file) === 'app.R' ? app.dir || '.' : app.file
139 return { argv: ['Rscript', '-e', `shiny::runApp('${rString(target)}', host = '127.0.0.1', port = ${p}, launch.browser = FALSE)`], cwd: '' }
140 }
141 case 'shiny-py':
142 return { argv: ['shiny', 'run', '--host', '127.0.0.1', '--port', p, app.file], cwd: '' }
143 case 'streamlit':
144 return { argv: ['streamlit', 'run', app.file, '--server.headless', 'true', '--server.address', '127.0.0.1', '--server.port', p], cwd: '' }
145 }
146}
147
148// ---------------------------------------------------------------- the localhost-only rule
149
150const LOCAL = /^http:\/\/(127\.0\.0\.1|localhost):(\d{1,5})(\/[^\s#]*)?$/
151
152/** True only for http://127.0.0.1:N or http://localhost:N (any path); never https, another host, or no port. */
153export function isLocalUrl(url: string): boolean {
154 const m = LOCAL.exec(url)
155 if (m === null) return false
156 const port = Number(m[2])
157 return port >= 1 && port <= 65535
158}
159
160/** The `http://host:port` part of a local URL. */
161export const originOf = (url: string): string => {
162 const m = LOCAL.exec(url)
163 return m === null ? url : `http://${m[1]}:${m[2]}`
164}
165
166export const localUrl = (port: number): string => `http://127.0.0.1:${port}`
167
168const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007/g
169const URL_IN_TEXT = /http:\/\/(?:127\.0\.0\.1|localhost):\d{1,5}(?:\/[^\s'"<>)\]]*)?/
170
171/** The first local URL a line of output names, without a trailing slash; undefined when none. */
172export function urlIn(text: string): string | undefined {
173 const m = URL_IN_TEXT.exec(text.replace(ANSI, ''))
174 if (m === null) return undefined
175 const url = m[0].replace(/[.,;:]+$/, '').replace(/\/+$/, '')
176 return isLocalUrl(url) ? url : undefined
177}
178
179// ---------------------------------------------------------------- the child's output
180
181export const MAX_LINES = 60
182const MAX_LINE_CHARS = 300
183
184const KEYED = /\b(token|access[_-]?token|password|passwd|pwd|secret|client[_-]?secret|api[_-]?key|apikey|access[_-]?key|auth)(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)/gi
185const BEARER = /\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi
186const SHAPED = /\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g
187
188/** One output line as shown: colour codes gone, secret-looking values replaced by ***, cut to a few hundred characters. */
189export function scrub(line: string): string {
190 const clean = line
191 .replace(ANSI, '')
192 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
193 .replace(KEYED, (_m, k: string, sep: string) => `${k}${sep}***`)
194 .replace(BEARER, (_m, k: string) => `${k} ***`)
195 .replace(SHAPED, '***')
196 return clean.length > MAX_LINE_CHARS ? `${clean.slice(0, MAX_LINE_CHARS)}…` : clean
197}
198
199/**
200 * Adds a piece of output to the kept lines: complete lines are scrubbed and kept (the last MAX_LINES), the
201 * unfinished end waits in `partial` (bounded) for the next piece.
202 */
203export function addOutput(lines: readonly string[], partial: string, text: string): { lines: string[]; partial: string } {
204 const all = (partial + text).replace(/\r\n?/g, '\n').split('\n')
205 const rest = all.pop() ?? ''
206 const added = all.map(scrub).filter(l => l.trim() !== '')
207 return { lines: [...lines, ...added].slice(-MAX_LINES), partial: rest.slice(-4000) }
208}
209
210// ---------------------------------------------------------------- plumber's routes
211
212const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options']
213export const MAX_ROUTES = 200
214
215/** Method, path and summary of each operation in an OpenAPI document (plumber's /openapi.json), at most MAX_ROUTES. */
216export function parseRoutes(json: string): UiPreviewRoute[] {
217 let doc: unknown
218 try {
219 doc = JSON.parse(json)
220 } catch {
221 return []
222 }
223 const paths = (doc as { paths?: unknown } | null)?.paths
224 if (paths === null || typeof paths !== 'object') return []
225 const out: UiPreviewRoute[] = []
226 for (const [path, ops] of Object.entries(paths as Record<string, unknown>)) {
227 if (ops === null || typeof ops !== 'object') continue
228 for (const method of METHODS) {
229 const op = (ops as Record<string, unknown>)[method]
230 if (op === undefined || op === null || typeof op !== 'object') continue
231 const raw = (op as { summary?: unknown; description?: unknown }).summary ?? (op as { description?: unknown }).description
232 const summary = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim().slice(0, 100) : ''
233 out.push({ method: method.toUpperCase(), path: path.slice(0, 200), summary })
234 if (out.length >= MAX_ROUTES) return out
235 }
236 }
237 return out
238}
239
240// ---------------------------------------------------------------- commands
241
242export type Parsed =
243 | { kind: 'open' | 'stop' | 'shot' | 'status' }
244 | { kind: 'start'; file: string; port: number | undefined; error: string }
245 | { kind: 'unknown'; word: string }
246
247/** `/ui-preview [start [file] [--port N] | stop | shot | status]`. */
248export function parseArgs(args: string): Parsed {
249 const words = args.trim().split(/\s+/).filter(w => w !== '')
250 const head = (words[0] ?? '').toLowerCase()
251 if (head === '') return { kind: 'open' }
252 if (head === 'stop' || head === 'shot' || head === 'status') return { kind: head }
253 if (head !== 'start') return { kind: 'unknown', word: head.slice(0, 30) }
254 let file = ''
255 let port: number | undefined
256 let error = ''
257 for (let i = 1; i < words.length; i++) {
258 const w = words[i]!
259 const eq = /^--port=(.*)$/.exec(w)
260 const value = eq !== null ? eq[1] : w === '--port' ? words[++i] : undefined
261 if (eq !== null || w === '--port') {
262 const n = Number(value)
263 if (!Number.isInteger(n) || n < 1024 || n > 65535) error = 'the port must be a whole number from 1024 to 65535'
264 else port = n
265 } else if (file === '') file = w
266 else error = `one file only (got "${w.slice(0, 40)}")`
267 }
268 return { kind: 'start', file, port, error }
269}
270
271/** A path the person typed, relative to the root with forward slashes; undefined when it leaves the root. */
272export function relPathOf(root: string, typed: string): string | undefined {
273 let p = typed.replace(/\\/g, '/')
274 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
275 if (p.startsWith(`${r}/`)) p = p.slice(r.length + 1)
276 else if (/^([A-Za-z]:)?\//.test(p)) return undefined
277 p = p.replace(/^(\.\/)+/, '').replace(/\/+$/, '')
278 if (p === '' || p.split('/').some(part => part === '..')) return undefined
279 return p
280}
281
282// ---------------------------------------------------------------- the browser for screenshots
283
284export type BrowserEnv = { localAppData?: string; programFiles?: string; programFilesX86?: string }
285
286/**
287 * Where a Chrome, Chromium or Edge may be, in the order tried: absolute paths (checked to exist) and names on PATH
288 * (probed with --version). The person's real browser is never opened: the screenshot runs headless with a profile
289 * of its own.
290 */
291export function browserCandidates(env: BrowserEnv): { path: string; isName: boolean }[] {
292 const pf = env.programFiles ?? 'C:\\Program Files'
293 const pf86 = env.programFilesX86 ?? 'C:\\Program Files (x86)'
294 const abs = (path: string) => ({ path, isName: false })
295 const name = (path: string) => ({ path, isName: true })
296 return [
297 abs('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
298 abs('/Applications/Chromium.app/Contents/MacOS/Chromium'),
299 name('google-chrome'),
300 name('chromium'),
301 name('chromium-browser'),
302 abs(`${pf}\\Google\\Chrome\\Application\\chrome.exe`),
303 abs(`${pf86}\\Google\\Chrome\\Application\\chrome.exe`),
304 ...(env.localAppData ? [abs(`${env.localAppData}\\Google\\Chrome\\Application\\chrome.exe`)] : []),
305 abs(`${pf86}\\Microsoft\\Edge\\Application\\msedge.exe`),
306 abs(`${pf}\\Microsoft\\Edge\\Application\\msedge.exe`),
307 ]
308}
309
310export function shotArgv(browser: string, png: string, profile: string, url: string): string[] {
311 return [
312 browser,
313 '--headless=new',
314 '--disable-gpu',
315 '--hide-scrollbars',
316 '--window-size=1280,800',
317 `--user-data-dir=${profile}`,
318 '--no-first-run',
319 '--no-default-browser-check',
320 `--screenshot=${png}`,
321 url,
322 ]
323}
324
325export function reviewText(url: string, png: string | undefined): string {
326 const shot = png === undefined ? 'No screenshot yet.' : `Screenshot: ${png}.`
327 return `Review the running app at ${url}. ${shot} Check layout, spacing, contrast and broken requests; if the chrome-devtools MCP is connected, open the page with it and read the console and network errors.`
328}
329types/index.d.ts 30 lines1/** Which kind of app ui-preview runs. */
2export type UiPreviewKind = 'plumber' | 'npm' | 'shiny-r' | 'shiny-py' | 'streamlit'
3/** The app found (or named): its kind, the file, its folder (relative to the project root, '' for the root), a label. */
4export type UiPreviewApp = { kind: UiPreviewKind; file: string; dir: string; label: string }
5/** stopped: no child; starting: started, URL not answering yet; running: it answers; silent: no answer within 60 s; exited: the child ended. */
6export type UiPreviewStatus = 'stopped' | 'starting' | 'running' | 'silent' | 'exited'
7/** One plumber route from /openapi.json. */
8export type UiPreviewRoute = { method: string; path: string; summary: string }
9/** The last screenshot: the PNG's absolute path and a number that changes with its content. */
10export type UiPreviewShot = { path: string; generation: number }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'ui-preview': {
15 /** Where the app stands, for the pane and the moddeck side bar. */
16 status: UiPreviewStatus
17 /** The app's local URL ('' before a start). */
18 url: string
19 app: UiPreviewApp | null
20 /** The child's last 60 output lines, secret-looking values replaced by ***. */
21 lines: string[]
22 /** Plumber routes (method, path, summary), at most 200. */
23 routes: UiPreviewRoute[]
24 shot: UiPreviewShot | null
25 /** The last thing said (a failure, where a draft went). */
26 note: string
27 }
28 }
29}
30