Skip to content

Repository files navigation

opentui-browser

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.

What it provides

  • A focusable BrowserRenderable with 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

Requirements

  • Bun 1.3 or newer, or Node.js 26.3 or newer.
  • @opentui/core 0.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.

Install

With Bun:

bun add opentui-browser @opentui/core

With npm:

npm install opentui-browser @opentui/core

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

Quick start

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 example

Set OPENTUI_BROWSER_URL to change its initial page. Set OPENTUI_BROWSER_PROFILE_DIR to use a dedicated persistent Electron profile.

Configuration

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.

Runtime and ownership

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.

DevTools and agents

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 composition

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.

Performance model

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.

Limitations and security

  • 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-browser updated so its pinned Electron runtime receives package updates.

Development

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.

Release

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.

About

A Chromium browser surface for OpenTUI using the Kitty graphics protocol

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages