Browse the images you paste into the prompt: chips above the prompt and an Images tab in the browser-pane window that previews them and opens them in your OS…

Claude Code mods are no longer limited to what a terminal can draw. browser-pane gives every mod a tab in one shared browser window, and with it the whole web platform: 3D with Three.js, CAD with JSCAD, rich editors, live charts, Web Audio and MIDI, speech in and out, WebRTC, and whatever else a web page can do. The terminal stays where the conversation happens; the browser is where a mod shows and works with what the conversation is about.
A mod adds its tab through $.browserPane, a Guide tab lists them all, and browser-pane runs the one local server and window behind them, securely. The integration guide explains the idea and walks you through building your own.

api/ requests to its module.At the prompt of a terminal session:
/plugin install browser-pane --marketplace sandipchitale/browser-pane
From a shell: claude plugin install browser-pane --marketplace sandipchitale/browser-pane. Answer y to add the marketplace, then choose a scope.
You usually don't install it yourself: a mod that uses it lists it as a dependency, and installing that mod installs browser-pane too. To try it, install one of the examples:
/plugin install browser-pane-template --marketplace sandipchitale/browser-pane
Commands: /browser-pane opens the window on the Guide tab, /browser-pane <tab> on a tab.
It needs Node.js on your PATH and Google Chrome, which opens the window as an app window in a profile of its own. Without Chrome it opens your default browser.
examples/images/ is a complete mod that uses it; this section walks through the same steps.
In your mod's .claude-plugin/plugin.json:
"dependencies": ["browser-pane"]
Claude Code then loads your mod only when browser-pane is installed, and lays browser-pane's types into your mod's .claude-plugin/types/, so $.browserPane is typed.
A bare name is looked up in your own marketplace, so list browser-pane there too. Then one install line installs both:
{
"name": "browser-pane",
"source": { "source": "github", "repo": "sandipchitale/browser-pane" }
}
Two mods that each list it share one copy: the second is skipped, as it has the same name.
From session.start. This is cheap: nothing starts until the first show.
on('session.start', async ($, e, next) => {
await $.browserPane.register({
id: 'images', // lowercase letters, digits, dashes
title: 'Images',
root: `${$.plugin.root}/tab`, // your static files: index.html at least
server: `${$.plugin.root}/tab-server/server.mjs`, // optional
description: 'One line for the Guide tab.',
pinned: true, // optional: first in the Guide and the tab strip
})
return next(e)
})
const r = await $.browserPane.show({ tab: 'images', select: '2', data: { any: 'json' } })
if (!r.isOpened) {
// r.reason says why: no $.process here (desktop app, IDE), no Node, ...
// Show the content your own way, say in a Pane.
}
select reaches the tab's page (see onSelect below).data reaches the tab's server module (update) before the tab shows.r.how is 'opened' or 'reused'.$.browserPane.update({ tab, data }) hands new data to the server module without bringing the window forward. The latest data is kept, and sent again if the server restarts.
Your root is served at /tab/<id>/. Load the client in <head>, so the window's theme applies before the page paints:
<head>
<script src="/bp/client.js"></script>
</head>
<script>
browserPane.onSelect(select => { /* show it */ }) // the one it opened with first
browserPane.onRefresh(() => { /* reload your data */ }) // the window's Refresh button; without one, the page reloads
browserPane.api('info').then(r => r.json()).then(info => { /* ... */ }) // /tab/<id>/api/info
</script>
The window draws the controls every tab would otherwise repeat, as icons at the right of the tab strip: Refresh (the tab showing) and Theme (one button each for System, Light and Dark, remembered). A tab draws neither. It styles itself from <html data-theme="light|dark">, which the client keeps in step, and can read browserPane.theme or listen with browserPane.onTheme(theme => ...).
Dotfiles, and links that lead outside root, are never served.
A Node ES module whose default export makes the tab's handlers. It runs inside browser-pane's server, with full Node:
export default function create({ id, root }) {
let data = {}
return {
update(next) { data = next }, // from show({ data }) and update()
handle(req, res, sub) { // requests under /tab/<id>/api/; sub = 'info'
if (sub === 'info') { res.end(JSON.stringify(data)); return true }
return false // 404
},
}
}
If the module fails to load or throws, only that tab is affected: its requests answer 404 or 500, and the Guide tab shows the error. A changed module is imported afresh the next time the tab registers.
Run commands as argv arrays (spawn(cmd, args)), never through a shell, and serve only files you chose, never a path the page sent.
Each example is an independent mod: its own folder, its own plugin.json with browser-pane as a dependency, and its own .claude-plugin/marketplace.json. This repository's marketplace lists them too, so each installs from GitHub in one line, browser-pane with it. Each folder is also shaped like a repository of its own, the way a mod you write against browser-pane would be: its own marketplace lists the mod (./) and browser-pane (from GitHub).
| Mod | Tab | What it does | ||
|---|---|---|---|---|
browser-pane-template (examples/template) | Template | A minimal starter: a page, a server module, and the hooks between them. Copy it to start your own mod; its integration.md explains the integration with browser-pane, and its CLAUDE.md points Claude Code there. /template [text]. | ||
browser-pane-images (examples/images) | Images | Browse the images pasted or dropped into the prompt: 🖼 Image #N chips above the prompt, a master/detail tab with preview, open in the OS viewer, show in Finder, copy path. `/images [n \ | pane]`. | |
browser-pane-editors (examples/editors) | Plugins, MCP, Cockpit | Three tabs from one server module. Plugins: install, enable, update plugins and manage marketplaces. MCP: servers by scope with live health, add, edit, enable, disable. Cockpit: this session live (tokens and cost, subagents, search), with meters for context fill and the 5-hour, weekly and spend limits (how far through each window, when it resets). `/editors [plugins \ | mcp \ | cockpit]`. |
Install one, inside Claude Code:
/plugin install browser-pane-images --marketplace sandipchitale/browser-pane
The integration guide explains why browser-pane is shaped the way it is, and walks you step by step from a copy of the template to a published mod, with Claude Code.
In short: copy examples/template to a folder of your own, open Claude Code there, and ask it to build what you want. Its integration.md covers the dependency and marketplace, the $.browserPane calls, the page's client, the server module, the safety rules, testing and the development loop, and lists what to rename.
your mod ── $.browserPane.show ──▶ browser-pane's hook ── POST /control/* (token header) ──▶ server.mjs
│
Chrome app window ◀── /?t=token → cookie bp_<port> ── shell: tab strip + Guide ─┘
/tab/<id>/… your static files
/tab/<id>/api/… your server module
show, and stops a minute after the session's heartbeat stops.show comes while it polls, the open window switches tabs. This matters because Chrome hands a second launch with the same profile to the running process, which would open a new window../test.sh # everything: the servers (node --test), then each mod's hooks (claude plugin test)
claude plugin validate . # the manifest, the contract and the hooks module
claude plugin test <dir> runs every *.test.ts under <dir>, so each mod is tested from its own folder: test.sh tests browser-pane from a copy without examples/.
MIT
hooks/register.tsx 259 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ImagesFile } from '../types'
5import { openFile, revealFile } from './os'
6import { droppedImagePath, tokens } from './paths'
7
8const draft = atom({ plugin: 'browser-pane-images', key: 'draft' } as const, '')
9const files = atom({ plugin: 'browser-pane-images', key: 'files' } as const, {} as Record<string, ImagesFile>)
10const origins = atom({ plugin: 'browser-pane-images', key: 'origins' } as const, {} as Record<string, string>)
11// Images the user cleared from the pane, so a rescan doesn't bring them back.
12const cleared = atom({ plugin: 'browser-pane-images', key: 'cleared' } as const, {} as Record<string, boolean>)
13
14const TAB = 'images'
15const PANE = 'browser-pane-images'
16
17// Dropped or pasted file paths not yet matched to an [Image #N] chip.
18let pending: string[] = []
19
20// Icon labels of one width, so the buttons match.
21const PAD = (icon: string) => ` ${icon} `
22
23// What the tab's server module needs: the session's folder, and where dropped files came from.
24async function tabData($: any) {
25 return { session: await $.session.id(), origins: await read($, origins) }
26}
27
28async function scan($: any) {
29 const sid = await $.session.id()
30 const r = await $.process.run(['find', '/private/tmp', '/tmp', '-maxdepth', '5', '-path', `*/${sid}/images/*`])
31 const gone = await read($, cleared)
32 const found: Record<string, ImagesFile> = {}
33 for (const line of r.stdout.split('\n')) {
34 const m = /\/images\/(\d+)\.[A-Za-z]+$/.exec(line.trim())
35 if (m && !gone[m[1]]) found[m[1]] = { path: line.trim() }
36 }
37 await update($, files, cur => ({ ...cur, ...found }))
38}
39
40async function locate($: any, n: number) {
41 const sid = await $.session.id()
42 const r = await $.process.run(['find', '/private/tmp', '/tmp', '-maxdepth', '5', '-path', `*/${sid}/images/${n}.*`])
43 const path = r.stdout.split('\n').find((l: string) => l.trim())
44 if (path) {
45 await update($, files, m => ({ ...m, [n]: { path } }))
46 }
47}
48
49async function clearAll($: any) {
50 const ids = Object.keys(await read($, files))
51 await update($, cleared, m => ({ ...m, ...Object.fromEntries(ids.map(id => [id, true])) }))
52 await update($, files, () => ({}))
53}
54
55async function openPane($: any) {
56 await scan($)
57 await $.ui.open({ id: PANE, title: 'Images', focus: true })
58}
59
60// A chip shows its image in the Images tab, or in the OS viewer when the window can't open.
61async function showInWindow($: any, n: number) {
62 const r = await $.browserPane.show({ tab: TAB, select: String(n), data: await tabData($) })
63 if (r.isOpened) {
64 const where = r.how === 'reused' ? 'the open Images tab. It may be behind other windows' : 'the Images tab'
65 await $.ui.toast(`Image #${n} is shown in ${where}.`)
66 return
67 }
68 await $.ui.toast(`The browser window can't open (${r.reason}). Opening Image #${n} in your OS viewer.`)
69 await show($, n)
70}
71
72async function show($: any, n: number) {
73 const run = (argv: string[]) => $.process.run(argv)
74 const origin = (await read($, origins))[n]
75 // The dropped file may have been moved since, so the saved copy is the fallback.
76 if (origin && (await openFile(run, origin))) return true
77 let have = (await read($, files))[n]
78 if (!have) {
79 await locate($, n)
80 have = (await read($, files))[n]
81 }
82 if (have && (await openFile(run, have.path))) return true
83 await $.ui.toast(`Image #${n} isn't saved yet or can't be found`)
84 return false
85}
86
87export const register: Register = on => {
88 on('session.start', async ($, e, next) => {
89 await $.browserPane.register({
90 id: TAB,
91 title: 'Images',
92 root: `${$.plugin.root}/tab`,
93 server: `${$.plugin.root}/tab-server/server.mjs`,
94 description: "This session's pasted and dropped images: preview them, open them in your OS viewer, show them in Finder.",
95 })
96 // Opened from the Guide tab, the Images tab still needs to know the session.
97 await $.browserPane.update({ tab: TAB, data: await tabData($) })
98 await $.command.register({
99 name: 'images',
100 description: "Browse this session's images in the browser-pane window: /images [n | pane]",
101 })
102 return next(e)
103 })
104
105 on('command.run', { command: 'images' }, async ($, e) => {
106 const arg = e.args.trim()
107 if (arg === 'pane') {
108 await openPane($)
109 return { text: 'Images pane opened.' }
110 }
111 const n = Number(arg)
112 if (n) {
113 const ok = await show($, n)
114 return { text: ok ? `Opened Image #${n}.` : `Image #${n} not found.` }
115 }
116 const r = await $.browserPane.show({ tab: TAB, data: await tabData($) })
117 if (r.isOpened) return { text: r.how === 'reused' ? 'images: switched the open window to the Images tab.' : 'images: window opened.' }
118 await openPane($)
119 return { text: `images: the browser window can't open (${r.reason}), so the Images pane opened instead.` }
120 })
121
122 on('prompt.edit', async ($, e, next) => {
123 const result: any = await next(e)
124 const after: string = result?.text ?? e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
125 await update($, draft, () => after)
126 if (after.trim() === '') pending = []
127 const dropped = droppedImagePath(e.inputText)
128 if (dropped) pending.push(dropped)
129 const before = new Set(tokens(e.text))
130 let learned = false
131 for (const n of tokens(after)) {
132 if (before.has(n)) continue
133 const origin = pending.shift()
134 if (origin) {
135 await update($, origins, m => ({ ...m, [n]: origin }))
136 learned = true
137 }
138 }
139 // The tab lists a dropped file from where it came from, so it hears of each new one.
140 if (learned) await $.browserPane.update({ tab: TAB, data: await tabData($) })
141 return result
142 })
143
144 on('prompt.submit', async ($, e, next) => {
145 await update($, draft, () => '')
146 pending = []
147 const sent = await next(e)
148 await scan($).catch(() => {})
149 return sent
150 })
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 const { Box, Button } = $.ui.resolve(e)
154 // Other plugins' rows (and the engine's) stay, below this one.
155 const [text, beneath] = await Promise.all([read($, draft), next(e)])
156 const ns = [...new Set(tokens(text))]
157 if (ns.length === 0) return beneath
158 return (
159 <Box flexDirection="column">
160 <Box flexDirection="row" gap={1}>
161 {ns.map(n => (
162 <Button key={`img-${n}`} plain onPress={() => showInWindow($, n)}>
163 {`🖼 Image #${n}`}
164 </Button>
165 ))}
166 </Box>
167 {beneath}
168 </Box>
169 )
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Text, Button } = $.ui.resolve(e)
174 const known = await read($, files)
175 const origin = await read($, origins)
176 const shown = (n: number) => origin[n] ?? known[n].path
177 const ids = Object.keys(known).map(Number).sort((a, b) => a - b)
178 // Tooltips are the last children of the root, so nothing paints over them:
179 // an absolute Box paints over siblings before it only. Each joins its
180 // button's hover scope, and sits at a position the fixed layout gives.
181 const ROW0 = 4
182 const ROWH = 3
183 const COLS = [2, 14, 26]
184 const tip = (key: string, top: number, left: number, text: string) => (
185 <Box
186 key={`card-${key}`}
187 position="absolute"
188 top={top}
189 left={left}
190 display="none"
191 hover={{ display: 'flex', scope: key }}
192 borderStyle="round"
193 backgroundColor="inverseText"
194 paddingX={1}
195 >
196 <Text color="text">{text}</Text>
197 </Box>
198 )
199 return (
200 <Box flexDirection="column" paddingX={1} paddingBottom={4}>
201 <Box key="clear-box" hover={{ scope: 'tip-clear' }} alignSelf="flex-start">
202 <Button key="clear" onPress={() => clearAll($)}>
203 {PAD('🧹')}
204 </Button>
205 </Box>
206 <Text> </Text>
207 <Text bold>Images in this session</Text>
208 <Text> </Text>
209 {ids.length === 0 && <Text dimColor>No images yet.</Text>}
210 {ids.map(n => (
211 <Box
212 key={`row-${n}`}
213 flexDirection="row"
214 alignItems="center"
215 gap={2}
216 paddingX={1}
217 paddingY={1}
218 hover={{ backgroundColor: 'subtle' }}
219 >
220 <Box key={`reveal-box-${n}`} hover={{ scope: `tip-reveal-${n}` }}>
221 <Button key={`reveal-${n}`} onPress={() => revealFile((argv: string[]) => $.process.run(argv), shown(n))}>
222 {PAD('📂')}
223 </Button>
224 </Box>
225 <Box key={`copy-box-${n}`} hover={{ scope: `tip-copy-${n}` }}>
226 <Button key={`copy-${n}`} onPress={(press: any) => $.ui.copy({ text: shown(n), surface: press.surface })}>
227 {PAD('📋')}
228 </Button>
229 </Box>
230 <Box key={`view-box-${n}`} hover={{ scope: `tip-view-${n}` }}>
231 <Button key={`view-${n}`} onPress={() => show($, n)}>
232 {PAD('👀')}
233 </Button>
234 </Box>
235 <Button key={`open-${n}`} plain onPress={() => show($, n)}>
236 {`[Image #${n}]`}
237 </Button>
238 </Box>
239 ))}
240 {tip('tip-clear', 1, 1, 'Clear list')}
241 {ids.map((n, i) => {
242 const rowTop = ROW0 + i * ROWH
243 // Below the row, unless the card would run past the bottom edge of the
244 // window as scrolled: then above the button (the card is 3 rows tall,
245 // the button on the row's middle line).
246 const offset = e.props?.scroll?.offset ?? 0
247 const room = e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 24
248 const below = rowTop + ROWH + 3 - offset > room ? rowTop - 2 : rowTop + ROWH
249 return [
250 tip(`tip-reveal-${n}`, below, COLS[0], 'Show in Finder'),
251 tip(`tip-copy-${n}`, below, COLS[1], 'Copy path'),
252 tip(`tip-view-${n}`, below, COLS[2], 'View in OS preview'),
253 ]
254 })}
255 </Box>
256 )
257 })
258}
259hooks/os.ts 34 lines1// Opening and revealing a file differs per OS and the mod can't read the OS,
2// so each command is tried in turn and the first that works is used.
3type Run = (argv: string[]) => Promise<{ exitCode: number }>
4
5const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, '') || '/'
6
7async function firstOk(run: Run, attempts: { argv: string[]; trustExit?: boolean }[]) {
8 for (const a of attempts) {
9 try {
10 const r = await run(a.argv)
11 if (r.exitCode === 0 || a.trustExit) return true
12 } catch {
13 // command not installed: try the next one
14 }
15 }
16 return false
17}
18
19export const openArgv = (p: string) => [
20 { argv: ['open', p] },
21 { argv: ['xdg-open', p] },
22 { argv: ['cmd', '/c', 'start', '', p] },
23]
24
25// explorer.exe exits 1 even when it worked, so its attempt is trusted.
26export const revealArgv = (p: string) => [
27 { argv: ['open', '-R', p] },
28 { argv: ['explorer', `/select,${p}`], trustExit: true },
29 { argv: ['xdg-open', dirname(p)] },
30]
31
32export const openFile = (run: Run, p: string) => firstOk(run, openArgv(p))
33export const revealFile = (run: Run, p: string) => firstOk(run, revealArgv(p))
34hooks/paths.ts 21 lines1export const tokens = (text: string) =>
2 [...text.matchAll(/\[Image #?(\d+)\]/g)].map(m => Number(m[1]))
3
4const IMAGE_EXT = /\.(png|jpe?g|gif|webp|heic|tiff?|bmp)$/i
5
6// The absolute path of a dropped or pasted image file (plain, quoted, escaped
7// or file://), or null for anything else, including a URL that won't decode.
8export function droppedImagePath(input: string): string | null {
9 let raw = input.trim().replace(/^(['"])(.*)\1$/, '$2')
10 if (raw.startsWith('file://')) {
11 try {
12 raw = decodeURIComponent(raw.slice('file://'.length))
13 } catch {
14 return null
15 }
16 } else {
17 raw = raw.replace(/\\(.)/g, '$1')
18 }
19 return raw.startsWith('/') && raw.length > 1 && IMAGE_EXT.test(raw) ? raw : null
20}
21types/index.d.ts 14 lines1/** An image the mod found for an [Image #N] tag. */
2export type ImagesFile = { path: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'browser-pane-images': {
7 draft: string
8 files: Record<string, ImagesFile>
9 origins: Record<string, string>
10 cleared: Record<string, boolean>
11 }
12 }
13}
14