@myrialabs/ptykit/client — the framework-agnostic browser client.
Don't want to wire xterm.js by hand? mountTerminal is the framework-agnostic
counterpart to <PtyTerminal/>: hand it a container and a url
and it creates the xterm Terminal, attaches a FitAddon, opens the session,
and wires output⇄input — while staying fully configurable.
import { mountTerminal } from '@myrialabs/ptykit/client';
const handle = await mountTerminal(document.getElementById('screen')!, {
url: '/pty',
namespace: 'project-123',
sessionId: 'project-123-terminal-1',
create: true, // attach instead when false (the default)
// appearance — all optional
fontSize: 13,
cursorStyle: 'bar',
theme: { background: '#0f172a', foreground: '#e2e8f0' },
// callbacks — all optional
onStatus: (s) => render(s), // 'connected' | 'reconnecting' | 'disconnected'
onExit: (code) => { /* … */ },
onError: (err) => { /* surfaced instead of a silently blank terminal */ },
});
// Escape hatch: the underlying instances are on the returned handle.
handle.terminal.focus();
handle.fitAddon?.fit();
handle.session.write('ls\r');
handle.dispose(); // detach, dispose the terminal, disconnectclient?— pass an existingPtyKitClientto share one socket across several terminals (e.g. a tabbed UI). When you pass one,dispose()leaves it connected; whenmountTerminalcreates its own,dispose()disconnects it.fit: falseskips theFitAddon+ResizeObserver(thenhandle.fitAddonisundefined).- Batteries included: the
clipboard,webLinks,unicode11, andligaturesaddons load by default (each an optional peer dep — a missing one is skipped silently). Passfalseto any of them to opt out. A right-click copy/pastecontextMenuis also on by default; passcontextMenu: falseto handle it yourself. addons— construct extra xterm addons yourself and pass the instances; they load after the built-ins. UseonReady(terminal)for post-load activation or to install custom key handlers.- Reattach fidelity:
mountTerminalfits to the container before attaching, so the create/attach request carries the real viewport size. The server resizes the session (and its scrollback) to match before replaying — the replayed frame therefore lines up with the viewport, and full-screen TUIs (vim, htop, codex/claude/opencode) restore cleanly instead of garbled. See the server note. - Loading state: a spinner overlay shows in
targetwhile xterm loads and the session attaches, so the container is never blank; it is removed on ready (and on error). Disable withloading: false, or label it withloadingText. - Styling: import
@myrialabs/ptykit/xterm.cssonce (the Svelte component does this for you). It re-exports xterm's base styles plus chrome defaults — the terminal fills its container and gets a slim, theme-neutral scrollbar. Background and padding are left to you. WebSocketImpl— passhostSocket(...)to run the internal client over a socket your app already owns instead of opening a new one (see Embedded).- xterm and the FitAddon are imported dynamically, so a non-browser/headless
consumer of
@myrialabs/ptykit/clientnever pulls them in, and the call is SSR-safe. - Resolves once the session is open; rejects (and calls
onError) if attach/create fails — so a backend/connection failure surfaces rather than leaving a blank terminal.
Reach for the lower-level PtyKitClient below when you need full control over the
terminal lifecycle (e.g. custom addons, multiple terminals per session).
You don't have to hand-write an 18-key ANSI palette. theme accepts a built-in
preset name, a full xterm ITheme object, or nothing (defaults to the dark
preset — a legible terminal out of the box). Built-in presets:
dark · light · solarized-dark · solarized-light · dracula · nord · matrix
import { mountTerminal, draculaTheme } from '@myrialabs/ptykit/client';
await mountTerminal(el, { url: '/pty', namespace, theme: 'solarized-dark' }); // preset by name
await mountTerminal(el, { url: '/pty', namespace, theme: { ...draculaTheme, background: '#000' } }); // tweak a presetSwitch at runtime with the handle — e.g. to follow the OS colour scheme:
const handle = await mountTerminal(el, { url: '/pty', namespace });
matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) =>
handle.setTheme(e.matches ? 'light' : 'dark'),
);Each preset is exported (darkTheme, solarizedDarkTheme, draculaTheme, …)
along with the themes registry and resolveTheme, so you can import one and
extend it. mountViewer and <PtyTerminal> take the same theme option (the
component's theme prop is reactive — assign a new value and the live terminal
re-themes).
Need a terminal just to display streamed text — build logs, install progress,
debug traces — with no shell, no PTY, and no user input? mountViewer is the
output-only sibling of mountTerminal: same appearance/addon options and the
same lazy, SSR-safe xterm loading, but instead of a session it returns a
write/clear/fit/dispose handle. No new Terminal(), loadAddon,
open, or ResizeObserver to wire yourself.
import { mountViewer } from '@myrialabs/ptykit/client';
const view = await mountViewer(document.getElementById('log')!, {
fontSize: 12,
theme: 'dark', // preset name, full ITheme, or omit (defaults to 'dark')
webLinks: true, // clipboard / webLinks / unicode11 / ligatures — opt-in here
// stdin: false is the default — a viewer ignores keystrokes
// convertEol defaults to true so bare "\n" log output doesn't stair-step
});
view.setTheme('light'); // swap at runtime
socket.onLog((chunk) => view.write(chunk)); // push output
view.clear(); // clear + reset
view.dispose(); // tear down + stop observing resizeview.terminal / view.fitAddon are exposed for advanced use (selection,
reset(), etc.).
One WebSocket multiplexes every session in a namespace.
import { PtyKitClient } from '@myrialabs/ptykit/client';
const client = new PtyKitClient({
url: '/api/pty',
namespace: 'project-123', // default namespace for create/attach
reconnect: { enabled: true, baseDelayMs: 1000, maxDelayMs: 30_000, maxAttempts: 5 },
// persistence: { load, save }, // override the default sessionStorage scoping
});
client.onStatus((s) => render(s)); // 'connected' | 'reconnecting' | 'disconnected'
// Attach to an existing session (replays serialized scrollback), or create one.
const session = await client.attach('project-123-terminal-1');
// const session = await client.create({ cols: 80, rows: 24 });
session.onData((chunk) => term.write(chunk)); // deduped by seq; replay stripped
session.onExit((code) => { /* … */ });
session.write(input); // raw keystroke pass-through
await session.resize(cols, rows);
await session.clear();
await session.cancel(); // Ctrl+C
await session.kill(); // kill the server-side session
session.detach(); // stop locally; server session lives onBeyond a single session's output, the client surfaces room-level session lifecycle so a UI can mirror sessions opened by other clients in the same namespace (e.g. a teammate's new terminal). "Session", not "tab" — how you render it (tabs, a sidebar, split panes) is entirely up to you.
const offCreated = client.onSessionCreated((e) => {
// e: { sessionId, namespace, streamId, pid, currentDirectory, cols, rows }
showSessionIfMissing(e.sessionId); // fires for every (re)attach — dedupe by id
});
const offClosed = client.onSessionClosed((e) => {
// e: { sessionId, namespace }
removeSession(e.sessionId);
});These are not filtered by the local session map — that is the point: they let
a client learn about sessions it did not open. They fire for any room the client
has joined (a room is joined when you create/attach a session in that
namespace). Treat onSessionCreated as idempotent: it also fires for your own
sessions and again on each reconnect re-attach, so dedupe by sessionId.
- Reconnect ON by default; exponential backoff 1s → 30s, max 5 attempts.
- Heal-reconnect: a stalled idempotent request forces one reconnect + retry before failing (recovers "open but dead" sockets).
- Idempotency-aware resend: reads (and any request never delivered) resend on reconnect; other mutations only if not yet sent.
- On reconnect every known session is re-attached (idempotent
create-session) so the room subscription and scrollback recover.
Defaults to sessionStorage scoped per namespace (in-memory fallback off the
browser). Override with persistence: { load, save } when the caller owns the
authoritative tab list (e.g. a server DB). create() without a sessionId
generates and persists one; attach() without one loads the persisted id.
The reattach replay frame is unicast during attach(), before your code calls
onData. The session buffers that output (bounded) and flushes it to the first
onData subscriber, so reattach never drops the replayed screen.
PtyKit is WebSocket-only and normally opens its own socket. If your app already
runs a single multiplexed WebSocket, adapt it with hostSocket(...) and pass it
as WebSocketImpl — PtyKit keeps its reconnect/heal/idempotent-resend while your
socket stays the only connection. The wire protocol is unchanged; only socket
ownership moves to the host. Pair this with the server's
embedded transport.
import { PtyKitClient, hostSocket } from '@myrialabs/ptykit/client';
const client = new PtyKitClient({
namespace: 'project-123', // `url` is optional / ignored here
WebSocketImpl: hostSocket({
send: (frame) => appWs.emit('pty:frame', frame), // one client→server frame
subscribe: (onFrame) => appWs.on('pty:frame', onFrame), // server→client frames; returns unsub
isOpen: () => appWs.status === 'connected',
onStatusChange: (cb) => appWs.onStatus((s) => cb(s === 'connected')),
}),
});hostSocket fires onclose only when an established link actually drops; while
merely waiting for the host to come up it stays pending, so reconnect attempts are
not burned — PtyKit re-attaches every session the moment the host reconnects.
import { attachFit } from '@myrialabs/ptykit/client';
const dispose = attachFit(session, term, fitAddon, { debounceMs: 100 });Observes the terminal's container, fits on resize (debounced), skips redundant resizes, and forwards new dimensions to the server. Returns a disposer.
Replayed frames (delivered without a seq) are passed through
stripReportRequests so the terminal does not answer color/cursor/DA queries
into an idle prompt. Live output (with a seq) is delivered verbatim and deduped.