SLOPSHOPPER

browser-pane-images

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…

panebandcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · browser-pane-images
│ ┃ browser-pane-images ✕ › fix the failing auth test and add an audit log call │ ┃ [ 🧹 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Images in this session ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No images yet. ⎿ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · browser-pane-images
[ 🧹 ] Images in this session No images yet.
README

browser-pane

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.

The shared window's Guide tab, listing the tabs mods added

  • One window per session. Showing a tab while the window is open switches that window; a second one is never opened.
  • Tabs are static pages, plus an optional Node module. The shared server serves a tab's files and sends the tab's api/ requests to its module.
  • Locked to you. The server listens on 127.0.0.1 only, checks the Host header, and needs a per-session token: a header for the mod, a cookie for the window.

Install

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.

Using it from a mod

examples/images/ is a complete mod that uses it; this section walks through the same steps.

1. Depend on it

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.

2. Register a tab

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)
})

3. Show it

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.

4. The tab's page

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.

5. The tab's server module (optional)

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.

Examples

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

ModTabWhat it does
browser-pane-template (examples/template)TemplateA 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)ImagesBrowse 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, CockpitThree 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

Write your own

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.

How it works

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
  • The server starts on the first show, and stops a minute after the session's heartbeat stops.
  • The window polls the server once a second. When a 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.
  • When the session ends, the window stays and says so; close it yourself.

Developing

./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/.

License

MIT

Source 4 files
hooks/register.tsx 259 lines
1import { 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}
259
hooks/os.ts 34 lines
1// 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))
34
hooks/paths.ts 21 lines
1export 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}
21
types/index.d.ts 14 lines
1/** 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