Compare commits

...

1 Commits

Author SHA1 Message Date
Jon Ayers f5b4777453 feat: add ghostty-web terminal support 2025-11-20 00:00:20 +00:00
10 changed files with 230 additions and 82 deletions
+2 -1
View File
@@ -72,7 +72,8 @@ func CSPHeaders(telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, stat
CSPDirectiveConnectSrc: {"'self'"},
CSPDirectiveChildSrc: {"'self'"},
// https://github.com/suren-atoyan/monaco-react/issues/168
CSPDirectiveScriptSrc: {"'self'"},
// 'wasm-unsafe-eval' allows WebAssembly instantiation (required for ghostty-web terminal)
CSPDirectiveScriptSrc: {"'self' 'wasm-unsafe-eval'"},
CSPDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
// data: is used by monaco editor on FE for Syntax Highlight
CSPDirectiveFontSrc: {"'self' data:"},
+7 -1
View File
@@ -3582,6 +3582,7 @@ const (
ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality.
ExperimentWorkspaceSharing Experiment = "workspace-sharing" // Enables updating workspace ACLs for sharing with users and groups.
ExperimentAIBridge Experiment = "aibridge" // Enables AI Bridge functionality.
ExperimentGhosttyWeb Experiment = "ghostty-web" // Enables Ghostty web terminal emulator.
)
func (e Experiment) DisplayName() string {
@@ -3604,6 +3605,8 @@ func (e Experiment) DisplayName() string {
return "Workspace Sharing"
case ExperimentAIBridge:
return "AI Bridge"
case ExperimentGhosttyWeb:
return "Ghostty Web Terminal"
default:
// Split on hyphen and convert to title case
// e.g. "web-push" -> "Web Push", "mcp-server-http" -> "Mcp Server Http"
@@ -3623,13 +3626,16 @@ var ExperimentsKnown = Experiments{
ExperimentMCPServerHTTP,
ExperimentWorkspaceSharing,
ExperimentAIBridge,
ExperimentGhosttyWeb,
}
// ExperimentsSafe should include all experiments that are safe for
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
var ExperimentsSafe = Experiments{}
var ExperimentsSafe = Experiments{
ExperimentGhosttyWeb,
}
// Experiments is a list of experiments.
// Multiple experiments may be enabled at the same time.
+5 -2
View File
@@ -40,7 +40,8 @@ test("web terminal", async ({ context, page }) => {
const agent = await startAgent(page, token);
const terminal = await openTerminalWindow(page, context, workspaceName);
await terminal.waitForSelector("div.xterm-rows", {
// Wait for either xterm.js or ghostty-web terminal to be ready
await terminal.waitForSelector("div.xterm-rows, canvas", {
state: "visible",
});
@@ -55,8 +56,10 @@ test("web terminal", async ({ context, page }) => {
// Check if "echo" command was executed
// try-catch is used temporarily to find the root cause: https://github.com/coder/coder/actions/runs/6176958762/job/16767089943
try {
// ghostty-web renders to canvas, so text might not be in DOM
// Check the terminal container's textContent or xterm-rows for xterm.js
await terminal.waitForSelector(
'div.xterm-rows span:text-matches("hello123456")',
'div.xterm-rows span:text-matches("hello123456"), [data-testid="terminal"]:has-text("hello123456")',
{
state: "visible",
timeout: 10 * 1000,
@@ -209,8 +209,9 @@ test.skip("create docker workspace", async ({ context, page }) => {
workspaceName,
"main",
);
// Both xterm.js and ghostty-web create a textarea for clipboard
await terminal.waitForSelector(
`//textarea[contains(@class,"xterm-helper-textarea")]`,
`//textarea[contains(@class,"xterm-helper-textarea") or @aria-label="Terminal input"]`,
{ state: "visible" },
);
});
+1
View File
@@ -74,6 +74,7 @@
"@xterm/addon-webgl": "0.18.0",
"@xterm/xterm": "5.5.0",
"ansi-to-html": "0.7.2",
"ghostty-web": "^0.2.1",
"axios": "1.12.0",
"chroma-js": "2.6.0",
"class-variance-authority": "0.7.1",
+8
View File
@@ -175,6 +175,9 @@ importers:
front-matter:
specifier: 4.0.2
version: 4.0.2
ghostty-web:
specifier: ^0.2.1
version: 0.2.1
humanize-duration:
specifier: 3.32.2
version: 3.32.2
@@ -4187,6 +4190,9 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, tarball: https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz}
engines: {node: '>=10'}
ghostty-web@0.2.1:
resolution: {integrity: sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw==, tarball: https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.2.1.tgz}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, tarball: https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz}
engines: {node: '>= 6'}
@@ -10407,6 +10413,8 @@ snapshots:
get-stream@6.0.1: {}
ghostty-web@0.2.1: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
+2
View File
@@ -1887,6 +1887,7 @@ export type Experiment =
| "aibridge"
| "auto-fill-parameters"
| "example"
| "ghostty-web"
| "mcp-server-http"
| "notifications"
| "oauth2"
@@ -1898,6 +1899,7 @@ export const Experiments: Experiment[] = [
"aibridge",
"auto-fill-parameters",
"example",
"ghostty-web",
"mcp-server-http",
"notifications",
"oauth2",
@@ -36,15 +36,27 @@ const renderTerminal = async (
const expectTerminalText = (container: HTMLElement, text: string) => {
return waitFor(
() => {
const elements = container.getElementsByClassName("xterm-rows");
if (elements.length === 0) {
throw new Error("no xterm-rows");
// Try xterm.js structure first
const xtermRows = container.getElementsByClassName("xterm-rows");
if (xtermRows.length > 0) {
const row = xtermRows[0] as HTMLDivElement;
if (!row.textContent) {
throw new Error("no text content in xterm-rows");
}
expect(row.textContent).toContain(text);
return;
}
const row = elements[0] as HTMLDivElement;
if (!row.textContent) {
throw new Error("no text content");
// Try ghostty-web structure (canvas + parent textContent)
// Note: ghostty-web renders to canvas, but terminal messages
// are still written as text to the parent for accessibility
const terminalDiv = container.querySelector('[data-testid="terminal"]');
if (terminalDiv?.textContent) {
expect(terminalDiv.textContent).toContain(text);
return;
}
expect(row.textContent).toContain(text);
throw new Error("no terminal element found");
},
{ timeout: 5_000 },
);
+179 -70
View File
@@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import { deploymentConfig } from "api/queries/deployment";
import { appearanceSettings } from "api/queries/users";
import {
Terminal as GhosttyTerminal,
FitAddon as GhosttyFitAddon,
} from "ghostty-web";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
workspaceByOwnerAndName,
workspaceUsage,
@@ -42,6 +47,16 @@ export const Language = {
websocketErrorMessagePrefix: "WebSocket failed: ",
};
/**
* TerminalPage provides a web-based terminal interface with automatic reconnection.
*
* The terminal implementation can be switched between xterm.js and ghostty-web via
* the "ghostty-web" experiment. ghostty-web provides better performance and standards
* compliance using Ghostty's VT100 parser via WebAssembly.
*
* When the experiment is disabled (default), xterm.js is used.
* When enabled, ghostty-web is used (renderer config is ignored).
*/
const TerminalPage: FC = () => {
// Maybe one day we'll support a light themed terminal, but terminal coloring
// is notably a pain because of assumptions certain programs might make about your
@@ -54,7 +69,7 @@ const TerminalPage: FC = () => {
const terminalWrapperRef = useRef<HTMLDivElement>(null);
// The terminal is maintained as a state to trigger certain effects when it
// updates.
const [terminal, setTerminal] = useState<Terminal>();
const [terminal, setTerminal] = useState<Terminal | GhosttyTerminal>();
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const [searchParams] = useSearchParams();
@@ -81,6 +96,9 @@ const TerminalPage: FC = () => {
const config = useQuery(deploymentConfig());
const renderer = config.data?.config.web_terminal_renderer;
const { experiments } = useDashboard();
const useGhosttyWeb = experiments.includes("ghostty-web");
const { copyToClipboard } = useClipboard();
// Periodically report workspace usage.
@@ -119,36 +137,63 @@ const TerminalPage: FC = () => {
appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT;
// Create the terminal!
const fitAddonRef = useRef<FitAddon>(undefined);
const fitAddonRef = useRef<FitAddon | GhosttyFitAddon>(undefined);
useEffect(() => {
if (!terminalWrapperRef.current || config.isLoading) {
return;
}
const terminal = new Terminal({
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
theme: {
background: theme.palette.background.default,
},
});
if (renderer === "webgl") {
terminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
terminal.loadAddon(new CanvasAddon());
let terminal: Terminal | GhosttyTerminal;
if (useGhosttyWeb) {
// ====== GHOSTTY-WEB PATH ======
terminal = new GhosttyTerminal({
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
cursorBlink: true,
theme: {
background: theme.palette.background.default,
},
});
const fitAddon = new GhosttyFitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
// Note: ghostty-web has built-in link detection via OSC 8
// We'll rely on browser's default link behavior or could override with
// DOM event listeners on the canvas after mount if needed
} else {
// ====== XTERM.JS PATH (existing code) ======
terminal = new Terminal({
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
theme: {
background: theme.palette.background.default,
},
});
// Apply renderer config (only for xterm.js)
if (renderer === "webgl") {
terminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
terminal.loadAddon(new CanvasAddon());
}
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
}
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
const isMac = navigator.platform.match("Mac");
@@ -159,34 +204,38 @@ const TerminalPage: FC = () => {
}
};
// There is no way to remove this handler, so we must attach it once and
// rely on a ref to send it to the current socket.
const escapedCarriageReturn = "\x1b\r";
terminal.attachCustomKeyEventHandler((ev) => {
// Make shift+enter send ^[^M (escaped carriage return). Applications
// typically take this to mean to insert a literal newline.
if (ev.shiftKey && ev.key === "Enter") {
if (ev.type === "keydown") {
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
// Custom key event handler (only for xterm.js)
// ghostty-web handles key events differently
if (terminal instanceof Terminal) {
// There is no way to remove this handler, so we must attach it once and
// rely on a ref to send it to the current socket.
const escapedCarriageReturn = "\x1b\r";
terminal.attachCustomKeyEventHandler((ev) => {
// Make shift+enter send ^[^M (escaped carriage return). Applications
// typically take this to mean to insert a literal newline.
if (ev.shiftKey && ev.key === "Enter") {
if (ev.type === "keydown") {
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
}
return false;
}
return false;
}
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text.
// By default this usually launches the browser dev tools, but users
// expect this keybinding to copy when in the context of the web terminal.
if ((isMac ? ev.metaKey : ev.ctrlKey) && ev.shiftKey && ev.key === "C") {
ev.preventDefault();
if (ev.type === "keydown") {
copySelection();
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text.
// By default this usually launches the browser dev tools, but users
// expect this keybinding to copy when in the context of the web terminal.
if ((isMac ? ev.metaKey : ev.ctrlKey) && ev.shiftKey && ev.key === "C") {
ev.preventDefault();
if (ev.type === "keydown") {
copySelection();
}
return false;
}
return false;
}
return true;
});
return true;
});
}
// Copy using the clipboard API on selection. This selected text will go
// into the clipboard, not the primary selection, as the browser does not
@@ -205,20 +254,62 @@ const TerminalPage: FC = () => {
copySelection();
});
terminal.open(terminalWrapperRef.current);
// Open terminal (async for ghostty-web, sync for xterm.js)
const openTerminal = async () => {
if (useGhosttyWeb) {
await (terminal as GhosttyTerminal).open(terminalWrapperRef.current!);
// We have to fit twice here. It's unknown why, but the first fit will
// overflow slightly in some scenarios. Applying a second fit resolves this.
fitAddon.fit();
fitAddon.fit();
const escapedCarriageReturn = "\x1b\r";
const canvas = terminalWrapperRef.current?.querySelector("canvas");
if (canvas) {
// Handle special key combinations for ghostty-web via DOM events
canvas.addEventListener("keydown", (event: KeyboardEvent) => {
// Make shift+enter send ^[^M (escaped carriage return)
if (event.shiftKey && event.key === "Enter") {
event.preventDefault();
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
}
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text
else if ((isMac ? event.metaKey : event.ctrlKey) && event.shiftKey && event.key === "C") {
event.preventDefault();
copySelection();
}
});
// Intercept link clicks for port-forwarding
canvas.addEventListener("click", (event: MouseEvent) => {
const target = event.target as HTMLElement;
const computedStyle = window.getComputedStyle(target);
if (computedStyle.cursor === "pointer") {
event.preventDefault();
// ghostty-web handles most link clicks internally
}
});
}
} else {
(terminal as Terminal).open(terminalWrapperRef.current!);
}
// We have to fit twice here. It's unknown why, but the first fit will
// overflow slightly in some scenarios. Applying a second fit resolves this.
fitAddonRef.current?.fit();
fitAddonRef.current?.fit();
// Terminal is correctly sized and is ready to be used.
setTerminal(terminal);
};
// Use void to explicitly mark async function call
void openTerminal();
// This will trigger a resize event on the terminal.
const listener = () => fitAddon.fit();
const listener = () => fitAddonRef.current?.fit();
window.addEventListener("resize", listener);
// Terminal is correctly sized and is ready to be used.
setTerminal(terminal);
return () => {
window.removeEventListener("resize", listener);
terminal.dispose();
@@ -226,6 +317,7 @@ const TerminalPage: FC = () => {
}, [
config.isLoading,
renderer,
useGhosttyWeb,
theme.palette.background.default,
currentTerminalFont,
copyToClipboard,
@@ -262,8 +354,10 @@ const TerminalPage: FC = () => {
// typing immediately.
terminal.focus();
// Disable input while we connect.
terminal.options.disableStdin = true;
// Disable input while we connect (only for xterm.js).
if (terminal instanceof Terminal) {
terminal.options.disableStdin = true;
}
// Show a message if we failed to find the workspace or agent.
if (workspace.isLoading) {
@@ -331,11 +425,13 @@ const TerminalPage: FC = () => {
websocket.binaryType = "arraybuffer";
websocketRef.current = websocket;
websocket.addEventListener(WebsocketEvent.open, () => {
// Now that we are connected, allow user input.
terminal.options = {
disableStdin: false,
windowsMode: workspaceAgent?.operating_system === "windows",
};
// Now that we are connected, allow user input (only for xterm.js).
if (terminal instanceof Terminal) {
terminal.options = {
disableStdin: false,
windowsMode: workspaceAgent?.operating_system === "windows",
};
}
// Send the initial size.
websocket?.send(
new TextEncoder().encode(
@@ -349,11 +445,15 @@ const TerminalPage: FC = () => {
});
websocket.addEventListener(WebsocketEvent.error, (_, event) => {
console.error("WebSocket error:", event);
terminal.options.disableStdin = true;
if (terminal instanceof Terminal) {
terminal.options.disableStdin = true;
}
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.close, () => {
terminal.options.disableStdin = true;
if (terminal instanceof Terminal) {
terminal.options.disableStdin = true;
}
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.message, (_, event) => {
@@ -459,6 +559,8 @@ const styles = {
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
flex: 1,
// xterm.js styles (when experiment disabled)
// These styles attempt to mimic the VS Code scrollbar.
"& .xterm": {
padding: 4,
@@ -480,6 +582,13 @@ const styles = {
minHeight: 20,
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
// ghostty-web styles (when experiment enabled)
// ghostty-web renders directly to canvas, no special classes needed
"& canvas": {
width: "100%",
height: "100%",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
+5
View File
@@ -23,6 +23,7 @@ if (process.env.STATS !== undefined) {
export default defineConfig({
plugins,
publicDir: path.resolve(__dirname, "./static"),
assetsInclude: ["**/*.wasm"],
build: {
outDir: path.resolve(__dirname, "./out"),
emptyOutDir: false, // We need to keep the /bin folder and GITKEEP files
@@ -47,12 +48,16 @@ export default defineConfig({
if (id.includes("@emotion")) return "emotion";
if (id.includes("monaco-editor")) return "monaco";
if (id.includes("@xterm")) return "xterm";
if (id.includes("ghostty-web")) return "ghostty";
if (id.includes("emoji-mart")) return "emoji-mart";
if (id.includes("radix-ui")) return "radix-ui";
},
},
},
},
optimizeDeps: {
exclude: ["ghostty-web"],
},
define: {
"process.env": {
NODE_ENV: process.env.NODE_ENV,