Embed a real browser inside OpenTUI. Electron runs as a hidden offscreen sidecar—there is no visible Electron window—and the selected page is rendered directly into a Kitty-capable terminal.
This project is inspired by zenbu-labs/terminal-browser and its work on making a real browser feel at home inside a terminal interface.
- A focusable
BrowserRenderablewith keyboard, paste, pointer, wheel, hover, and cursor feedback. - Responsive page sizing that follows the OpenTUI surface and terminal resolution.
- One Electron runtime with one or more long-lived pages.
- Exact loopback DevTools endpoints and target IDs so an agent and the user can share the same page.
- Latest-frame scheduling and 1 fps background-page idling to keep rendering work bounded.
- Promise-based public APIs plus optional scoped Effect services.
There is one rendering path and no system-Chrome or screenshot fallback:
hidden Electron BrowserWindow -> top-down RGBA file -> Kitty f=32,t=t -> terminal image placement
- Bun 1.3 or newer, or Node.js 26.3 or newer.
@opentui/core0.4.5 (supported range:>=0.4.5 <0.5.0).- A local terminal with Kitty graphics support. Ghostty is the primary tested terminal.
- OpenTUI's
"alternate-screen"mode and a direct local renderer.
Remote renderers, terminal multiplexers such as tmux or screen, and remote terminal attachment are not supported by
the current image transport. Node applications that create an OpenTUI renderer must start Node with
--experimental-ffi.
With Bun:
bun add opentui-browser @opentui/coreWith npm:
npm install opentui-browser @opentui/coreElectron is a package dependency. If the package manager did not run Electron's install script, the first Managed Browser Runtime launch runs Electron's installer asynchronously in a bounded worker and validates the installed binary before continuing. That first launch needs either a cached binary or network access; later launches use the installed executable directly.
import { createCliRenderer } from "@opentui/core"
import { BrowserRenderable } from "opentui-browser"
let finish!: () => void
const waitForQuit = new Promise<void>((resolve) => {
finish = resolve
})
const renderer = await createCliRenderer({
screenMode: "alternate-screen",
exitOnCtrlC: true,
onDestroy: finish,
})
const browser = new BrowserRenderable(renderer, {
url: "https://example.com",
width: "100%",
height: "100%",
border: true,
title: " browser ",
bottomTitle: " Ctrl-C quit - click, type, scroll ",
bottomTitleAlignment: "center",
})
renderer.root.add(browser)
try {
await browser.start()
browser.focus()
await waitForQuit
} finally {
try {
await browser.close()
} finally {
renderer.destroy()
}
}BrowserRenderable exposes goto(), reload(), goBack(), goForward(), stopLoading(), settle(),
url, loading, pointerStyle, surfaceMetrics, started, closed, page, and runtime.
Run the included browser with terminal-native address and navigation controls:
bun run exampleSet OPENTUI_BROWSER_URL to change its initial page. Set OPENTUI_BROWSER_PROFILE_DIR to use a dedicated
persistent Electron profile.
The most commonly used BrowserRenderable options are:
| Option | Purpose |
|---|---|
url |
Initial URL. Values without a scheme are treated as HTTPS. |
page |
Present an existing Shared Browser Page without owning it. |
runtime |
Create and own a page inside an existing browser runtime. |
launch |
Configure the Electron runtime owned by this Browser Surface. |
maxFps |
Maximum terminal presentation rate. Defaults to 60. |
everyNthFrame |
Ask Electron to submit every Nth active paint frame. |
maxViewportWidth, maxViewportHeight, maxViewportPixels |
Bound the pixel cost of terminal-sized pages. |
showLoadingIndicator |
Toggle the built-in loading label. |
onNavigationStateChange |
Synchronize an address field, buttons, or a spinner with the page. |
onPointerStyleChange |
Observe the page cursor when the host coordinates pointer ownership. |
Common launch options include:
const browser = new BrowserRenderable(renderer, {
launch: {
userDataDirectory: "/absolute/path/to/dedicated-profile",
frameRate: 60,
readyTimeoutMs: 10_000,
shutdownTimeoutMs: 2_000,
},
})page and runtime are mutually exclusive, and neither can be combined with launch.
ElectronBrowser owns one hidden Electron sidecar, its profile, control connection, DevTools server, raw-frame
storage, and pages:
import { ElectronBrowser } from "opentui-browser"
const runtime = await ElectronBrowser.launch({
userDataDirectory: "/absolute/path/to/dedicated-profile",
})
try {
const page = await runtime.newPage({ url: "https://example.com" })
console.log(page.title, page.url)
} finally {
await runtime.close()
}Ownership is explicit:
| Browser Surface input | What the surface closes |
|---|---|
No source, or launch |
Its runtime, page, and Presentation |
runtime |
The page it creates and its Presentation |
page |
Only its Presentation |
Closing a surface never closes a supplied page or runtime. Electron uses a private temporary profile unless
userDataDirectory is supplied; owned temporary profiles are deleted during cleanup, while caller-owned profiles are
never removed.
Every ElectronPage exposes the exact attachment for its live webContents:
const runtime = await ElectronBrowser.launch()
const page = await runtime.newPage({ url: "https://example.com" })
console.log(page.devToolsTarget)
// {
// protocol: "cdp",
// endpoint: "http://127.0.0.1:<port>",
// targetId: "<exact-page-target-id>"
// }
console.log(runtime.webSocketEndpoint)Use page.devToolsTarget.endpoint as the browser HTTP URL for a CDP client such as Chrome DevTools MCP, then select
the listed page whose target ID equals page.devToolsTarget.targetId. runtime.webSocketEndpoint is the
browser-level WebSocket endpoint.
The terminal owns presentation and human input while the DevTools client inspects and operates the same page. DevTools does not carry presentation frames, and this package does not embed the interactive Chrome DevTools UI as another terminal pane.
For a complete OpenCode integration with automatic browser-control and Chrome DevTools MCP registration, see
@neriousy/opencode-browser.
Effect hosts can import scoped services from opentui-browser/effect:
import { Effect } from "effect"
import { BrowserSurface, Electron } from "opentui-browser/effect"
const program = Effect.gen(function* () {
const electron = yield* Electron.Service
const surfaces = yield* BrowserSurface.Service
const handle = yield* surfaces.open(renderer, {
runtime: electron.browser,
url: "https://example.com",
width: "100%",
height: "100%",
})
handle.renderable.focus()
}).pipe(Effect.provide(BrowserSurface.layer), Effect.provide(Electron.layer()), Effect.scoped)
await Effect.runPromise(program)Electron.layer() scopes the Managed Browser Runtime. BrowserSurface.layer scopes renderer attachment,
Presentation ownership, and shutdown. The browser, page, and renderable APIs remain Promise-based.
Only a visible page with an active Presentation paints at the configured rate. Pages without a Presentation remain
alive but idle at 1 fps. The Browser Surface defaults to a 60 fps presentation ceiling; hosts can lower maxFps,
launch.frameRate, viewport area, or use everyNthFrame to trade smoothness for lower CPU and file traffic.
Producer-driven work stays bounded:
- the raw-frame ring retains one active write and only the latest pending frame;
- a Presentation keeps only the latest useful frame;
- Kitty serializes terminal writes and reconciles them to the latest desired image;
- pointer moves, wheel input, viewport changes, and placement changes use latest-state reconciliation.
At 60 fps, tightly packed 960×600 RGBA frames have an upper-bound raw rate of about 132 MiB/s; 1920×1080 is about 475 MiB/s. This is local frame traffic, not network traffic, and actual work depends on page paints and terminal size.
- Popup windows, dialogs, browser permission requests, webviews, and drag-and-drop navigation are disabled.
- The DevTools server is loopback-only, but any local process that attaches can inspect page cookies and storage.
- Offscreen pages use Electron sandboxing and context isolation with Node integration disabled.
- Raw-frame files live in a private runtime directory and are validated before Kitty consumes them.
- Keep
opentui-browserupdated so its pinned Electron runtime receives package updates.
The repository uses Bun for dependency management and scripts while keeping shared runtime modules usable from Bun and Node.js.
| Command | Purpose |
|---|---|
bun run build |
Build the package and Electron sidecar into dist/. |
bun run typecheck |
Run TypeScript without emitting files. |
bun test |
Run the test suite. |
bun run test:electron |
Build and run the live Electron smoke test. |
bun run check |
Run formatting, types, tests, and build. |
bun run test:packed |
Pack, install, and verify the published artifact surface. |
bun run bench |
Run deterministic hot-path benchmarks. |
bun run example |
Build and run the interactive browser example. |
Contributor-level domain language, ownership, and module boundaries live in
CONTEXT.md.
Run bun run check, bun run test:packed, and bun run test:electron before tagging. Publishing is tag-driven through
.github/workflows/publish.yml: push main, then push the matching v<version> tag. GitHub repeats check and
test:packed before publishing through npm trusted publishing.