import type { GhosttyTerminalController } from "@openclaw/libterminal/browser";
// Dockable operator terminal panel for the Control UI shell.
//
// Renders a VS Code-style shell dock (bottom by default, or right) with session
// tabs. Each tab hosts one libterminal Ghostty controller wired to a gateway PTY
// session. The browser runtime is dynamically imported on first open so it
// never weighs down the initial Control UI bundle.
import { LitElement, css, html, nothing, svg } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { terminalTheme } from "./terminal-theme.ts";
// Inline icon set (self-contained; the Control UI blocks external asset loads).
const TERMINAL_GLYPH = svg``;
const CLOSE_GLYPH = svg``;
const PLUS_GLYPH = svg``;
const DOCK_BOTTOM_GLYPH = svg``;
const DOCK_RIGHT_GLYPH = svg``;
type TerminalDock = "bottom" | "right";
type PanelLayout = {
open: boolean;
dock: TerminalDock;
height: number;
width: number;
};
type TerminalTabState = {
id: string;
gatewaySessionId: string;
/** Shell basename shown on the tab, e.g. "zsh". */
shellName: string;
/** Agent + cwd shown on hover. */
hint: string;
controller: GhosttyTerminalController;
host: HTMLDivElement;
status: "live" | "exited";
statusLabel?: string;
/**
* Set when the tab is closed while its terminal.open RPC is still in flight
* (gatewaySessionId is empty in that window, so closeTab cannot close the
* server session). The open continuation checks this and closes the freshly
* created session instead of wiring it to the disposed terminal.
*/
cancelled?: boolean;
};
/** Reduces a shell path to a tab label, e.g. "/bin/zsh" -> "zsh". */
function shellBasename(shell: string): string {
const base = shell.split(/[\\/]/).pop()?.trim();
return base && base.length > 0 ? base : "shell";
}
const LAYOUT_KEY = "openclaw.terminal.panel.v1";
// Session ids for reattach after a reload/reconnect. Deliberately
// sessionStorage, not localStorage: attach is take-over, and a shared
// per-origin key would make multiple Control UI windows clobber each other's
// ids and steal each other's live shells. Per-tab storage survives exactly the
// cases reattach is for (reload, laptop sleep, transient disconnect).
const SESSIONS_KEY = "openclaw.terminal.sessions.v1";
const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "bottom", height: 320, width: 520 };
const MIN_HEIGHT = 140;
const MIN_WIDTH = 320;
const TOGGLE_EVENT = "openclaw:terminal-toggle";
const TERMINAL_FONT_FAMILY =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Symbols Nerd Font Mono", "MesloLGLDZ Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Liberation Mono", monospace';
const TERMINAL_INPUT_DECODER = new TextDecoder();
const TERMINAL_OUTPUT_ENCODER = new TextEncoder();
function loadLayout(): PanelLayout {
try {
const raw = globalThis.localStorage?.getItem(LAYOUT_KEY);
if (!raw) {
return { ...DEFAULT_LAYOUT };
}
const parsed = JSON.parse(raw) as Partial;
return {
open: Boolean(parsed.open),
dock: parsed.dock === "right" ? "right" : "bottom",
height: clampSize(parsed.height, MIN_HEIGHT, maxPanelHeight(), DEFAULT_LAYOUT.height),
width: clampSize(parsed.width, MIN_WIDTH, maxPanelWidth(), DEFAULT_LAYOUT.width),
};
} catch {
return { ...DEFAULT_LAYOUT };
}
}
// A size persisted on a large desktop must not swallow a smaller window: cap
// the dock at 80% of the viewport so the header/resizer stay reachable and the
// shell content keeps a usable slice.
function maxPanelHeight(): number {
return Math.max(MIN_HEIGHT, Math.floor((globalThis.innerHeight || 800) * 0.8));
}
function maxPanelWidth(): number {
return Math.max(MIN_WIDTH, Math.floor((globalThis.innerWidth || 1280) * 0.8));
}
function clampSize(value: unknown, min: number, max: number, fallback: number): number {
const size =
typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
return Math.min(size, max);
}
function loadPersistedSessionIds(): string[] {
try {
const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return [];
}
}
/** `` — the dockable Control UI shell surface. */
export class OpenClawTerminalPanel extends LitElement {
/** Gateway client used for terminal.* RPCs; null until connected. */
@property({ attribute: false }) client: TerminalGatewayClient | null = null;
/** Agent whose workspace and sandbox policy own newly opened sessions. */
@property({ attribute: false }) agentId: string | null = null;
/** Whether the connected gateway advertises the terminal surface. */
@property({ type: Boolean }) available = false;
/** Active Control UI color mode, mirrored into the terminal theme. */
@property({ attribute: false }) themeMode: "dark" | "light" = "dark";
/**
* Terminal-only document mode (`?view=terminal`), used by the mobile apps'
* WebViews: fills the viewport, always open while available, no dock chrome.
*/
@property({ type: Boolean }) fullscreen = false;
@state() private open = false;
@state() private dock: TerminalDock = "bottom";
@state() private height = DEFAULT_LAYOUT.height;
@state() private width = DEFAULT_LAYOUT.width;
@state() private tabs: TerminalTabState[] = [];
@state() private activeId: string | null = null;
@state() private booting = false;
@state() private errorText: string | null = null;
private connection: TerminalConnection | null = null;
private tabSeq = 0;
private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event);
private readonly onToggleRequest = () => this.toggle();
// Re-clamp a dock sized on a larger window so the header/resizer never end
// up off-screen after the viewport shrinks (e.g. rotate, window resize).
private readonly onViewportResize = () => {
const height = Math.min(this.height, maxPanelHeight());
const width = Math.min(this.width, maxPanelWidth());
if (height === this.height && width === this.width) {
return;
}
this.height = height;
this.width = width;
this.syncLayoutReservation();
this.tabs.find((tab) => tab.id === this.activeId)?.controller.fit();
};
override connectedCallback(): void {
super.connectedCallback();
if (!this.fullscreen) {
const layout = loadLayout();
this.dock = layout.dock;
this.height = layout.height;
this.width = layout.width;
// Only restore the open state when the surface is actually available.
this.open = layout.open && this.available;
window.addEventListener("keydown", this.onGlobalKeyDown);
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
window.addEventListener("resize", this.onViewportResize);
} else {
// Fullscreen documents have no toggle/dock chrome; the panel is simply
// open whenever the terminal surface is available.
this.open = this.available;
}
if (this.open) {
void this.restoreSessions();
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("keydown", this.onGlobalKeyDown);
window.removeEventListener(TOGGLE_EVENT, this.onToggleRequest);
window.removeEventListener("resize", this.onViewportResize);
// Release the content-area reservation so the shell reflows to full size.
document.documentElement.style.setProperty("--oc-terminal-reserve-bottom", "0px");
document.documentElement.style.setProperty("--oc-terminal-reserve-right", "0px");
this.disposeAllTabs();
}
override updated(changed: Map): void {
if (changed.has("available")) {
if (!this.available) {
// The surface disappeared (gateway disconnect/disable). Tear down local
// tabs and the connection (disposeAllTabs drops the gateway
// subscription too). Server sessions survive a disconnect for the
// detach grace period, and their ids stay persisted, so the restore on
// reconnect reattaches them instead of opening fresh shells. Hide the
// panel WITHOUT persisting: a disconnect must not overwrite the user's
// open preference, or the reconnect path would never auto-reopen.
this.open = false;
this.disposeAllTabs();
} else if (!this.open && (this.fullscreen || loadLayout().open)) {
// Hello arrived after mount (or a reconnect); restore the persisted
// open state (fullscreen documents are always open while available)
// and reattach persisted sessions where possible.
this.open = true;
void this.restoreSessions();
}
}
if (changed.has("themeMode")) {
const theme = terminalTheme(this.themeMode);
for (const tab of this.tabs) {
// ghostty-web 0.4.0 ignores options.theme after open() (its option
// handler only warns), so update the renderer directly and force one
// full render — the frame loop repaints only dirty rows, which would
// leave a static screen on the old palette.
const term = tab.controller.terminal;
if (term.renderer && term.wasmTerm) {
term.renderer.setTheme(theme);
term.renderer.render(term.wasmTerm, true, term.viewportY, term);
}
}
}
// Hiding the panel returns `nothing`, which detaches each session's ghostty
// host. Re-attach live hosts whenever the viewport is rendered so a
// hide/show cycle keeps the terminals intact instead of blanking them.
if (this.open) {
const viewport = this.renderRoot.querySelector(".tp-viewport");
if (viewport) {
for (const tab of this.tabs) {
if (tab.host.parentElement !== viewport) {
viewport.append(tab.host);
}
}
this.tabs.find((tab) => tab.id === this.activeId)?.controller.fit();
}
}
this.syncLayoutReservation();
}
/**
* Publishes the dock's footprint as CSS variables on the document root so the
* Control UI shell reserves space for it (via `.content` margins) instead of
* letting the terminal overlay the chat. The panel itself stays fixed; the
* content simply shrinks to make room, so this reads as a real dock.
*/
private syncLayoutReservation(): void {
if (this.fullscreen) {
// No shell content to reserve space for in a terminal-only document.
return;
}
const root = document.documentElement.style;
const bottom =
this.available && this.open && this.dock === "bottom" ? `${this.height}px` : "0px";
const right = this.available && this.open && this.dock === "right" ? `${this.width}px` : "0px";
root.setProperty("--oc-terminal-reserve-bottom", bottom);
root.setProperty("--oc-terminal-reserve-right", right);
}
/** Opens the panel if closed, closes it if open. */
toggle(): void {
if (!this.available) {
return;
}
if (this.open) {
this.closePanel();
} else {
this.open = true;
this.persistLayout();
void this.restoreSessions();
}
}
private closePanel(): void {
this.open = false;
this.persistLayout();
}
private handleGlobalKey(event: KeyboardEvent): void {
// Ctrl+` toggles the terminal, matching common IDE shells.
if (event.ctrlKey && !event.metaKey && !event.altKey && event.code === "Backquote") {
event.preventDefault();
this.toggle();
}
}
/**
* Entry point whenever the panel (re)opens: reattach persisted sessions if
* the gateway still has them, otherwise fall back to one fresh session.
*/
private async restoreSessions(): Promise {
if (!this.client || !this.available || this.booting || this.tabs.length > 0) {
await this.ensureInitialSession();
return;
}
const persisted = loadPersistedSessionIds();
if (persisted.length > 0) {
this.booting = true;
try {
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
const listed = await this.connection.list();
const known = new Set(listed.map((session) => session.sessionId));
for (const sessionId of persisted.filter((id) => known.has(id))) {
await this.attachSession(sessionId);
}
} catch {
// terminal.list failed (older gateway, surface flapping): fall through
// to a fresh session below.
} finally {
this.booting = false;
}
// Prune ids the gateway no longer knows (reaped or externally closed).
this.persistLiveSessions();
}
await this.ensureInitialSession();
}
private async ensureInitialSession(): Promise {
if (this.tabs.length === 0 && !this.booting) {
await this.openSession();
}
}
/** Boots a tab with a libterminal controller, ready for an open or attach RPC. */
private async bootTab(): Promise<{
tab: TerminalTabState;
connection: TerminalConnection;
cols: number;
rows: number;
}> {
if (!this.client) {
throw new Error("terminal client unavailable");
}
const { createGhosttyTerminal } = await import("@openclaw/libterminal/browser");
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
// Captured so the cancelled-open cleanup can close the session even if a
// teardown swaps this.connection while the open/attach RPC is in flight.
const connection = this.connection;
const host = document.createElement("div");
host.className = "tp-host";
const id = `tab-${++this.tabSeq}`;
// Wait for the panel (and its .tp-viewport) to render before attaching the
// ghostty host, so the terminal opens into a laid-out, measurable node.
await this.updateComplete;
const viewport = this.renderRoot.querySelector(".tp-viewport");
if (!viewport) {
throw new Error("terminal viewport unavailable");
}
viewport.append(host);
const tabRef = { current: undefined as TerminalTabState | undefined };
let controller: GhosttyTerminalController;
try {
controller = await createGhosttyTerminal({
parent: host,
readOnly: false,
terminalOptions: {
fontSize: 13,
fontFamily: TERMINAL_FONT_FAMILY,
cursorBlink: true,
theme: terminalTheme(this.themeMode),
scrollback: 5000,
},
// The browser controller owns these subscriptions and their teardown.
// Ignore startup callbacks until the Gateway session is adopted.
onData: (bytes) => {
const sessionId = tabRef.current?.gatewaySessionId;
if (sessionId) {
void connection.input(sessionId, TERMINAL_INPUT_DECODER.decode(bytes));
}
},
onResize: ({ columns, rows }) => {
const sessionId = tabRef.current?.gatewaySessionId;
if (sessionId) {
void connection.resize(sessionId, columns, rows);
}
},
});
} catch (error) {
host.remove();
throw error;
}
const tab: TerminalTabState = {
id,
gatewaySessionId: "",
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
hint: "",
controller,
host,
status: "live",
};
tabRef.current = tab;
this.tabs = [...this.tabs, tab];
this.activeId = id;
const { terminal } = controller;
return { tab, connection, cols: terminal.cols || 80, rows: terminal.rows || 24 };
}
/** Output/exit sink for one tab, shared by open and attach. */
private tabSink(tab: TerminalTabState) {
return {
// The cancelled guard also protects the buffered-event replay inside
// connection.open/attach from writing to an already-disposed terminal.
onData: (data: string) => {
if (!tab.cancelled) {
tab.controller.write(TERMINAL_OUTPUT_ENCODER.encode(data));
}
},
onExit: (info: { reason?: string; exitCode: number | null }) => this.handleExit(tab.id, info),
};
}
/** Binds a freshly opened or attached gateway session to its tab. */
private adoptSession(
tab: TerminalTabState,
result: { sessionId: string; shell: string; agentId: string; cwd: string },
): void {
tab.gatewaySessionId = result.sessionId;
tab.shellName = shellBasename(result.shell);
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
// Libterminal observes layout before the Gateway session exists. Resync the
// current grid now so a resize during the open/attach RPC is not lost.
const { cols, rows } = tab.controller.terminal;
void this.connection?.resize(result.sessionId, cols || 80, rows || 24);
this.tabs = [...this.tabs];
this.persistLiveSessions();
}
/** Removes a tab whose open/attach never produced a server session. */
private dropFailedTab(tab: TerminalTabState): void {
this.disposeTab(tab);
this.tabs = this.tabs.filter((entry) => entry.id !== tab.id);
if (this.activeId === tab.id) {
this.activeId = this.tabs.at(-1)?.id ?? null;
}
}
private async openSession(): Promise {
if (!this.client || !this.available || this.booting) {
return;
}
this.booting = true;
this.errorText = null;
// Freeze the selection for this tab; later agent changes affect only new tabs.
const agentId = this.agentId?.trim() || undefined;
// Tracked outside the try so the catch can dispose a tab whose open failed.
let createdTab: TerminalTabState | undefined;
try {
const boot = await this.bootTab();
createdTab = boot.tab;
const result = await boot.connection.open(
{ agentId, cols: boot.cols, rows: boot.rows },
this.tabSink(boot.tab),
);
if (boot.tab.cancelled) {
// The tab's close button was clicked while the open RPC was in flight.
// The server session is live and its sink registered; close it now or
// it survives invisibly (eating the session cap) until disconnect.
void boot.connection.close(result.sessionId);
return;
}
this.adoptSession(boot.tab, result);
boot.tab.controller.terminal.focus();
} catch (err) {
this.errorText = err instanceof Error ? err.message : String(err);
// A failed open (e.g. terminal disabled or a sandboxed agent is refused)
// must not leave a phantom "live" tab with no server session. Drop it but
// keep the panel open so the error stays visible.
if (createdTab && !createdTab.gatewaySessionId) {
this.dropFailedTab(createdTab);
}
} finally {
this.booting = false;
}
}
/** Reattaches one persisted session; returns false when it is gone. */
private async attachSession(sessionId: string): Promise {
let createdTab: TerminalTabState | undefined;
try {
const boot = await this.bootTab();
createdTab = boot.tab;
const result = await boot.connection.attach(sessionId, this.tabSink(boot.tab));
if (boot.tab.cancelled) {
void boot.connection.close(result.sessionId);
return false;
}
this.adoptSession(boot.tab, result);
return true;
} catch {
// Session expired between list and attach (reaper race) or an older
// gateway: quietly drop the placeholder tab; restore falls back to a
// fresh session when nothing could be reattached.
if (createdTab && !createdTab.gatewaySessionId) {
this.dropFailedTab(createdTab);
}
return false;
}
}
private handleExit(tabId: string, info: { reason?: string; exitCode: number | null }): void {
const tab = this.tabs.find((entry) => entry.id === tabId);
if (!tab) {
return;
}
tab.status = "exited";
if (info.reason === "detached") {
// Another connection attached this session away; it is alive elsewhere.
tab.statusLabel = t("terminal.detached");
} else {
tab.statusLabel =
info.reason === "process_exit" && info.exitCode !== null
? t("terminal.exitedCode", { code: String(info.exitCode) })
: t("terminal.exited");
}
// The connection drops its own sink on exit delivery, so no release() here —
// the session id may not be recorded yet when an early exit is replayed.
this.tabs = [...this.tabs];
this.persistLiveSessions();
}
private closeTab(tabId: string): void {
const tab = this.tabs.find((entry) => entry.id === tabId);
if (!tab) {
return;
}
if (tab.gatewaySessionId && tab.status === "live") {
void this.connection?.close(tab.gatewaySessionId);
} else if (!tab.gatewaySessionId && tab.status === "live") {
// Open still in flight: no session id to close yet. Flag it so the open
// continuation closes the server session as soon as the RPC resolves.
tab.cancelled = true;
}
this.disposeTab(tab);
this.tabs = this.tabs.filter((entry) => entry.id !== tabId);
if (this.activeId === tabId) {
this.activeId = this.tabs.at(-1)?.id ?? null;
}
this.persistLiveSessions();
// Fullscreen documents (mobile WebViews) have no toggle to reopen a closed
// panel, so closing the last tab keeps the panel with an empty tab strip
// (the "+" button stays reachable) instead of leaving a dead blank page.
if (this.tabs.length === 0 && !this.fullscreen) {
this.closePanel();
}
}
private switchTo(tabId: string): void {
this.activeId = tabId;
const tab = this.tabs.find((entry) => entry.id === tabId);
// Refit after the container becomes visible so cols/rows match the viewport.
void this.updateComplete.then(() => {
tab?.controller.fit();
tab?.controller.terminal.focus();
});
}
private disposeTab(tab: TerminalTabState): void {
try {
tab.controller.dispose();
tab.host.remove();
} catch {
// Best-effort teardown; a partially-initialized tab may throw.
}
}
private disposeAllTabs(): void {
for (const tab of this.tabs) {
// No terminal.close here: this teardown runs for disconnects,
// availability loss, and element removal — exactly the sessions the
// persisted-id reattach flow recovers afterwards. Deliberate closes go
// through closeTab(); sessions nobody reattaches are bounded by the
// server's detach reaper.
// The cancelled flag covers a tab whose open RPC is still in flight; its
// continuation closes the fresh session instead of adopting the
// disposed terminal.
tab.cancelled = true;
this.disposeTab(tab);
}
this.tabs = [];
this.activeId = null;
// Drop the gateway subscription with the tabs so the listener never outlives
// the connection (disconnect/disable/element-removal all route through here).
this.connection?.dispose();
this.connection = null;
}
private setDock(dock: TerminalDock): void {
this.dock = dock;
this.persistLayout();
void this.updateComplete.then(() => {
for (const tab of this.tabs) {
tab.controller.fit();
}
});
}
/**
* Records which gateway sessions this window's live tabs own so a reload or
* reconnect can reattach them. Intentionally NOT cleared on disconnect
* teardown (disposeAllTabs) — surviving ids are the reattach memory.
*/
private persistLiveSessions(): void {
const ids = this.tabs
.filter((tab) => tab.status === "live" && tab.gatewaySessionId)
.map((tab) => tab.gatewaySessionId);
try {
globalThis.sessionStorage?.setItem(SESSIONS_KEY, JSON.stringify(ids));
} catch {
// Storage may be unavailable (private mode); reattach just won't work.
}
}
private persistLayout(): void {
try {
const layout: PanelLayout = {
open: this.open,
dock: this.dock,
height: this.height,
width: this.width,
};
globalThis.localStorage?.setItem(LAYOUT_KEY, JSON.stringify(layout));
} catch {
// Storage may be unavailable (private mode); layout just won't persist.
}
}
private startResize(event: PointerEvent): void {
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startHeight = this.height;
const startWidth = this.width;
const onMove = (move: PointerEvent) => {
if (this.dock === "bottom") {
const next = Math.max(MIN_HEIGHT, startHeight + (startY - move.clientY));
this.height = Math.min(next, maxPanelHeight());
} else {
const next = Math.max(MIN_WIDTH, startWidth + (startX - move.clientX));
this.width = Math.min(next, maxPanelWidth());
}
// Reflow the content reservation live so the shell tracks the drag.
this.syncLayoutReservation();
const active = this.tabs.find((tab) => tab.id === this.activeId);
active?.controller.fit();
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
this.persistLayout();
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}
override render() {
if (!this.available || !this.open) {
return nothing;
}
const mode = this.fullscreen ? "fullscreen" : this.dock;
const style = this.fullscreen
? nothing
: this.dock === "bottom"
? `height:${this.height}px`
: `width:${this.width}px`;
return html`
${this.fullscreen
? nothing
: html`