A text-mode web browser in a pane beside the chat: /browse a URL or PR, follow links, and let Claude open pages for review.

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.
| Mod | What it does | Commands |
|---|---|---|
| savvy-progress | Progress bar above the prompt; an Agents side panel with a pixel crab per subagent showing model, effort, task progress, context, tokens and estimated cost | /savvy, /crab-style <keyword> <color> <hat> |
| tool-skins | Themed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine) | /skin <theme> |
| you-should-know | After a long reply, a Haiku side agent flags costs, risks and actions you might miss | /ysk on, /ysk off, /ysk focus <text> |
| file-tree | A panel showing which files Claude is reading or editing; turns green once committed | /files |
| cache-tax | Warns before a prompt reloads a cold prompt cache, with a token and cost estimate | /keepwarm, /cachetax ttl <minutes> |
| blast-radius | Stops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancel | automatic |
| reflect | Spots corrections in your prompts and offers to save them as rules in CLAUDE.md | /reflect <rule> |
| terminal-browser | A text-mode browser pane; Claude can open pages there too | /browse <url or path> |
Cost figures are estimates at API list prices. you-should-know and reflect make small Haiku calls, which count toward your usage.
At a Claude Code prompt in a terminal:
/plugin install savvy-progress --marketplace Isaacg1000/isaac-mods
Answer y to add the marketplace, then pick the user scope. Repeat with each mod name you want.
Or from a shell, all at once:
claude plugin marketplace add Isaacg1000/isaac-mods
claude plugin install savvy-progress@isaac-mods --scope user
claude plugin install tool-skins@isaac-mods --scope user
claude plugin install you-should-know@isaac-mods --scope user
claude plugin install file-tree@isaac-mods --scope user
claude plugin install cache-tax@isaac-mods --scope user
claude plugin install blast-radius@isaac-mods --scope user
claude plugin install reflect@isaac-mods --scope user
claude plugin install terminal-browser@isaac-mods --scope user
Each mod lives in plugins/<name>/: hooks/register.tsx is the hooks module, types/index.d.ts its state contract. Check and test one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>
When this folder is added as a local marketplace (claude plugin marketplace add <this folder>), edits are picked up with /reload-plugins.
hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Page } from '../types'
5import { titleOf, toMarkdown } from './html.ts'
6
7const PANE = 'browser'
8const page = atom({ plugin: 'terminal-browser', key: 'page' } as const, null as Page | null)
9const history = atom({ plugin: 'terminal-browser', key: 'history' } as const, [] as string[])
10const pageIndex = atom({ plugin: 'terminal-browser', key: 'pageIndex' } as const, 0)
11
12const isLocalPath = (s: string) => /^([a-zA-Z]:[\\/]|\/|\.{1,2}[\\/]|~[\\/])/.test(s) || /\.(html?|md|txt)$/i.test(s)
13
14function normalize(input: string): string {
15 const s = input.trim()
16 if (/^(https?|file):\/\//i.test(s)) return s
17 if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/i.test(s)) return `http://${s}`
18 if (isLocalPath(s)) return s
19 return `https://${s}`
20}
21
22async function load($: EngineInterface, target: string): Promise<Page> {
23 const url = normalize(target)
24 try {
25 if (/^file:\/\//i.test(url) || isLocalPath(url)) {
26 let path = url.replace(/^file:\/\/\/?/i, '')
27 if (/^[a-zA-Z]:/.test(path) === false && !path.startsWith('/')) path = `${await $.session.cwd()}/${path}`
28 const text = (await $.fs.read(decodeURIComponent(path))) as string
29 const isHtml = /\.html?$/i.test(path) || /^\s*<(!doctype|html)/i.test(text)
30 return {
31 url: path.replace(/\\/g, '/'),
32 title: isHtml ? titleOf(text) || path : path,
33 markdown: isHtml ? toMarkdown(text, `file:///${path.replace(/\\/g, '/')}`) : text,
34 status: 200,
35 error: null,
36 isLoading: false,
37 }
38 }
39 const res = await $.http.fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Claude Code terminal-browser)', Accept: 'text/html,text/plain,*/*' } })
40 const type = res.headers['content-type'] ?? res.headers['Content-Type'] ?? ''
41 const isHtml = /html/i.test(type) || /^\s*<(!doctype|html)/i.test(res.text)
42 const isJson = /json/i.test(type)
43 return {
44 url,
45 title: isHtml ? titleOf(res.text) || url : url,
46 markdown: isHtml ? toMarkdown(res.text, url) : isJson ? '```json\n' + res.text.slice(0, 60_000) + '\n```' : res.text.slice(0, 80_000),
47 status: res.status,
48 error: res.ok ? null : `HTTP ${res.status}`,
49 isLoading: false,
50 }
51 } catch (err) {
52 return { url, title: url, markdown: '', status: 0, error: String(err), isLoading: false }
53 }
54}
55
56async function navigate($: EngineInterface, target: string, isBack = false) {
57 const prev = await read($, page)
58 await update($, page, p => ({ ...(p ?? { title: '', markdown: '', status: 0, error: null }), url: normalize(target), isLoading: true }))
59 const opened = await $.ui.open({ id: PANE, title: 'Browser', columns: 100 })
60 const loaded = await load($, target)
61 if (!isBack && prev && prev.url !== loaded.url) await update($, history, h => [...h, prev.url].slice(-50))
62 await update($, pageIndex, () => 0)
63 await update($, page, () => loaded)
64 if (opened.isPlaced) await $.ui.open({ id: PANE, title: loaded.title.slice(0, 40) || 'Browser' })
65 return loaded
66}
67
68export const register: Register = on => {
69 on('session.start', async ($, e, next) => {
70 await $.command.register({
71 name: 'browse',
72 description: 'Open a URL, GitHub PR or local HTML file in the browser pane',
73 argumentHint: '<url | path>',
74 })
75 await $.tool.register({
76 name: 'open_page',
77 description:
78 "Open a web page, GitHub pull request, localhost dev server or local HTML/Markdown file in the user's browser pane beside the chat so they can review it without leaving Claude Code. Returns the page title and a short text excerpt.",
79 inputSchema: {
80 type: 'object',
81 properties: { url: { type: 'string', description: 'An http(s) URL, localhost:port, file:// URL or a local file path' } },
82 required: ['url'],
83 },
84 isDeferred: false,
85 })
86 return next(e)
87 })
88
89 on('command.run', { command: 'browse' }, async ($, e) => {
90 const target = e.args.trim()
91 if (!target) {
92 const p = await read($, page)
93 await $.ui.open({ id: PANE, title: p?.title.slice(0, 40) || 'Browser', focus: true })
94 return { text: p ? `Browser: ${p.url}` : 'Usage: /browse <url | path>' }
95 }
96 const loaded = await navigate($, target)
97 return { text: loaded.error ? `Could not open ${loaded.url}: ${loaded.error}` : `Opened ${loaded.title} (${loaded.url}).` }
98 })
99
100 on('tool.call', { tool: 'mcp__terminal-browser__open_page' }, async ($, e) => {
101 const url = String((e as { url?: unknown }).url ?? '')
102 if (!url) return { isError: true as const, result: 'url is required', text: 'url is required' }
103 const loaded = await navigate($, url)
104 if (loaded.error) return { isError: true as const, result: loaded.error, text: `Could not open ${loaded.url}: ${loaded.error}` }
105 return { result: { url: loaded.url, title: loaded.title }, text: `Opened "${loaded.title}" (${loaded.url}) in the user's browser pane.\n\n${loaded.markdown.slice(0, 1500)}` }
106 })
107
108 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
109 const { Box, Text, Button, Markdown, Input } = $.ui.resolve(e)
110 const p = await read($, page)
111 const back = await read($, history)
112 const index = await read($, pageIndex)
113
114 const toolbar = (
115 <Box flexDirection="row" gap={1}>
116 <Button key="back" label="←" hotkey="b" onPress={() => {
117 void (async () => {
118 const h = await read($, history)
119 const last = h.at(-1)
120 if (!last) return
121 await update($, history, list => list.slice(0, -1))
122 await navigate($, last, true)
123 })()
124 }} dimColor={back.length === 0} />
125 <Button key="reload" label="⟳" hotkey="r" onPress={() => void (p && navigate($, p.url, true))} />
126 {p && <Button key="copy" label="Copy URL" plain onPress={press => void $.ui.copy({ text: p.url, surface: press.surface })} />}
127 </Box>
128 )
129
130 const urlBar = Input ? (
131 <Input key="url" placeholder="Enter a URL or file path" value={p?.url ?? ''} submitLabel="Go" onSubmit={value => void navigate($, value)} />
132 ) : (
133 <Text dimColor wrap="truncate-middle">{p?.url ?? ''}</Text>
134 )
135
136 if (!p) {
137 return (
138 <Box flexDirection="column">
139 {urlBar}
140 <Text dimColor>Type a URL above, run /browse <url>, or ask Claude to open a page.</Text>
141 </Box>
142 )
143 }
144
145 // Page the document so long pages stay readable at any height.
146 const rows = Math.max(8, (e.viewport?.rows ?? 30) - 9)
147 const lines = p.markdown.split('\n')
148 const chunks: string[] = []
149 for (let i = 0; i < lines.length; i += rows) chunks.push(lines.slice(i, i + rows).join('\n'))
150 const current = Math.min(index, Math.max(0, chunks.length - 1))
151
152 return (
153 <Box flexDirection="column">
154 <Box flexDirection="row" gap={1}>
155 {toolbar}
156 <Box flexGrow={1} flexShrink={1}>{urlBar}</Box>
157 </Box>
158 <Text bold wrap="truncate-end">
159 {p.isLoading ? '⏳ loading…' : p.title}
160 </Text>
161 {p.error && <Text color="error">{p.error}</Text>}
162 {!p.isLoading && chunks.length > 0 && (
163 <Markdown key="page" text={chunks[current] ?? ''} onLinkPress={link => void navigate($, link.href)} />
164 )}
165 {chunks.length > 1 && (
166 <Box flexDirection="row" gap={2}>
167 <Button key="prev" label="◀ Prev" hotkey="k" dimColor={current === 0} onPress={() => update($, pageIndex, i => Math.max(0, i - 1))} />
168 <Text dimColor>
169 {current + 1}/{chunks.length}
170 </Text>
171 <Button key="next" label="Next ▶" hotkey="j" dimColor={current >= chunks.length - 1} onPress={() => update($, pageIndex, i => Math.min(chunks.length - 1, i + 1))} />
172 </Box>
173 )}
174 </Box>
175 )
176 })
177}
178hooks/html.ts 85 lines1// A small HTML-to-Markdown reader: enough for docs, articles, READMEs, PRs and local builds.
2
3const ENTITIES: Record<string, string> = {
4 amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
5 laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', middot: '·', bull: '•', rsquo: '’', lsquo: '‘',
6 rdquo: '”', ldquo: '“', times: '×', larr: '←', rarr: '→',
7}
8
9export function decode(text: string): string {
10 return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
11 if (code[0] === '#') {
12 const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
13 return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : whole
14 }
15 return ENTITIES[code.toLowerCase()] ?? whole
16 })
17}
18
19function absolute(href: string, base: string): string {
20 try {
21 return new URL(href, base).toString()
22 } catch {
23 return href
24 }
25}
26
27export function titleOf(html: string): string {
28 const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
29 return m ? decode(m[1]!.replace(/\s+/g, ' ').trim()) : ''
30}
31
32export function toMarkdown(html: string, base: string): string {
33 let s = html
34 // Prefer the page's main content where it marks one.
35 const main = s.match(/<main[\s>][\s\S]*?<\/main>/i) ?? s.match(/<article[\s>][\s\S]*?<\/article>/i)
36 if (main) s = main[0]
37 else {
38 const body = s.match(/<body[^>]*>([\s\S]*)<\/body>/i)
39 if (body) s = body[1]!
40 }
41
42 s = s
43 .replace(/<!--[\s\S]*?-->/g, '')
44 .replace(/<(script|style|noscript|svg|template|iframe|head|nav|footer|form|button|select)\b[\s\S]*?<\/\1>/gi, '')
45 .replace(/<(input|meta|link)\b[^>]*>/gi, '')
46
47 // Preformatted blocks: protect them from whitespace collapsing.
48 const blocks: string[] = []
49 s = s.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, inner: string) => {
50 const code = decode(inner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''))
51 blocks.push('\n```\n' + code.replace(/\n+$/, '') + '\n```\n')
52 return `\u0000${blocks.length - 1}\u0000`
53 })
54
55 s = s
56 .replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, n: string, inner: string) => `\n\n${'#'.repeat(Number(n))} ${inner.replace(/\s+/g, ' ').trim()}\n\n`)
57 .replace(/<a\b[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href: string, inner: string) => {
58 const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
59 if (!text || href.startsWith('javascript:') || href.startsWith('#')) return text
60 return `[${text.replace(/[[\]]/g, '')}](${absolute(decode(href), base).replace(/\s/g, '%20').replace(/\)/g, '%29')})`
61 })
62 .replace(/<img\b[^>]*?alt\s*=\s*["']([^"']*)["'][^>]*>/gi, (_, alt: string) => (alt.trim() ? `[image: ${alt.trim()}]` : ''))
63 .replace(/<img\b[^>]*>/gi, '')
64 .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, _t: string, inner: string) => (inner.trim() ? `**${inner.trim()}**` : ''))
65 .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, _t: string, inner: string) => (inner.trim() ? `*${inner.trim()}*` : ''))
66 .replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_, inner: string) => '`' + inner.replace(/<[^>]+>/g, '') + '`')
67 .replace(/<li\b[^>]*>/gi, '\n- ')
68 .replace(/<blockquote\b[^>]*>/gi, '\n> ')
69 .replace(/<(br|hr)\s*\/?>/gi, m => (/hr/i.test(m) ? '\n\n---\n\n' : '\n'))
70 .replace(/<\/(td|th)>/gi, ' | ')
71 .replace(/<\/(p|div|section|tr|ul|ol|table|blockquote|header|details|summary|dl|dd|dt|figure)>/gi, '\n\n')
72 .replace(/<[^>]+>/g, '')
73
74 s = decode(s)
75 .replace(/[ \t\f\v\r]+/g, ' ')
76 .split('\n')
77 .map(l => l.trim())
78 .join('\n')
79 .replace(/\n{3,}/g, '\n\n')
80 .replace(/\u0000(\d+)\u0000/g, (_, i: string) => blocks[Number(i)] ?? '')
81 .trim()
82
83 return s.length > 80_000 ? s.slice(0, 80_000) + '\n\n…(page truncated)' : s
84}
85types/index.d.ts 15 lines1export type Page = {
2 url: string
3 title: string
4 markdown: string
5 status: number
6 error: string | null
7 isLoading: boolean
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'terminal-browser': { page: Page | null; history: string[]; pageIndex: number }
13 }
14}
15