@relictombs/opentui-browser
v0.2.0
Published
A fast Electron browser surface for OpenTUI using the Kitty graphics protocol
Maintainers
Readme
@relictombs/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
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.
- Effect-native public APIs, typed failures, and scoped services.
There is one rendering path and no system-Chrome or screenshot fallback:
hidden Electron BrowserWindow -> authenticated binary RGBA socket -> in-memory Kitty transmit -> terminal image placementRequirements
- Node.js 24 or newer with npm. Bun is not required by consumers and is used only by repository contributors.
@opentui/core0.5.1 (supported range:>=0.5.1 <0.6.0).- A local terminal with Kitty graphics support. Ghostty is the primary tested terminal.
- OpenTUI's
"alternate-screen"mode and a direct local renderer.
The public BrowserRenderable path requires Electron and the renderer on one machine. Terminal multiplexers such as
tmux or screen and remote terminal attachment are outside this direct image transport's supported baseline. Node
applications that create an OpenTUI renderer must start Node with --experimental-ffi.
This package's public BrowserRenderable quick start is the local library path: Electron and the terminal run on the
same machine, with no FFmpeg or network transport. For the complete OpenCode setup, including a browser running on a
remote server, use npx --yes @relictombs/[email protected] and choose browser or both surfaces. The private remote adapter is
deliberately not a second backend inside @relictombs/opentui-browser.
Install
npm install @relictombs/[email protected] @opentui/coreContributor checkouts use Bun for workspace dependencies and scripts.
Kitty transport, Presentation coordination, and terminal readiness are private bundled implementation packages. They do not need to be installed or published separately.
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 { Deferred, Effect } from "effect"
import { BrowserRenderable } from "@relictombs/opentui-browser"
const program = Effect.gen(function* () {
const quit = yield* Deferred.make<void>()
const renderer = yield* Effect.tryPromise(() =>
createCliRenderer({
screenMode: "alternate-screen",
exitOnCtrlC: true,
onDestroy: () => Effect.runFork(Deferred.succeed(quit, undefined)),
}),
)
const browser = new BrowserRenderable(renderer, {
url: "https://example.com",
width: "100%",
height: "100%",
border: true,
})
renderer.root.add(browser)
yield* Effect.acquireUseRelease(
Effect.void,
() =>
browser.start().pipe(Effect.andThen(Effect.sync(() => browser.focus())), Effect.andThen(Deferred.await(quit))),
() => browser.close().pipe(Effect.ignore, Effect.ensuring(Effect.sync(() => renderer.destroy()))),
)
})
await Effect.runPromise(program)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 exampleThe example opens example.com. 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, binary media connection, DevTools
server, browser storage, and pages:
import { ElectronBrowser } from "@relictombs/opentui-browser"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const runtime = yield* ElectronBrowser.launch({
userDataDirectory: "/absolute/path/to/dedicated-profile",
})
const page = yield* runtime.newPage({ url: "https://example.com" })
yield* Effect.logInfo("browser page ready", page.url)
}).pipe(Effect.scoped)
await Effect.runPromise(program)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 inspectTarget = Effect.gen(function* () {
const runtime = yield* ElectronBrowser.launch()
const page = yield* runtime.newPage({ url: "https://example.com" })
yield* Effect.logInfo("DevTools attachment", {
page: page.devToolsTarget,
browser: runtime.webSocketEndpoint,
})
}).pipe(Effect.scoped)
await Effect.runPromise(inspectTarget)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
@relictombs/opencode-browser.
Effect composition
Scoped services are available from either the root package or the curated @relictombs/opentui-browser/effect entrypoint:
import { Effect } from "effect"
import { BrowserSurface, Electron } from "@relictombs/opentui-browser/effect"
const program = Effect.gen(function* () {
const electron = yield* Electron.Service
const surfaces = yield* BrowserSurface.Service
const page = yield* electron.newPage({ url: "https://example.com" })
const handle = yield* surfaces.open(renderer, {
page: page.page,
width: "100%",
height: "100%",
})
yield* handle.goto("https://effect.website")
const state = yield* handle.snapshot()
yield* Effect.logInfo("browser ready", state)
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 and every page opened through its Effect handle.
BrowserSurface.layer scopes renderer attachment, Presentation ownership, and shutdown. Effect handles expose typed
navigation, input, settling, and snapshot operations. Root browser, page, and renderable operations are Effects too;
applications run them at their runtime boundary.
BrowserSurface.layer also provides the shared @relictombs/kitty/effect service, so its default graphics path uses
schema-decoded protocol operations and scoped placement cleanup. Synchronous OpenTUI callbacks admit work into the
same Effect coordinator. Advanced hosts can provide their own Kitty service to BrowserSurface.layerFromKitty.
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 memory traffic.
Producer-driven work stays bounded:
- the media sender retains one frame awaiting acknowledgement 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.
The local Browser path uses raw Kitty direct data because generic Kitty graphics detection does not prove that a terminal safely supports compressed direct data. Large frames yield between bounded terminal writes so input and other host work can run between output batches.
At 60 fps, tightly packed 960×600 RGBA capture has an upper-bound raw rate of about 132 MiB/s; 1920×1080 is about 475 MiB/s. Remote transport applies H.264 after capture instead, 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 frames never enter the filesystem. A separate one-time-token-authenticated loopback socket has strict bounded framing and is closed with the managed runtime.
- Keep
@relictombs/opentui-browserupdated 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. Run the commands below from packages/opentui/browser in a repository checkout.
| 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
@relictombs/opentui-browser@<version> tag. GitHub repeats check and test:packed before publishing through npm trusted
publishing.
