Files
adolf/ui/src/components/terminal/terminal-panel.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

986 lines
35 KiB
TypeScript

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`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4l3 3-3 3M8 11h5" /></svg>`;
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></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<PanelLayout>;
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 [];
}
}
/** `<openclaw-terminal-panel>` — 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<string, unknown>): 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<void> {
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<void> {
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<void> {
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<boolean> {
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`
<section class="tp tp--${mode}" style=${style} aria-label=${t("terminal.title")}>
${this.fullscreen
? nothing
: html`<div
class="tp-resizer tp-resizer--${this.dock}"
@pointerdown=${(e: PointerEvent) => this.startResize(e)}
role="separator"
aria-label=${t("terminal.resize")}
></div>`}
<header class="tp-header">
<div class="tp-tabs" role="tablist">
${this.tabs.map(
(tab) => html`
<div
class="tp-tab ${tab.id === this.activeId ? "is-active" : ""} ${tab.status ===
"exited"
? "is-exited"
: ""}"
role="tab"
title=${tab.hint || nothing}
aria-selected=${tab.id === this.activeId ? "true" : "false"}
@click=${() => this.switchTo(tab.id)}
>
<span class="tp-tab__icon" aria-hidden="true">${TERMINAL_GLYPH}</span>
<span class="tp-tab__label">${tab.shellName}</span>
${tab.statusLabel
? html`<span class="tp-tab__status">${tab.statusLabel}</span>`
: nothing}
<button
class="tp-tab__close"
type="button"
title=${t("terminal.closeSession")}
aria-label=${t("terminal.closeSession")}
@click=${(e: Event) => {
e.stopPropagation();
this.closeTab(tab.id);
}}
>
${CLOSE_GLYPH}
</button>
</div>
`,
)}
<button
class="tp-new"
type="button"
?disabled=${this.booting}
title=${t("terminal.newSession")}
aria-label=${t("terminal.newSession")}
@click=${() => void this.openSession()}
>
${PLUS_GLYPH}
</button>
</div>
${this.fullscreen
? nothing
: html`<div class="tp-actions">
<button
class="tp-icon ${this.dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("terminal.dockBottom")}
aria-label=${t("terminal.dockBottom")}
@click=${() => this.setDock("bottom")}
>
${DOCK_BOTTOM_GLYPH}
</button>
<button
class="tp-icon ${this.dock === "right" ? "is-active" : ""}"
type="button"
title=${t("terminal.dockRight")}
aria-label=${t("terminal.dockRight")}
@click=${() => this.setDock("right")}
>
${DOCK_RIGHT_GLYPH}
</button>
<button
class="tp-icon"
type="button"
title=${t("terminal.hide")}
aria-label=${t("terminal.hide")}
@click=${() => this.closePanel()}
>
${CLOSE_GLYPH}
</button>
</div>`}
</header>
${this.errorText
? html`<div class="tp-error" role="alert">${this.errorText}</div>`
: nothing}
<div class="tp-viewport">
${this.booting && this.tabs.length === 0
? html`<div class="tp-empty">${t("terminal.starting")}</div>`
: nothing}
</div>
</section>
`;
}
override willUpdate(): void {
// Keep only the active session's host visible; ghostty renders to a canvas
// that must be laid out to measure correctly.
for (const tab of this.tabs) {
tab.host.style.display = tab.id === this.activeId ? "block" : "none";
}
}
static override styles = css`
:host {
position: fixed;
z-index: 60;
color: var(--text, #d7dae0);
font-family: var(--font-sans, system-ui, sans-serif);
}
.tp {
position: fixed;
display: flex;
flex-direction: column;
background: var(--bg, #0e1015);
overflow: hidden;
}
/* A docked panel needs only a single hairline separator on its inner edge —
no shadow, so it reads as part of the layout rather than a floating card. */
.tp--bottom {
left: var(--shell-nav-width, 0);
right: 0;
bottom: 0;
border-top: 1px solid var(--border, #262b34);
}
.tp--right {
top: var(--shell-topbar-height, 0);
right: 0;
bottom: 0;
border-left: 1px solid var(--border, #262b34);
}
/* Terminal-only document (mobile WebViews): fill the viewport, no seams. */
.tp--fullscreen {
inset: 0;
}
.tp-resizer {
position: absolute;
z-index: 2;
background: transparent;
}
.tp-resizer:hover {
background: var(--accent, #ff5c5c);
opacity: 0.5;
}
.tp-resizer--bottom {
top: 0;
left: 0;
right: 0;
height: 5px;
cursor: ns-resize;
}
.tp-resizer--right {
top: 0;
bottom: 0;
left: 0;
width: 5px;
cursor: ew-resize;
}
.tp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 6px 0 4px;
border-bottom: 1px solid var(--border, #262b34);
background: var(--bg, #0e1015);
min-height: 36px;
}
.tp-tabs {
display: flex;
align-items: stretch;
gap: 1px;
overflow-x: auto;
scrollbar-width: none;
}
.tp-tabs::-webkit-scrollbar {
display: none;
}
.tp-tab {
display: flex;
align-items: center;
gap: 7px;
padding: 0 10px;
height: 36px;
cursor: pointer;
color: var(--muted, #8a919e);
white-space: nowrap;
font-size: 12.5px;
/* Reserve the active underline height so tabs don't shift on selection. */
border-bottom: 2px solid transparent;
transition:
color 0.12s ease,
background 0.12s ease;
}
.tp-tab:hover {
color: var(--text, #d7dae0);
background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent);
}
.tp-tab.is-active {
color: var(--text, #d7dae0);
border-bottom-color: var(--accent, #ff5c5c);
}
.tp-tab.is-exited {
opacity: 0.55;
}
.tp-tab__icon {
display: inline-flex;
color: var(--accent, #4ec9a8);
}
.tp-tab.is-exited .tp-tab__icon {
color: var(--muted, #8a919e);
}
.tp-tab__label {
font-variant-numeric: tabular-nums;
}
.tp-tab__status {
font-size: 11px;
color: var(--muted, #8a919e);
}
.tp-tab__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
opacity: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.tp-tab:hover .tp-tab__close,
.tp-tab.is-active .tp-tab__close {
opacity: 0.7;
}
.tp-new,
.tp-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--muted, #8a919e);
cursor: pointer;
border-radius: 6px;
padding: 0;
}
.tp-tab__close:hover,
.tp-new:hover,
.tp-icon:hover {
background: color-mix(in srgb, var(--text, #d7dae0) 12%, transparent);
color: var(--text, #d7dae0);
}
.tp-icon.is-active {
color: var(--text, #d7dae0);
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
}
.tp-actions {
display: flex;
align-items: center;
gap: 2px;
padding-left: 6px;
}
.tp-viewport {
position: relative;
flex: 1;
min-height: 0;
background: var(--bg, #0e1015);
}
.tp-host {
position: absolute;
inset: 0;
padding: 6px 8px;
/* ghostty-web focuses this contenteditable host while drawing its own
cursor on canvas; hide the otherwise duplicated browser caret. */
caret-color: transparent;
}
.tp-empty,
.tp-error {
padding: 10px 12px;
font-size: 12px;
color: var(--muted, #8a919e);
}
.tp-error {
color: var(--danger, #ff6b6b);
}
`;
}
// Guarded define (not @customElement) so re-imports under a shared registry —
// e.g. vitest with isolate=false — don't throw "already registered".
if (!customElements.get("openclaw-terminal-panel")) {
customElements.define("openclaw-terminal-panel", OpenClawTerminalPanel);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-terminal-panel": OpenClawTerminalPanel;
}
}