mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): adopt shared libterminal browser runtime (#100128)
This commit is contained in:
committed by
GitHub
parent
7bb686c140
commit
1437f79d0c
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Control UI terminal rendering:** adopt the shared `@openclaw/libterminal` browser lifecycle and add Nerd Font fallbacks so icon-enabled shell listings render their glyphs when a compatible local font is installed.
|
||||
- **ClawRouter auth profiles:** resolve credential-scoped catalog models during agent runs when the proxy key is stored in an auth profile, and document plugin and model allowlists.
|
||||
- **Telegram durability:** recover stalled ingress claims, retry restart-dropped media, survive transient polling errors, dead-letter poison updates, preserve forwarded rich text, route plugin callbacks correctly, keep progress updates in one stable multi-line window, map self-hosted Bot API container paths through trusted host roots, and fall back safely when Telegram rejects rich final replies. (#97118, #98102, #98735, #98775, #98776, #97174, #98907, #91984, #98786) Thanks @vincentkoc, @luoyanglang, @DaveArcher18, @obviyus, @goldmar, @Marvinthebored, @Dizesales, and @shakkernerd.
|
||||
- **Cross-channel inbound media:** preserve captions and expose unavailable-attachment notices when WhatsApp, LINE, Signal, iMessage, Microsoft Teams, Feishu, Mattermost, or Zalo cannot materialize inbound media, instead of dispatching phantom placeholders or dropping media-only turns. (#100092)
|
||||
|
||||
Generated
+11
-3
@@ -1936,6 +1936,9 @@ importers:
|
||||
'@noble/ed25519':
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
'@openclaw/libterminal':
|
||||
specifier: 0.3.1
|
||||
version: 0.3.1
|
||||
'@openclaw/media-core':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/media-core
|
||||
@@ -1948,9 +1951,6 @@ importers:
|
||||
dompurify:
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
ghostty-web:
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
highlight.js:
|
||||
specifier: 11.11.1
|
||||
version: 11.11.1
|
||||
@@ -3146,6 +3146,10 @@ packages:
|
||||
resolution: {integrity: sha512-hQi+BxO10KdRFlYUot1syC+hTaUnGeQNdqX5kwkKJig8CFq1tKsYJLPm+zkiiGsSKOprPAquQl/txejEhpKPgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@openclaw/libterminal@0.3.1':
|
||||
resolution: {integrity: sha512-nKEreLY6sdRvn+9UEcZhFpLYg3mJA4tmqNLZiYSMfislU11skiPWJfT7fn6ERsgAJsVMOSxeiVjBb5AX7dLKdQ==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@openclaw/proxyline@0.3.3':
|
||||
resolution: {integrity: sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
@@ -8913,6 +8917,10 @@ snapshots:
|
||||
jszip: 3.10.1
|
||||
tar: 7.5.19
|
||||
|
||||
'@openclaw/libterminal@0.3.1':
|
||||
dependencies:
|
||||
ghostty-web: 0.4.0
|
||||
|
||||
'@openclaw/proxyline@0.3.3(undici@8.5.0)':
|
||||
dependencies:
|
||||
undici: 8.5.0
|
||||
|
||||
+1
-1
@@ -11,11 +11,11 @@
|
||||
"dependencies": {
|
||||
"@create-markdown/preview": "2.0.3",
|
||||
"@noble/ed25519": "3.1.0",
|
||||
"@openclaw/libterminal": "0.3.1",
|
||||
"@openclaw/media-core": "workspace:*",
|
||||
"@openclaw/normalization-core": "workspace:*",
|
||||
"@openclaw/uirouter": "0.1.0",
|
||||
"dompurify": "3.4.11",
|
||||
"ghostty-web": "0.4.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"json5": "2.2.3",
|
||||
"lit": "3.3.3",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBrowserOnlySharedModuleAliases,
|
||||
resolveExternalPackageAliasesForVite,
|
||||
resolveSourcePackageAliasesForVite,
|
||||
resolveTsconfigPathAliasesForVite,
|
||||
} from "../../vite.config.ts";
|
||||
@@ -36,6 +37,14 @@ describe("Control UI Vite config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves published OpenClaw packages before the broad plugin alias", () => {
|
||||
const aliases = resolveExternalPackageAliasesForVite();
|
||||
expect(aliases.find((alias) => alias.find === "@openclaw/libterminal/browser")).toEqual({
|
||||
find: "@openclaw/libterminal/browser",
|
||||
replacement: path.join(repoRoot, "node_modules/@openclaw/libterminal/dist/browser.js"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps specific tsconfig aliases ahead of broad package aliases", () => {
|
||||
const aliases = resolveTsconfigPathAliasesForVite();
|
||||
const netPolicyIpIndex = aliases.findIndex((alias) => alias.find === "@openclaw/net-policy/ip");
|
||||
|
||||
@@ -3,28 +3,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalGatewayClient } from "./terminal-connection.ts";
|
||||
|
||||
vi.mock("ghostty-web", () => {
|
||||
class Terminal {
|
||||
cols = 100;
|
||||
rows = 30;
|
||||
viewportY = 0;
|
||||
type CreateOptions = {
|
||||
terminalOptions?: { fontFamily?: string };
|
||||
onData?: (bytes: Uint8Array) => void;
|
||||
onResize?: (size: { columns: number; rows: number }) => void;
|
||||
};
|
||||
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData() {}
|
||||
onResize() {}
|
||||
write() {}
|
||||
focus() {}
|
||||
dispose() {}
|
||||
}
|
||||
const createGhosttyTerminalMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
class FitAddon {
|
||||
fit() {}
|
||||
observeResize() {}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
return { init: vi.fn(async () => {}), Terminal, FitAddon };
|
||||
vi.mock("@openclaw/libterminal/browser", () => {
|
||||
return { createGhosttyTerminal: createGhosttyTerminalMock };
|
||||
});
|
||||
|
||||
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
|
||||
@@ -32,9 +20,26 @@ import { OpenClawTerminalPanel } from "./terminal-panel.ts";
|
||||
describe("OpenClawTerminalPanel", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
createGhosttyTerminalMock.mockReset();
|
||||
});
|
||||
|
||||
it("opens new sessions for the selected agent", async () => {
|
||||
let createOptions: CreateOptions | undefined;
|
||||
createGhosttyTerminalMock.mockImplementation(async (options: CreateOptions) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
terminal: {
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
viewportY: 0,
|
||||
write: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
},
|
||||
write: vi.fn(),
|
||||
fit: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
});
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const client: TerminalGatewayClient = {
|
||||
request: async <T>(method: string, params?: unknown) => {
|
||||
@@ -63,5 +68,25 @@ describe("OpenClawTerminalPanel", () => {
|
||||
params: { agentId: "ops", cols: 100, rows: 30 },
|
||||
});
|
||||
});
|
||||
expect(createOptions?.terminalOptions?.fontFamily).toContain("MesloLGLDZ Nerd Font Mono");
|
||||
await vi.waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
method: "terminal.resize",
|
||||
params: { sessionId: "session-1", cols: 100, rows: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
createOptions?.onData?.(new TextEncoder().encode("pwd\n"));
|
||||
createOptions?.onResize?.({ columns: 120, rows: 40 });
|
||||
await vi.waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
method: "terminal.input",
|
||||
params: { sessionId: "session-1", data: "pwd\n" },
|
||||
});
|
||||
expect(requests).toContainEqual({
|
||||
method: "terminal.resize",
|
||||
params: { sessionId: "session-1", cols: 120, rows: 40 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FitAddon, Terminal } from "ghostty-web";
|
||||
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 ghostty-web terminal wired to a gateway PTY session.
|
||||
// ghostty-web (WASM, ~0.5MB) is dynamically imported on first open so it never
|
||||
// weighs down the initial Control UI bundle.
|
||||
// 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";
|
||||
@@ -34,8 +34,7 @@ type TerminalTabState = {
|
||||
shellName: string;
|
||||
/** Agent + cwd shown on hover. */
|
||||
hint: string;
|
||||
term: Terminal;
|
||||
fit: FitAddon;
|
||||
controller: GhosttyTerminalController;
|
||||
host: HTMLDivElement;
|
||||
status: "live" | "exited";
|
||||
statusLabel?: string;
|
||||
@@ -65,6 +64,10 @@ const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "bottom", height: 320,
|
||||
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 {
|
||||
@@ -151,7 +154,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
this.height = height;
|
||||
this.width = width;
|
||||
this.syncLayoutReservation();
|
||||
this.tabs.find((tab) => tab.id === this.activeId)?.fit.fit();
|
||||
this.tabs.find((tab) => tab.id === this.activeId)?.controller.fit();
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
@@ -207,7 +210,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
// 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;
|
||||
const term = tab.controller.terminal;
|
||||
if (term.renderer && term.wasmTerm) {
|
||||
term.renderer.setTheme(theme);
|
||||
term.renderer.render(term.wasmTerm, true, term.viewportY, term);
|
||||
@@ -225,7 +228,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
viewport.append(tab.host);
|
||||
}
|
||||
}
|
||||
this.tabs.find((tab) => tab.id === this.activeId)?.fit.fit();
|
||||
this.tabs.find((tab) => tab.id === this.activeId)?.controller.fit();
|
||||
}
|
||||
}
|
||||
this.syncLayoutReservation();
|
||||
@@ -312,7 +315,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a tab with a ghostty terminal, ready for an open or attach RPC. */
|
||||
/** Boots a tab with a libterminal controller, ready for an open or attach RPC. */
|
||||
private async bootTab(): Promise<{
|
||||
tab: TerminalTabState;
|
||||
connection: TerminalConnection;
|
||||
@@ -322,8 +325,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
if (!this.client) {
|
||||
throw new Error("terminal client unavailable");
|
||||
}
|
||||
const { init, Terminal, FitAddon } = await import("ghostty-web");
|
||||
await init();
|
||||
const { createGhosttyTerminal } = await import("@openclaw/libterminal/browser");
|
||||
if (!this.connection) {
|
||||
this.connection = new TerminalConnection(this.client);
|
||||
}
|
||||
@@ -332,31 +334,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
const connection = this.connection;
|
||||
const host = document.createElement("div");
|
||||
host.className = "tp-host";
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
cursorBlink: true,
|
||||
theme: terminalTheme(this.themeMode),
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
const id = `tab-${++this.tabSeq}`;
|
||||
const tab: TerminalTabState = {
|
||||
id,
|
||||
gatewaySessionId: "",
|
||||
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
|
||||
hint: "",
|
||||
term,
|
||||
fit,
|
||||
host,
|
||||
status: "live",
|
||||
};
|
||||
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.activeId = id;
|
||||
// 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;
|
||||
@@ -365,10 +343,52 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
throw new Error("terminal viewport unavailable");
|
||||
}
|
||||
viewport.append(host);
|
||||
|
||||
term.open(host);
|
||||
fit.fit();
|
||||
return { tab, connection, cols: term.cols || 80, rows: term.rows || 24 };
|
||||
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. */
|
||||
@@ -378,7 +398,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
// connection.open/attach from writing to an already-disposed terminal.
|
||||
onData: (data: string) => {
|
||||
if (!tab.cancelled) {
|
||||
tab.term.write(data);
|
||||
tab.controller.write(TERMINAL_OUTPUT_ENCODER.encode(data));
|
||||
}
|
||||
},
|
||||
onExit: (info: { reason?: string; exitCode: number | null }) => this.handleExit(tab.id, info),
|
||||
@@ -393,13 +413,10 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
tab.gatewaySessionId = result.sessionId;
|
||||
tab.shellName = shellBasename(result.shell);
|
||||
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
|
||||
|
||||
// Forward keystrokes and viewport resizes to the PTY.
|
||||
tab.term.onData((data) => void this.connection?.input(result.sessionId, data));
|
||||
tab.term.onResize(
|
||||
({ cols: c, rows: r }) => void this.connection?.resize(result.sessionId, c, r),
|
||||
);
|
||||
tab.fit.observeResize();
|
||||
// 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();
|
||||
@@ -439,7 +456,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
this.adoptSession(boot.tab, result);
|
||||
boot.tab.term.focus();
|
||||
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)
|
||||
@@ -465,9 +482,6 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
return false;
|
||||
}
|
||||
this.adoptSession(boot.tab, result);
|
||||
// The PTY grid rarely matches the new viewport; resizing also delivers
|
||||
// the SIGWINCH repaint that restores full-screen apps after the replay.
|
||||
void boot.connection.resize(result.sessionId, boot.cols, boot.rows);
|
||||
return true;
|
||||
} catch {
|
||||
// Session expired between list and attach (reaper race) or an older
|
||||
@@ -529,15 +543,14 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
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?.fit.fit();
|
||||
tab?.term.focus();
|
||||
tab?.controller.fit();
|
||||
tab?.controller.terminal.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private disposeTab(tab: TerminalTabState): void {
|
||||
try {
|
||||
tab.fit.dispose();
|
||||
tab.term.dispose();
|
||||
tab.controller.dispose();
|
||||
tab.host.remove();
|
||||
} catch {
|
||||
// Best-effort teardown; a partially-initialized tab may throw.
|
||||
@@ -570,7 +583,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
this.persistLayout();
|
||||
void this.updateComplete.then(() => {
|
||||
for (const tab of this.tabs) {
|
||||
tab.fit.fit();
|
||||
tab.controller.fit();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -622,7 +635,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
// Reflow the content reservation live so the shell tracks the drag.
|
||||
this.syncLayoutReservation();
|
||||
const active = this.tabs.find((tab) => tab.id === this.activeId);
|
||||
active?.fit.fit();
|
||||
active?.controller.fit();
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Maps the Control UI light/dark surfaces onto ghostty-web's 16-color theme.
|
||||
import type { ITheme } from "ghostty-web";
|
||||
// Maps the Control UI light/dark surfaces onto the terminal's 16-color theme.
|
||||
import type { CreateGhosttyTerminalOptions } from "@openclaw/libterminal/browser";
|
||||
|
||||
type TerminalTheme = NonNullable<
|
||||
NonNullable<CreateGhosttyTerminalOptions["terminalOptions"]>["theme"]
|
||||
>;
|
||||
|
||||
// ANSI palette tuned to sit on the Control UI's near-black / near-white surfaces.
|
||||
// Shared 8 + bright 8; foreground/background/cursor are overridden per mode below.
|
||||
@@ -23,7 +27,7 @@ const ANSI = {
|
||||
} as const;
|
||||
|
||||
/** Builds the terminal theme for the given Control UI color mode. */
|
||||
export function terminalTheme(mode: "dark" | "light"): ITheme {
|
||||
export function terminalTheme(mode: "dark" | "light"): TerminalTheme {
|
||||
if (mode === "light") {
|
||||
return {
|
||||
...ANSI,
|
||||
|
||||
@@ -162,6 +162,17 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
|
||||
|
||||
export function resolveExternalPackageAliasesForVite(): ControlUiViteAlias[] {
|
||||
return [
|
||||
{
|
||||
find: "@openclaw/libterminal/browser",
|
||||
replacement: path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
"@openclaw",
|
||||
"libterminal",
|
||||
"dist",
|
||||
"browser.js",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/uirouter",
|
||||
replacement: path.join(repoRoot, "node_modules", "@openclaw", "uirouter", "dist", "index.js"),
|
||||
|
||||
Reference in New Issue
Block a user