diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 05b23b396..63da1356b 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -1,12 +1,41 @@ "use dom"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; +function buildXtermThemeKey(theme: ITheme): string { + const values: Array = [ + theme.background, + theme.foreground, + theme.cursor, + theme.cursorAccent, + theme.selectionBackground, + theme.selectionForeground, + theme.black, + theme.red, + theme.green, + theme.yellow, + theme.blue, + theme.magenta, + theme.cyan, + theme.white, + theme.brightBlack, + theme.brightRed, + theme.brightGreen, + theme.brightYellow, + theme.brightBlue, + theme.brightMagenta, + theme.brightCyan, + theme.brightWhite, + ].map((value) => (typeof value === "string" ? value : "")); + + return values.join("|"); +} + interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; @@ -67,6 +96,13 @@ export default function TerminalEmulator({ const hostRef = useRef(null); const runtimeRef = useRef(null); const appliedInitialOutputRef = useRef(null); + const mountedThemeRef = useRef(xtermTheme); + const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); + + useEffect(() => { + mountedThemeRef.current = xtermTheme; + runtimeRef.current?.setTheme({ theme: xtermTheme }); + }, [themeKey]); useEffect(() => { const root = rootRef.current; @@ -206,7 +242,7 @@ export default function TerminalEmulator({ root, host, initialOutputText, - theme: xtermTheme, + theme: mountedThemeRef.current, }); appliedInitialOutputRef.current = initialOutputText; @@ -217,7 +253,7 @@ export default function TerminalEmulator({ } appliedInitialOutputRef.current = null; }; - }, [streamKey, xtermTheme]); + }, [streamKey]); useEffect(() => { const runtime = runtimeRef.current; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 30f599dcb..2bc5688c4 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -6,6 +6,9 @@ type StubTerminal = { write: (text: string, callback?: () => void) => void; reset: () => void; focus: () => void; + refresh?: (start: number, end: number) => void; + options?: { theme?: unknown }; + rows?: number; }; function createRuntimeWithTerminal(): { @@ -33,6 +36,9 @@ function createRuntimeWithTerminal(): { terminal.resetCalls = resetCalls; }, focus: () => {}, + refresh: () => {}, + options: { theme: undefined }, + rows: 0, resetCalls, }; @@ -171,4 +177,23 @@ describe("terminal-emulator-runtime", () => { expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false); expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true); }); + + it("updates terminal theme without remounting", () => { + const runtime = new TerminalEmulatorRuntime(); + const refresh = vi.fn(); + const terminal: StubTerminal = { + write: () => {}, + reset: () => {}, + focus: () => {}, + refresh, + options: { theme: { background: "before" } }, + rows: 12, + }; + (runtime as unknown as { terminal: StubTerminal }).terminal = terminal; + + runtime.setTheme({ theme: { background: "after" } as never }); + + expect(terminal.options?.theme).toEqual({ background: "after" }); + expect(refresh).toHaveBeenCalledWith(0, 11); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 9f596aa97..38f9bb3b0 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -464,6 +464,30 @@ export class TerminalEmulatorRuntime { this.fitAndEmitResize?.(input?.force ?? false); } + setTheme(input: { theme: ITheme }): void { + const terminal = this.terminal; + if (!terminal) { + return; + } + + try { + terminal.options.theme = input.theme; + terminalDebugLog({ + scope: "emulator-runtime", + event: "theme:update", + }); + } catch { + // ignore + return; + } + + try { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + } catch { + // ignore + } + } + focus(): void { this.terminal?.focus(); }