/browse <url or file> opens a pane beside the chat showing a screenshot (where the terminal supports images) and the page text, so you can review pages without…

Nine mods for Claude Code (2.1.287 or newer), written from the walkthrough video. Each lives in mods/<name>.
Install one, or all, from a terminal session:
/plugin install <name> --marketplace KianaVega/KianaVega
| Mod | What it does | Extra model usage |
|---|---|---|
| savvy-progress | Progress bar above the prompt and a live list of subagents with model, tokens and rough cost | none |
| tool-skins | Icons and colors for tool calls in seven themes, switch with /skin <name> | none |
| you-should-know | A second, small model reads long answers and flags anything important you might miss | one small call per long answer |
| file-tree | Pane showing files Claude reads and edits, green once committed (/file-tree) | none |
| cache-tax | Holds a prompt that would reload a cold cache and shows an estimate. /keepwarm pings before the cache expires | none, except keep-warm pings |
| blast-radius | Stops recursive deletes, git clean, hard resets and shows the affected files before you press Proceed | none |
| reflect | Offers to save corrections as a rule in CLAUDE.md | one small call when you save |
| terminal-browser | /browse <url or file> opens a pane with a screenshot (kitty or Ghostty) and the page text | none |
| replay-theater | Replay button after a turn that steps through each edit as a diff (/replay) | none |
hooks/register.tsx. Edit and reload.hooks/register.tsx 131 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Page } from '../types'
5
6const PANE = 'browser'
7const SHOT_DIR = '/tmp/terminal-browser'
8const BROWSERS = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']
9const MAX_TEXT = 6000
10
11const page = atom({ plugin: 'terminal-browser', key: 'page' } as const, null as Page | null)
12
13const toText = (html: string): { title: string; text: string } => {
14 const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? ''
15 const text = html
16 .replace(/<(script|style|noscript)[\s\S]*?<\/\1>/gi, '')
17 .replace(/<(h[1-6])[^>]*>([\s\S]*?)<\/\1>/gi, (_m, tag: string, inner: string) => `\n${'#'.repeat(Number(tag[1]))} ${inner}\n`)
18 .replace(/<li[^>]*>/gi, '\n- ')
19 .replace(/<(br|\/p|\/div|\/tr)[^>]*>/gi, '\n')
20 .replace(/<[^>]+>/g, '')
21 .replace(/ /g, ' ')
22 .replace(/&/g, '&')
23 .replace(/</g, '<')
24 .replace(/>/g, '>')
25 .replace(/\n{3,}/g, '\n\n')
26 .trim()
27
28 return { title, text: text.slice(0, MAX_TEXT) }
29}
30
31export const register: Register = on => {
32 let lastHtml: string | undefined
33
34 on('session.start', async ($, e, next) => {
35 await $.command.register({
36 name: 'browse',
37 description: 'Open a URL or local HTML file in a pane beside the chat',
38 argumentHint: '<url | file>',
39 })
40
41 return next(e)
42 })
43
44 // Remember the last HTML file Claude wrote so a bare /browse can open it.
45 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
46 if (e.file_path.endsWith('.html')) lastHtml = e.file_path
47
48 return next(e)
49 })
50
51 on('command.run', { command: 'browse' }, async ($, e) => {
52 const target = e.args.trim() || lastHtml
53 if (target === undefined || target === '') {
54 return { text: 'Usage: /browse <url or html file>. With no argument it opens the last HTML file Claude wrote.' }
55 }
56 const isUrl = /^https?:\/\//i.test(target)
57 const cwd = await $.session.cwd()
58 const abs = target.startsWith('/') ? target : `${cwd}/${target}`
59 const url = isUrl ? target : `file://${abs}`
60
61 let body = ''
62 let note = ''
63 try {
64 body = isUrl ? (await $.http.fetch(target)).text : String(await $.fs.read(abs))
65 } catch {
66 note = 'Could not load the page text.'
67 }
68 const { title, text } = toText(body)
69
70 let shot: string | null = null
71 try {
72 const found = await $.process.run(['sh', '-c', `command -v ${BROWSERS.join(' ')} | head -1`])
73 const browser = found.stdout.trim()
74 if (browser !== '') {
75 const out = `${SHOT_DIR}/shot-${await $.clock.now()}.png`
76 await $.fs.write(`${SHOT_DIR}/.keep`, '')
77 const ran = await $.process.run(
78 [browser, '--headless', '--no-sandbox', '--disable-gpu', `--screenshot=${out}`, '--window-size=1280,800', url],
79 { timeoutMs: 30000 },
80 )
81 if (ran.exitCode === 0) shot = out
82 }
83 } catch {
84 note = `${note} No screenshot (no headless browser available).`.trim()
85 }
86
87 await update($, page, () => ({ url, title, text, shot, note }))
88 await $.ui.open({ id: PANE, title: 'Browser' })
89
90 return { text: `Opened ${url} in the Browser pane.` }
91 })
92
93 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94 const current = await read($, page)
95
96 if (current === null) {
97 const { Text } = $.ui.resolve(e)
98
99 return <Text dimColor>Nothing open. Try /browse https://example.com</Text>
100 }
101
102 // Only the terminal surface draws pictures; others get the text alone.
103 const picture =
104 e.surface === 'terminal' && current.shot !== null
105 ? (() => {
106 const { Image } = $.ui.resolve(e)
107
108 return (
109 <Image
110 source={{ file: current.shot, format: 'png' }}
111 columns={Math.max(20, e.props.bodyColumns)}
112 rows={14}
113 alt="Page screenshot"
114 />
115 )
116 })()
117 : null
118 const { Box, Text, Markdown } = $.ui.resolve(e)
119
120 return (
121 <Box flexDirection="column">
122 <Text bold>{current.title || current.url}</Text>
123 <Text dimColor>{current.url}</Text>
124 {current.note !== '' && <Text color="warning">{current.note}</Text>}
125 {picture}
126 <Markdown text={current.text} />
127 </Box>
128 )
129 })
130}
131types/index.d.ts 8 lines1export type Page = { url: string; title: string; text: string; shot: string | null; note: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'terminal-browser': { page: Page | null }
6 }
7}
8