A browser that runs in the terminal, inside Claude Code: preview sites and HTML documents, and let the agent drive the built-in browser. Terminal only; it…

Opens a real browser in a pane inside Claude Code's terminal UI, so you can preview a site or an HTML document without leaving the session, and Claude can drive it.
It works in the terminal only. On the desktop app it draws nothing and does not register /browser, so it never clashes with the desktop's own browser.
First install the terminal-browser program that does the rendering:
curl -fsSL https://terminal-browser.sh/install | bash
(or brew install terminal-browser). Then, in Claude Code:
/plugin marketplace add partalemre/MODZ
/plugin install terminal-browser@modz
Or in one step: /plugin install terminal-browser --marketplace partalemre/MODZ.
If the program is missing, /browser says "Install terminal-browser first" and gives the address; if it is too old, it asks you to run terminal-browser upgrade.
/browser opens a pane on the right with a live page drawn as pixels through the kitty graphics protocol. Until the first frame arrives the pane shows the address and "Loading the page...", or the reason the bridge could not start. /browser with no address opens the start page.
You can click and type in the page. The mouse position can be slightly off, because Claude Code does not enable pixel-coordinate mouse reporting.
Claude can control the open page with the terminal-browser command line program; an open tool for it is optional (see Settings).
/browser opens the browser, or closes it if it is already open./browser <url> opens that address. Hosts with a port work, for example localhost:3000 and example.com:8080./browser close closes it.If the bridge dies, the next /browser starts it again.
| Option | Default | Meaning |
|---|---|---|
agentTool | false | Gives Claude an open and a close tool for the browser pane. Off by default, because Claude can already open the browser through the terminal-browser command line program. |
To turn it on, add this to ~/.claude/settings.json:
{
"pluginConfigs": {
"terminal-browser@modz": { "options": { "agentTool": true } }
}
}
terminal-browser program, so your OS may ask whether your terminal may access the local network.Makes no model calls and sends no requests off your machine. With agentTool on, the two tool definitions add a few tokens to every request.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
Adapted from zenbu-labs/terminal-browser, licensed MIT. The license file is in this folder.
hooks/register.tsx 186 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import type { Browser } from './browser'
4import { post } from './bridge-client'
5import { imageGrid, imageSource, paneStatus } from './frame'
6import type { Host } from './host'
7import { browserClosed, closeBrowser, openBrowser } from './pane-control'
8import { IMAGE_KEY, START_URL, state, viewKey } from './session'
9import { statusView } from './status'
10import { isInputMessage, isSizeMessage } from './bridge-protocol'
11import { setLang } from './lang'
12import { openedText, word } from './words'
13
14// Hook filters stay literal here (session.ts holds the same id): the scan follows literals, not imports.
15const PANE = 'browser'
16const IMAGE_ALT = ''
17const OPEN_TOOL = 'mcp__terminal-browser__open'
18const CLOSE_TOOL = 'mcp__terminal-browser__close'
19const MIN_BODY_ROWS = 8
20const PANE_CHROME_ROWS = 8
21const FALLBACK_VIEWPORT_ROWS = 30
22const FALLBACK_VIEWPORT_COLUMNS = 80
23const MAX_SCROLL_TICKS = 10
24
25type On = Parameters<Register>[0]
26
27function hostOf($: EngineInterface): Host {
28 return {
29 root: $.plugin.root,
30 exists: path => $.fs.exists(path),
31 run: (argv, init) => $.process.run(argv, init),
32 fetch: (url, init) => $.http.fetch(url, init),
33 sleep: ms => $.clock.sleep(ms),
34 log: (text, init) => $.ui.log(text, init),
35 redraw: () => $.ui.invalidate('ui.render'),
36 open: request => $.ui.open(request),
37 close: request => $.ui.close(request),
38 blit: request => $.ui.blit(request),
39 fill: request => $.prompt.fill(request),
40 }
41}
42
43function onBrowserNoun(on: On): void {
44// this is a hack because the plugin api does not allow $ or the result of next to be passed to functions
45 on('engine.create', async ($, e, next) => {
46 const built = await next(e)
47 const servedByHooks = () => { throw new Error('the browser noun is served by its hooks') }
48 const browser: Browser = { open: servedByHooks, close: servedByHooks }
49 return { ...built, browser }
50 })
51
52 on('browser.open', async ($, e) => {
53 const opened = await openBrowser(hostOf($), e.url ?? null)
54 return { value: opened }
55 })
56
57 on('browser.close', async ($) => {
58 return { value: await closeBrowser(hostOf($)) }
59 })
60}
61
62function onSessionStart(on: On, agentToolEnabled: boolean): void {
63 on('session.start', async ($, e, next) => {
64 // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
65 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
66 const r = await next(e)
67 let surfaces: readonly string[] = []
68 try {
69 surfaces = await $.session.surfaces()
70 } catch (err) {
71 $.ui.log(`terminal-browser: yüzeyler okunamadı, terminal yok sayılıyor: ${err}`, { to: 'debug' })
72 }
73 // The desktop has a browser of its own: register nothing there, so /browser never clashes.
74 state.isOffTerminal = !surfaces.includes('terminal') || surfaces.includes('desktop')
75 if (state.isOffTerminal) return r
76 await $.command.register({
77 name: 'browser',
78 description: word('command'),
79 argumentHint: '[url]',
80 immediate: true,
81 }).catch(err => $.ui.log(`terminal-browser: /browser kaydedilemedi: ${err}`))
82 if (agentToolEnabled) {
83 await $.tool.register({
84 name: 'open',
85 description: 'Open terminal-browser directly inside claude code. Control the open page with the terminal-browser action CLI.',
86 inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The page to open, as a full url or a host name' } } },
87 }).catch(err => $.ui.log(`terminal-browser: open aracı kaydedilemedi: ${err}`))
88 await $.tool.register({ name: 'close', description: 'Close the terminal-browser pane.' }).catch(err => $.ui.log(`terminal-browser: close aracı kaydedilemedi: ${err}`))
89 }
90 return r
91 })
92}
93
94function onCommands(on: On): void {
95 on('command.run', { command: 'browser' }, async ($, e) => {
96 const arg = e.args.trim()
97 if (arg === 'close' || (!arg && state.open)) {
98 const wasOpen = await closeBrowser(hostOf($))
99 return { text: word(wasOpen ? 'closed' : 'alreadyClosed') }
100 }
101 const opened = await openBrowser(hostOf($), arg || null)
102 return { text: opened.ok ? openedText(opened.url, opened.url === START_URL) : opened.error }
103 })
104
105 on('tool.call', { tool: new RegExp(`^${OPEN_TOOL}$`) }, async ($, e) => {
106 const url = (e as { url?: unknown }).url
107 const opened = await openBrowser(hostOf($), typeof url === 'string' ? url : null)
108 if (!opened.ok) return { deny: `could not open the browser: ${opened.error}` }
109 return { result: [{ type: 'text', text: `opened ${opened.url} ` }] }
110 })
111
112 on('tool.call', { tool: new RegExp(`^${CLOSE_TOOL}$`) }, async ($) => {
113 const closed = await closeBrowser(hostOf($))
114 return { result: [{ type: 'text', text: closed ? 'browser pane closed' : 'no browser pane was open (maybe the user closed it?)' }] }
115 })
116}
117
118function onPaneRender(on: On): void {
119 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
120 if (e.surface !== 'terminal') {
121 const { Box } = await $.ui.resolve(e)
122 return <Box />
123 }
124 const { Box, Client, Image, Text } = await $.ui.resolve(e)
125 const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : Math.max(MIN_BODY_ROWS, (e.viewport?.rows ?? FALLBACK_VIEWPORT_ROWS) - PANE_CHROME_ROWS)
126 const cols = Math.max(1, e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? FALLBACK_VIEWPORT_COLUMNS))
127 const frame = state.frame
128 const grid = frame ? imageGrid(frame, cols, rows) : null
129 state.mounted = grid
130 return (
131 <Box flexDirection="column" width={cols} height={rows}>
132 {frame && grid ? (
133 <Image key={IMAGE_KEY} source={imageSource(frame)} columns={grid.cols} rows={grid.rows} alt={IMAGE_ALT} />
134 ) : statusView({ Box, Text }, paneStatus())}
135 <Box position="absolute" top={0} left={0} width={cols} height={rows}>
136 <Client key={viewKey()} module="./surface.tsx" width={cols} height={rows} />
137 </Box>
138 </Box>
139 )
140 })
141}
142
143function onPaneInput(on: On): void {
144 on('ui.message', async ($, e, next) => {
145 if (e.requestId !== PANE || state.port === null) return next(e)
146 if (isSizeMessage(e.data)) {
147 state.region = { cols: e.data.cols, rows: e.data.rows }
148 if (state.pendingUrl) {
149 const url = state.pendingUrl
150 state.pendingUrl = null
151 await post(hostOf($), '/open', { url, ...state.region })
152 } else {
153 await post(hostOf($), '/size', state.region)
154 }
155 } else if (isInputMessage(e.data)) {
156 await post(hostOf($), '/input', { events: e.data.events })
157 }
158 return next(e)
159 })
160
161 on('ui.scroll', { requestId: PANE }, async ($, e) => {
162 if (state.port === null || !state.open || e.by === 0) return {}
163 const kind = e.by < 0 ? 'scrollup' : 'scrolldown'
164 const ticks = Math.min(Math.round(Math.abs(e.by)) || 1, MAX_SCROLL_TICKS)
165 const x = e.pointer?.column ?? Math.floor((state.region?.cols ?? 0) / 2)
166 const y = e.pointer?.row ?? Math.floor((state.region?.rows ?? 0) / 2)
167 const events = Array.from({ length: ticks }, () => ({ type: 'mouse', kind, x, y }))
168 await post(hostOf($), '/input', { events })
169 return {}
170 })
171}
172
173export const register: Register = (on, options) => {
174 const agentToolEnabled = options.agentTool === true
175 onBrowserNoun(on)
176 onSessionStart(on, agentToolEnabled)
177 onCommands(on)
178 onPaneRender(on)
179 onPaneInput(on)
180 on('ui.close', { id: PANE }, async ($, e, next) => {
181 const r = await next(e)
182 await browserClosed(hostOf($))
183 return r
184 })
185}
186hooks/bridge-client.ts 37 lines1import { isBridgeState, type BridgeState } from './bridge-protocol'
2import type { Host } from './host'
3import { authHeaders, bridgeUrl, state } from './session'
4
5// A watcher that stopped (the session unloading, the bridge gone) leaves nothing to talk to:
6// the next /browser launches a bridge again instead of posting to a dead port.
7export function forgetBridge(cause: unknown): void {
8 state.port = null
9 state.token = null
10 state.last = state.last === null ? null : { ...state.last, alive: false, error: `terminal-browser bağlantısı koptu: ${String(cause)}` }
11}
12
13export async function post(host: Host, path: string, body: unknown): Promise<unknown> {
14 if (state.port === null) return null
15 try {
16 const response = await host.fetch(bridgeUrl(path), {
17 method: 'POST',
18 headers: { 'content-type': 'application/json', ...authHeaders() },
19 body: JSON.stringify(body),
20 })
21 return response.ok ? JSON.parse(response.text || '{}') : null
22 } catch {
23 return null
24 }
25}
26
27export async function fetchState(host: Host, version: number): Promise<BridgeState | null> {
28 if (state.port === null) return null
29 try {
30 const response = await host.fetch(bridgeUrl(`/state?version=${version}`), { headers: authHeaders() })
31 const parsed: unknown = response.ok ? JSON.parse(response.text) : null
32 return isBridgeState(parsed) ? parsed : null
33 } catch {
34 return null
35 }
36}
37hooks/frame.ts 28 lines1import type { Frame } from './bridge-protocol'
2import { MAX_IMAGE_CELLS } from './surface'
3import { state, type Grid } from './session'
4
5// Until the first frame: the address on its way, or the bridge's own reason it stopped.
6export function paneStatus() {
7 const last = state.last
8 return { url: state.requestedUrl ?? last?.url ?? null, error: last?.error ?? null, isAlive: last === null ? null : last.alive }
9}
10
11export function imageSource(frame: Frame) {
12 return { shm: frame.shm, format: frame.format, width: frame.width, height: frame.height, generation: frame.generation }
13}
14
15export function imageGrid(frame: Frame, cols: number, rows: number): Grid {
16 return {
17 cols: Math.max(1, Math.min(frame.cols, cols, MAX_IMAGE_CELLS)),
18 rows: Math.max(1, Math.min(frame.rows, rows, MAX_IMAGE_CELLS)),
19 }
20}
21
22export function fitsMounted(frame: Frame): boolean {
23 const mounted = state.mounted
24 if (!mounted || !state.region) return false
25 const grid = imageGrid(frame, state.region.cols, state.region.rows)
26 return grid.cols === mounted.cols && grid.rows === mounted.rows
27}
28hooks/host.ts 18 lines1import type { EngineInterface } from 'claude-code'
2
3// The engine's reach, bound in register.tsx: the engine follows `$` only into functions of the hooks module's own
4// file, so the bridge, watcher and pane modules get this port instead.
5export type Host = {
6 root: string
7 exists(path: string): Promise<boolean>
8 run: EngineInterface['process']['run']
9 fetch: EngineInterface['http']['fetch']
10 sleep(ms: number): Promise<void>
11 log: EngineInterface['ui']['log']
12 redraw(): void
13 open: EngineInterface['ui']['open']
14 close: EngineInterface['ui']['close']
15 blit: EngineInterface['ui']['blit']
16 fill: EngineInterface['prompt']['fill']
17}
18hooks/pane-control.ts 50 lines1import { post } from './bridge-client'
2import { startBridge } from './bridge-launch'
3import type { Host } from './host'
4import { PANE, START_URL, state } from './session'
5import { normalizeUrl } from './urls'
6import { word } from './words'
7
8const PANE_ROWS = 24
9
10function targetUrl(raw: string | null): string {
11 if (raw) return normalizeUrl(raw)
12 const alive = state.last?.alive === true
13 return alive && state.last?.url ? state.last.url : START_URL
14}
15
16export async function openBrowser(host: Host, raw: string | null): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
17 if (state.isOffTerminal) return { ok: false, error: word('terminalOnly') }
18 if (state.port === null) {
19 const started = await startBridge(host)
20 if (!started.ok) return started
21 }
22 const url = targetUrl(raw)
23 state.pendingUrl = url
24 state.requestedUrl = url
25 state.open = true
26 await host.open({ id: PANE, title: word('paneTitle'), focus: true, rows: PANE_ROWS })
27 host.redraw()
28 if (state.region) {
29 state.pendingUrl = null
30 await post(host, '/open', { url, ...state.region })
31 }
32 return { ok: true, url }
33}
34
35export async function closeBrowser(host: Host): Promise<boolean> {
36 if (!state.open) return false
37 await host.close({ id: PANE })
38 return true
39}
40
41export async function browserClosed(host: Host): Promise<void> {
42 state.open = false
43 state.pendingUrl = null
44 state.requestedUrl = null
45 state.region = null
46 state.frame = null
47 state.mounted = null
48 await post(host, '/browser/close', {})
49}
50hooks/session.ts 30 lines1import type { BridgeState, Frame } from './bridge-protocol'
2
3export const PANE = 'browser'
4export const IMAGE_KEY = 'view'
5export const START_URL = 'terminal-browser://start'
6
7export type Grid = { cols: number; rows: number }
8
9export const state = {
10 port: null as number | null,
11 token: null as string | null,
12 open: false,
13 pendingUrl: null as string | null,
14 // The address last asked for, which the pane names until the first frame lands.
15 requestedUrl: null as string | null,
16 region: null as Grid | null,
17 last: null as BridgeState | null,
18 frame: null as Frame | null,
19 mounted: null as Grid | null,
20 watcher: 0,
21 // a hack to programatically trigger agent input focus
22 viewGeneration: 0,
23 // A session drawn only off the terminal (the desktop app) gets no /browser and no pane.
24 isOffTerminal: false,
25}
26
27export const bridgeUrl = (path: string) => `http://127.0.0.1:${state.port}${path}`
28export const authHeaders = () => ({ authorization: `Bearer ${state.token}` })
29export const viewKey = () => `view${state.viewGeneration}`
30hooks/status.tsx 39 lines1/* @jsx h */
2// What the pane says while it has no picture yet: the page it is loading, or why it stopped.
3// The browser's own picture covers this as soon as the first frame lands.
4import type { ElementTable } from 'claude-code'
5import { word } from './words'
6
7type Ui = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>
8
9export type PaneStatus = { url: string | null; error: string | null; isAlive: boolean | null }
10
11const START_URL = 'terminal-browser://start'
12
13// The start page has no address worth showing; a real one is shown whole.
14function shownUrl(url: string | null): string {
15 return url === null || url === START_URL ? word('startPage') : url
16}
17
18function lead(status: PaneStatus): { text: string; color?: string } {
19 if (status.error !== null && status.error !== '') return { text: status.error, color: 'error' }
20 if (status.isAlive === false) return { text: word('died'), color: 'warning' }
21
22 return { text: word('loading') }
23}
24
25export function statusView(ui: Ui, status: PaneStatus) {
26 const { Box, Text } = ui
27 const line = lead(status)
28 const isStuck = line.color !== undefined
29
30 return (
31 <Box key="tb-status" flexDirection="column" paddingX={1} paddingY={1}>
32 <Text color="suggestion" bold>{word('eyebrow')}</Text>
33 <Text dimColor wrap="truncate-middle">{shownUrl(status.url)}</Text>
34 <Text color={line.color} dimColor={!isStuck}>{line.text}</Text>
35 {isStuck ? <Text dimColor>{word('retry')}</Text> : null}
36 </Box>
37 )
38}
39hooks/bridge-protocol.ts 66 lines1// we cannot use libraries inside the claude code sandbox, hence this gross code
2
3
4export type Frame = {
5 shm: string
6 format: 'rgba' | 'rgb'
7 width: number
8 height: number
9 generation: number
10 cols: number
11 rows: number
12}
13
14export type BridgeState = {
15 version: number
16 frame: Frame | null
17 title: string
18 url: string | null
19 alive: boolean
20 error: string | null
21 inbox: number
22}
23
24export type LaunchReport =
25 | { port: number; token: string }
26 | { error: string; code: 'tty' | 'start' }
27
28export type SizeMessage = { type: 'size'; cols: number; rows: number }
29export type InputMessage = { type: 'input'; events: unknown[] }
30
31const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
32
33export const isFrame = (value: unknown): value is Frame =>
34 isRecord(value)
35 && typeof value.shm === 'string'
36 && (value.format === 'rgba' || value.format === 'rgb')
37 && Number.isInteger(value.width)
38 && Number.isInteger(value.height)
39 && Number.isInteger(value.generation)
40 && Number.isInteger(value.cols)
41 && Number.isInteger(value.rows)
42
43export const isBridgeState = (value: unknown): value is BridgeState =>
44 isRecord(value)
45 && Number.isInteger(value.version)
46 && typeof value.alive === 'boolean'
47 && 'frame' in value
48 && (value.frame === null || isFrame(value.frame))
49
50export const isLaunchReport = (value: unknown): value is LaunchReport =>
51 isRecord(value) && (typeof value.port === 'number' || typeof value.error === 'string')
52
53export const isSizeMessage = (data: unknown): data is SizeMessage =>
54 isRecord(data) && data.type === 'size' && Number.isInteger(data.cols) && Number.isInteger(data.rows)
55
56export const isInputMessage = (data: unknown): data is InputMessage =>
57 isRecord(data) && data.type === 'input' && Array.isArray(data.events)
58
59export type AgentText = { text: string; screenshot: string | null }
60
61const isAgentText = (value: unknown): value is AgentText =>
62 isRecord(value) && typeof value.text === 'string' && (value.screenshot === null || typeof value.screenshot === 'string')
63
64export const takenItems = (value: unknown): AgentText[] =>
65 isRecord(value) && Array.isArray(value.items) ? value.items.filter(isAgentText) : []
66hooks/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/words.ts 49 lines1// Every word the browser shows the person, in English and Turkish. Read through `pick` at draw time, after
2// session.start has set the language.
3import { pick } from './lang'
4
5const EN = {
6 command: 'Opens a browser on the right',
7 closed: 'Browser closed',
8 alreadyClosed: 'The browser is already closed',
9 opened: 'Browser opened',
10 startPage: 'Start page',
11 paneTitle: 'Browser',
12 eyebrow: '● BROWSER',
13 loading: 'Loading the page…',
14 died: 'The browser closed',
15 retry: 'Try again with /browser · /browser close closes it',
16 terminalOnly: 'terminal-browser runs only in the terminal',
17 upgrade: 'This needs a newer terminal-browser: run terminal-browser upgrade',
18 install: 'Install terminal-browser first',
19 noStart: 'terminal-browser could not start',
20}
21
22const TR: typeof EN = {
23 command: 'Sağda bir tarayıcı açar',
24 closed: 'Tarayıcı kapatıldı',
25 alreadyClosed: 'Tarayıcı zaten kapalı',
26 opened: 'Tarayıcı açıldı',
27 startPage: 'Başlangıç sayfası',
28 paneTitle: 'Tarayıcı',
29 eyebrow: '● TARAYICI',
30 loading: 'Sayfa yükleniyor…',
31 died: 'Tarayıcı kapandı',
32 retry: '/browser ile yeniden dene · /browser close kapatır',
33 terminalOnly: 'terminal-browser yalnızca terminalde çalışır',
34 upgrade: 'Daha yeni bir terminal-browser gerekiyor: terminal-browser upgrade çalıştır',
35 install: "Önce terminal-browser'ı kur",
36 noStart: 'terminal-browser başlatılamadı',
37}
38
39export type Word = keyof typeof EN
40
41export const word = (key: Word): string => pick({ en: EN, tr: TR })[key]
42
43// The command's answer after opening: the start page by name in the sentence's own case, a real address whole.
44export function openedText(url: string, isStart: boolean): string {
45 const startPage = pick({ en: 'start page', tr: 'başlangıç sayfası' })
46
47 return `${word('opened')}: ${isStart ? startPage : url}`
48}
49hooks/surface.tsx 81 lines1/* @jsx h */
2import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
3
4export const MAX_IMAGE_CELLS = 255
5
6type State = { cols: number; rows: number }
7
8type InputEvent =
9 | { type: 'mouse'; kind: ClientPointerEvent['type']; button?: string; x: number; y: number; mods: Mods }
10 | { type: 'key'; key: string; text?: string; mods: Mods }
11type Mods = { shift: boolean; alt: boolean; ctrl: boolean; super: boolean }
12
13const KEY_NAMES: Record<string, string> = {
14 return: 'enter',
15 enter: 'enter',
16 backspace: 'backspace',
17 delete: 'delete',
18 tab: 'tab',
19 up: 'up',
20 down: 'down',
21 left: 'left',
22 right: 'right',
23 home: 'home',
24 end: 'end',
25 pageup: 'pageup',
26 pagedown: 'pagedown',
27 insert: 'insert',
28 space: ' ',
29}
30
31function keyEvent(event: ClientKeyEvent): InputEvent | null {
32 const mods: Mods = { shift: Boolean(event.shift), alt: false, ctrl: Boolean(event.ctrl), super: Boolean(event.meta) }
33 const named = KEY_NAMES[event.key.toLowerCase()]
34 if (named !== undefined) {
35 return { type: 'key', key: named, text: named.length === 1 && !mods.ctrl ? named : undefined, mods }
36 }
37 if ([...event.key].length === 1) {
38 return { type: 'key', key: event.key, text: mods.ctrl || mods.super ? undefined : event.key, mods }
39 }
40 const fn = /^f(\d{1,2})$/i.exec(event.key)
41 if (fn) return { type: 'key', key: `f${fn[1]}`, mods }
42 return null
43}
44
45function pointerEvent(event: ClientPointerEvent): InputEvent | null {
46 const mods: Mods = { shift: Boolean(event.shift), alt: Boolean(event.alt), ctrl: Boolean(event.ctrl), super: false }
47 if (event.type === 'enter' || event.type === 'leave') return { type: 'mouse', kind: 'move', x: event.x, y: event.y, mods }
48 return { type: 'mouse', kind: event.type, button: event.button, x: event.x, y: event.y, mods }
49}
50
51export default function Browser(_props: unknown, surface: ClientSurface<State>) {
52 const { Box } = surface.elements
53 const queue: InputEvent[] = []
54
55 if (surface.state === undefined) {
56 surface.setState({ cols: 0, rows: 0 })
57 surface.onPointer(event => {
58 const mapped = pointerEvent(event)
59 if (mapped) queue.push(mapped)
60 })
61 surface.onKey(event => {
62 const mapped = keyEvent(event)
63 if (mapped) queue.push(mapped)
64 })
65
66 surface.every(20, () => {
67 if (queue.length === 0) return
68 surface.post({ type: 'input', events: queue.splice(0, queue.length) as unknown as never })
69 })
70 }
71
72 const cols = Math.min(surface.columns, MAX_IMAGE_CELLS)
73 const rows = Math.min(surface.rows, MAX_IMAGE_CELLS)
74 if (cols > 0 && rows > 0 && surface.state && (surface.state.cols !== cols || surface.state.rows !== rows)) {
75 surface.setState({ cols, rows })
76 surface.post({ type: 'size', cols, rows })
77 }
78
79 return <Box flexDirection="column" height="100%" />
80}
81hooks/bridge-launch.ts 71 lines1import { isLaunchReport } from './bridge-protocol'
2import { forgetBridge } from './bridge-client'
3import type { Host } from './host'
4import { state } from './session'
5import { watchBridge } from './watch'
6import { word } from './words'
7
8const INSTALL_URL = 'https://terminal-browser.sh'
9const REQUIRED_CAPABILITIES = ['embedding', 'image-frames']
10const CAPABILITIES_TIMEOUT_MS = 10_000
11const LAUNCH_TIMEOUT_MS = 20_000
12
13async function terminalBrowserCommand(host: Host): Promise<string[]> {
14 const checkoutCli = `${host.root}/../cli/dist/main.js`
15 if (await host.exists(checkoutCli)) return ['node', checkoutCli] // dev case
16 return ['terminal-browser']
17}
18
19async function checkCapabilities(host: Host, command: string[]): Promise<{ ok: true } | { ok: false; installed: boolean }> {
20 let result: { stdout: string; exitCode: number }
21 try {
22 result = await host.run([...command, 'capabilities'], { timeoutMs: CAPABILITIES_TIMEOUT_MS })
23 } catch {
24 return { ok: false, installed: false }
25 }
26 let capabilities: string[] = []
27 if (result.exitCode === 0) {
28 const line = result.stdout.split('\n').find((text: string) => text.startsWith('{'))
29 try {
30 const parsed = line ? JSON.parse(line) : null
31 if (parsed && Array.isArray(parsed.capabilities)) capabilities = parsed.capabilities
32 } catch (err) {
33 host.log(`terminal-browser: capabilities yanıtı okunamadı: ${err}`, { to: 'debug' })
34 }
35 }
36 const ok = REQUIRED_CAPABILITIES.every(need => capabilities.includes(need))
37 return ok ? { ok: true } : { ok: false, installed: true }
38}
39
40async function launchReport(host: Host, command: string[]): Promise<unknown> {
41 try {
42 const { stdout, stderr, exitCode } = await host.run([...command, 'claude-bridge', 'launch'], { timeoutMs: LAUNCH_TIMEOUT_MS })
43 const line = stdout.split('\n').find((text: string) => text.startsWith('{'))
44 return line ? JSON.parse(line) : { error: stderr.trim() || `exit ${exitCode}`, code: 'start' }
45 } catch (err) {
46 return { error: String(err), code: 'start' }
47 }
48}
49
50export async function startBridge(host: Host): Promise<{ ok: true } | { ok: false; error: string }> {
51 const command = await terminalBrowserCommand(host)
52 const check = await checkCapabilities(host, command)
53 if (!check.ok) {
54 return {
55 ok: false,
56 error: check.installed
57 ? word('upgrade')
58 : `${word('install')}: ${INSTALL_URL}`,
59 }
60 }
61 const report = await launchReport(host, command)
62 if (!isLaunchReport(report) || !('port' in report)) {
63 const detail = isLaunchReport(report) && 'error' in report ? report.error : word('noStart')
64 return { ok: false, error: `${detail}` }
65 }
66 state.port = report.port
67 state.token = report.token
68 void watchBridge(host).catch(cause => forgetBridge(cause))
69 return { ok: true }
70}
71