Compare commits

...

5 Commits

Author SHA1 Message Date
Jon Ayers
e2932c99ea fix: wait for ghostty-web onReady before using terminal
The ghostty-web terminal loads WASM asynchronously in the constructor.
Methods like clear() and write() require the internal wasmTerm to be
initialized, which happens when onReady fires.

This change ensures we only set the terminal state (triggering the
websocket effect) after the terminal is fully ready for ghostty-web.
For xterm.js, the terminal is ready immediately after open().
2025-11-25 00:26:14 +00:00
Jon Ayers
0485cafc52 fix: configure WASM path for ghostty-web
The new ghostty-web version loads WASM in the constructor and uses
different path resolution. This adds:
- Explicit wasmPath option pointing to /ghostty-vt.wasm
- postinstall script to copy WASM file to static/ directory

This ensures the WASM file is available at a known location regardless
of bundler configuration.
2025-11-25 00:21:36 +00:00
Jon Ayers
100dae81c3 refactor: simplify terminal instantiation with shared options
Extract common terminal options into a shared object and use ternary
expressions for terminal/addon creation. This reduces duplication while
keeping xterm.js-specific addons in a single conditional block.
2025-11-25 00:11:02 +00:00
Jon Ayers
80b9d0e5b7 fix: update ghostty-web to new synchronous API
The ghostty-web 0.2.1-next.4 release introduces an xterm.js-compatible API:

- open() is now synchronous (WASM loads in constructor)
- attachCustomKeyEventHandler() is supported
- disableStdin option is supported
- options property is now public

This simplifies the integration by:
- Removing the async openTerminal() wrapper
- Using attachCustomKeyEventHandler for both xterm.js and ghostty-web
- Enabling disableStdin for both terminal implementations
- Removing most instanceof Terminal checks
2025-11-25 00:04:13 +00:00
Jon Ayers
f5b4777453 feat: add ghostty-web terminal support 2025-11-20 00:00:20 +00:00
11 changed files with 378 additions and 260 deletions

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:"},

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.

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,

View File

@@ -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" },
);
});

View File

@@ -1,220 +1,222 @@
{
"name": "coder-v2",
"description": "Coder V2 (Workspaces V2)",
"repository": "https://github.com/coder/coder",
"private": true,
"license": "AGPL-3.0",
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"scripts": {
"build": "NODE_ENV=production pnpm vite build",
"check": "biome check --error-on-warnings .",
"check:fix": "biome check --error-on-warnings --fix .",
"check:all": "pnpm check && pnpm test",
"chromatic": "chromatic",
"dev": "vite",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "pnpm run lint:check && pnpm run lint:types && pnpm run lint:circular-deps && knip",
"lint:check": "biome lint --error-on-warnings .",
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
"lint:knip": "knip",
"lint:fix": "biome lint --error-on-warnings --write . && knip --fix",
"lint:types": "tsc -p .",
"playwright:install": "playwright install --with-deps chromium",
"playwright:test": "playwright test --config=e2e/playwright.config.ts",
"playwright:test-ui": "playwright test --config=e2e/playwright.config.ts --ui $([[ \"$CODER\" == \"true\" ]] && echo --ui-port=7500 --ui-host=0.0.0.0)",
"gen:provisioner": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./e2e/ --ts_proto_opt=outputJsonMethods=false,outputEncodeMethods=encode-no-creation,outputClientImpl=false,nestJs=false,outputPartialMethods=false,fileSuffix=Generated,suffix=hey -I ../provisionersdk/proto ../provisionersdk/proto/provisioner.proto",
"storybook": "STORYBOOK=true storybook dev -p 6006",
"storybook:build": "storybook build",
"storybook:ci": "storybook build --test",
"test": "jest",
"test:ci": "jest --selectProjects test --silent",
"test:coverage": "jest --selectProjects test --collectCoverage",
"test:watch": "jest --selectProjects test --watch",
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis"
},
"dependencies": {
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@emotion/cache": "11.14.0",
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@fontsource-variable/inter": "5.1.1",
"@fontsource/fira-code": "5.2.7",
"@fontsource/ibm-plex-mono": "5.2.7",
"@fontsource/jetbrains-mono": "5.2.5",
"@fontsource/source-code-pro": "5.2.5",
"@monaco-editor/react": "4.7.0",
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
"@mui/utils": "5.17.1",
"@mui/x-tree-view": "7.29.10",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.5",
"@radix-ui/react-radio-group": "1.2.3",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.7",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.1.1",
"@radix-ui/react-tooltip": "1.1.7",
"@tanstack/react-query-devtools": "5.77.0",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-unicode11": "0.8.0",
"@xterm/addon-web-links": "0.11.0",
"@xterm/addon-webgl": "0.18.0",
"@xterm/xterm": "5.5.0",
"ansi-to-html": "0.7.2",
"axios": "1.12.0",
"chroma-js": "2.6.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"color-convert": "2.0.1",
"cron-parser": "4.9.0",
"cronstrue": "2.50.0",
"dayjs": "1.11.18",
"emoji-mart": "5.6.0",
"file-saver": "2.0.5",
"formik": "2.4.6",
"front-matter": "4.0.2",
"humanize-duration": "3.32.2",
"jszip": "3.10.1",
"lodash": "4.17.21",
"lucide-react": "0.545.0",
"monaco-editor": "0.53.0",
"pretty-bytes": "6.1.1",
"react": "19.1.1",
"react-color": "2.19.3",
"react-confetti": "6.4.0",
"react-date-range": "1.4.0",
"react-dom": "19.1.1",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
"react-resizable-panels": "3.0.6",
"react-router": "7.8.0",
"react-syntax-highlighter": "15.6.1",
"react-textarea-autosize": "8.5.9",
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "1.8.11",
"recharts": "2.15.0",
"remark-gfm": "4.0.1",
"resize-observer-polyfill": "1.5.1",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"tzdata": "1.0.46",
"ua-parser-js": "1.0.41",
"ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10",
"undici": "6.21.3",
"unique-names-generator": "4.7.1",
"uuid": "9.0.1",
"websocket-ts": "2.2.1",
"yup": "1.6.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@chromatic-com/storybook": "4.1.0",
"@octokit/types": "12.3.0",
"@playwright/test": "1.50.1",
"@storybook/addon-docs": "9.1.2",
"@storybook/addon-links": "9.1.2",
"@storybook/addon-themes": "9.1.2",
"@storybook/react-vite": "9.1.2",
"@swc/core": "1.3.38",
"@swc/jest": "0.2.37",
"@tailwindcss/typography": "0.5.16",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "14.6.1",
"@types/chroma-js": "2.4.0",
"@types/color-convert": "2.0.4",
"@types/express": "4.17.17",
"@types/file-saver": "2.0.7",
"@types/humanize-duration": "3.27.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "20.17.16",
"@types/react": "19.1.17",
"@types/react-color": "3.0.13",
"@types/react-date-range": "1.4.4",
"@types/react-dom": "19.1.11",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1",
"@types/ssh2": "1.15.5",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "9.0.2",
"@vitejs/plugin-react": "5.0.4",
"autoprefixer": "10.4.21",
"chromatic": "11.29.0",
"dpdm": "3.14.0",
"express": "4.21.2",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.5.0",
"jest-fixed-jsdom": "0.0.10",
"jest-location-mock": "2.0.0",
"jest-websocket-mock": "2.5.0",
"jest_workaround": "0.1.14",
"knip": "5.64.1",
"msw": "2.4.8",
"postcss": "8.5.6",
"protobufjs": "7.4.0",
"rollup-plugin-visualizer": "5.14.0",
"rxjs": "7.8.1",
"ssh2": "1.17.0",
"storybook": "9.1.2",
"storybook-addon-remix-react-router": "5.0.0",
"tailwindcss": "3.4.18",
"ts-proto": "1.181.2",
"typescript": "5.6.3",
"vite": "7.1.11",
"vite-plugin-checker": "0.11.0"
},
"browserslist": [
"chrome 110",
"firefox 111",
"safari 16.0"
],
"resolutions": {
"optionator": "0.9.3",
"semver": "7.7.2"
},
"engines": {
"pnpm": ">=10.0.0 <11.0.0",
"node": ">=18.0.0 <23.0.0"
},
"pnpm": {
"overrides": {
"@babel/runtime": "7.26.10",
"@babel/helpers": "7.26.10",
"esbuild": "^0.25.0",
"form-data": "4.0.4",
"prismjs": "1.30.0",
"dompurify": "3.2.6",
"brace-expansion": "1.1.12"
},
"ignoredBuiltDependencies": [
"cpu-features",
"msw",
"protobufjs",
"storybook-addon-remix-react-router"
],
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"ssh2"
]
}
"name": "coder-v2",
"description": "Coder V2 (Workspaces V2)",
"repository": "https://github.com/coder/coder",
"private": true,
"license": "AGPL-3.0",
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"scripts": {
"build": "NODE_ENV=production pnpm vite build",
"check": "biome check --error-on-warnings .",
"check:fix": "biome check --error-on-warnings --fix .",
"check:all": "pnpm check && pnpm test",
"chromatic": "chromatic",
"dev": "vite",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "pnpm run lint:check && pnpm run lint:types && pnpm run lint:circular-deps && knip",
"lint:check": "biome lint --error-on-warnings .",
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
"lint:knip": "knip",
"lint:fix": "biome lint --error-on-warnings --write . && knip --fix",
"lint:types": "tsc -p .",
"playwright:install": "playwright install --with-deps chromium",
"playwright:test": "playwright test --config=e2e/playwright.config.ts",
"playwright:test-ui": "playwright test --config=e2e/playwright.config.ts --ui $([[ \"$CODER\" == \"true\" ]] && echo --ui-port=7500 --ui-host=0.0.0.0)",
"gen:provisioner": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./e2e/ --ts_proto_opt=outputJsonMethods=false,outputEncodeMethods=encode-no-creation,outputClientImpl=false,nestJs=false,outputPartialMethods=false,fileSuffix=Generated,suffix=hey -I ../provisionersdk/proto ../provisionersdk/proto/provisioner.proto",
"storybook": "STORYBOOK=true storybook dev -p 6006",
"storybook:build": "storybook build",
"storybook:ci": "storybook build --test",
"test": "jest",
"test:ci": "jest --selectProjects test --silent",
"test:coverage": "jest --selectProjects test --collectCoverage",
"test:watch": "jest --selectProjects test --watch",
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis",
"postinstall": "cp node_modules/ghostty-web/ghostty-vt.wasm static/ 2>/dev/null || true"
},
"dependencies": {
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@emotion/cache": "11.14.0",
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@fontsource-variable/inter": "5.1.1",
"@fontsource/fira-code": "5.2.7",
"@fontsource/ibm-plex-mono": "5.2.7",
"@fontsource/jetbrains-mono": "5.2.5",
"@fontsource/source-code-pro": "5.2.5",
"@monaco-editor/react": "4.7.0",
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
"@mui/utils": "5.17.1",
"@mui/x-tree-view": "7.29.10",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.5",
"@radix-ui/react-radio-group": "1.2.3",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.7",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.1.1",
"@radix-ui/react-tooltip": "1.1.7",
"@tanstack/react-query-devtools": "5.77.0",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-unicode11": "0.8.0",
"@xterm/addon-web-links": "0.11.0",
"@xterm/addon-webgl": "0.18.0",
"@xterm/xterm": "5.5.0",
"ansi-to-html": "0.7.2",
"ghostty-web": "0.2.1-next.4.g1680deb",
"axios": "1.12.0",
"chroma-js": "2.6.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"color-convert": "2.0.1",
"cron-parser": "4.9.0",
"cronstrue": "2.50.0",
"dayjs": "1.11.18",
"emoji-mart": "5.6.0",
"file-saver": "2.0.5",
"formik": "2.4.6",
"front-matter": "4.0.2",
"humanize-duration": "3.32.2",
"jszip": "3.10.1",
"lodash": "4.17.21",
"lucide-react": "0.545.0",
"monaco-editor": "0.53.0",
"pretty-bytes": "6.1.1",
"react": "19.1.1",
"react-color": "2.19.3",
"react-confetti": "6.4.0",
"react-date-range": "1.4.0",
"react-dom": "19.1.1",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
"react-resizable-panels": "3.0.6",
"react-router": "7.8.0",
"react-syntax-highlighter": "15.6.1",
"react-textarea-autosize": "8.5.9",
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "1.8.11",
"recharts": "2.15.0",
"remark-gfm": "4.0.1",
"resize-observer-polyfill": "1.5.1",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"tzdata": "1.0.46",
"ua-parser-js": "1.0.41",
"ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10",
"undici": "6.21.3",
"unique-names-generator": "4.7.1",
"uuid": "9.0.1",
"websocket-ts": "2.2.1",
"yup": "1.6.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@chromatic-com/storybook": "4.1.0",
"@octokit/types": "12.3.0",
"@playwright/test": "1.50.1",
"@storybook/addon-docs": "9.1.2",
"@storybook/addon-links": "9.1.2",
"@storybook/addon-themes": "9.1.2",
"@storybook/react-vite": "9.1.2",
"@swc/core": "1.3.38",
"@swc/jest": "0.2.37",
"@tailwindcss/typography": "0.5.16",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "14.6.1",
"@types/chroma-js": "2.4.0",
"@types/color-convert": "2.0.4",
"@types/express": "4.17.17",
"@types/file-saver": "2.0.7",
"@types/humanize-duration": "3.27.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "20.17.16",
"@types/react": "19.1.17",
"@types/react-color": "3.0.13",
"@types/react-date-range": "1.4.4",
"@types/react-dom": "19.1.11",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1",
"@types/ssh2": "1.15.5",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "9.0.2",
"@vitejs/plugin-react": "5.0.4",
"autoprefixer": "10.4.21",
"chromatic": "11.29.0",
"dpdm": "3.14.0",
"express": "4.21.2",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.5.0",
"jest-fixed-jsdom": "0.0.10",
"jest-location-mock": "2.0.0",
"jest-websocket-mock": "2.5.0",
"jest_workaround": "0.1.14",
"knip": "5.64.1",
"msw": "2.4.8",
"postcss": "8.5.6",
"protobufjs": "7.4.0",
"rollup-plugin-visualizer": "5.14.0",
"rxjs": "7.8.1",
"ssh2": "1.17.0",
"storybook": "9.1.2",
"storybook-addon-remix-react-router": "5.0.0",
"tailwindcss": "3.4.18",
"ts-proto": "1.181.2",
"typescript": "5.6.3",
"vite": "7.1.11",
"vite-plugin-checker": "0.11.0"
},
"browserslist": [
"chrome 110",
"firefox 111",
"safari 16.0"
],
"resolutions": {
"optionator": "0.9.3",
"semver": "7.7.2"
},
"engines": {
"pnpm": ">=10.0.0 <11.0.0",
"node": ">=18.0.0 <23.0.0"
},
"pnpm": {
"overrides": {
"@babel/runtime": "7.26.10",
"@babel/helpers": "7.26.10",
"esbuild": "^0.25.0",
"form-data": "4.0.4",
"prismjs": "1.30.0",
"dompurify": "3.2.6",
"brace-expansion": "1.1.12"
},
"ignoredBuiltDependencies": [
"cpu-features",
"msw",
"protobufjs",
"storybook-addon-remix-react-router"
],
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"ssh2"
]
}
}

9
site/pnpm-lock.yaml generated
View File

@@ -175,6 +175,9 @@ importers:
front-matter:
specifier: 4.0.2
version: 4.0.2
ghostty-web:
specifier: 0.2.1-next.4.g1680deb
version: 0.2.1-next.4.g1680deb
humanize-duration:
specifier: 3.32.2
version: 3.32.2
@@ -4187,6 +4190,10 @@ 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-next.4.g1680deb:
resolution: {integrity: sha512-GPpYcmoaXLpTiKM0AMBHqXafGOcst4Y47LeKyB6EfU7LH+fWAqed+NSoaY7RWhMPsX86VRjnHn1WCIQQ92TUMw==, tarball: https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.2.1-next.4.g1680deb.tgz}
hasBin: true
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 +10414,8 @@ snapshots:
get-stream@6.0.1: {}
ghostty-web@0.2.1-next.4.g1680deb: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3

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",

View File

@@ -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 },
);

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,60 @@ 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,
// Common terminal options shared by both xterm.js and ghostty-web
const terminalOptions = {
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
disableStdin: false,
theme: {
background: theme.palette.background.default,
},
});
if (renderer === "webgl") {
terminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
terminal.loadAddon(new CanvasAddon());
}
const fitAddon = new FitAddon();
};
// Create terminal - ghostty-web and xterm.js have compatible APIs
const terminal = useGhosttyWeb
? new GhosttyTerminal({
wasmPath: "/ghostty-vt.wasm",
...terminalOptions,
cursorBlink: true,
})
: new Terminal({
...terminalOptions,
allowProposedApi: true,
allowTransparency: true,
});
// Load FitAddon - both implementations are API-compatible
const fitAddon = useGhosttyWeb ? new GhosttyFitAddon() : new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
// xterm.js-specific addons (ghostty-web has built-in equivalents)
if (!useGhosttyWeb) {
// Renderer addon
if (renderer === "webgl") {
terminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
terminal.loadAddon(new CanvasAddon());
}
// Unicode support
terminal.loadAddon(new Unicode11Addon());
(terminal as Terminal).unicode.activeVersion = "11";
// Web links (ghostty-web has built-in OSC 8 link detection)
terminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
}
const isMac = navigator.platform.match("Mac");
@@ -159,6 +201,7 @@ const TerminalPage: FC = () => {
}
};
// Custom key event handler - works for both xterm.js and ghostty-web
// 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";
@@ -205,27 +248,46 @@ const TerminalPage: FC = () => {
copySelection();
});
// Open terminal - both xterm.js and ghostty-web have synchronous open()
// For ghostty-web, WASM loading happens in constructor, and open() returns void.
// The terminal becomes fully ready after onReady fires.
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.
fitAddon.fit();
fitAddon.fit();
// For ghostty-web, FitAddon subscribes to onReady internally and will fit when ready.
fitAddonRef.current?.fit();
fitAddonRef.current?.fit();
// This will trigger a resize event on the terminal.
const listener = () => fitAddon.fit();
window.addEventListener("resize", listener);
const resizeListener = () => fitAddonRef.current?.fit();
window.addEventListener("resize", resizeListener);
// Terminal is correctly sized and is ready to be used.
// For ghostty-web, wait for onReady before exposing terminal to other effects.
// The WASM loads asynchronously in the constructor, so methods like clear()
// and write() won't work until onReady fires.
if (useGhosttyWeb) {
const readyDisposable = (terminal as GhosttyTerminal).onReady(() => {
setTerminal(terminal);
});
return () => {
readyDisposable.dispose();
window.removeEventListener("resize", resizeListener);
terminal.dispose();
};
}
// For xterm.js, terminal is ready immediately after open()
setTerminal(terminal);
return () => {
window.removeEventListener("resize", listener);
window.removeEventListener("resize", resizeListener);
terminal.dispose();
};
}, [
config.isLoading,
renderer,
useGhosttyWeb,
theme.palette.background.default,
currentTerminalFont,
copyToClipboard,
@@ -263,6 +325,7 @@ const TerminalPage: FC = () => {
terminal.focus();
// Disable input while we connect.
// Both xterm.js and ghostty-web support disableStdin option.
terminal.options.disableStdin = true;
// Show a message if we failed to find the workspace or agent.
@@ -332,10 +395,13 @@ const TerminalPage: FC = () => {
websocketRef.current = websocket;
websocket.addEventListener(WebsocketEvent.open, () => {
// Now that we are connected, allow user input.
terminal.options = {
disableStdin: false,
windowsMode: workspaceAgent?.operating_system === "windows",
};
// Both xterm.js and ghostty-web support disableStdin option.
terminal.options.disableStdin = false;
// windowsMode is xterm.js-specific, only set it for xterm.js
if (terminal instanceof Terminal) {
terminal.options.windowsMode =
workspaceAgent?.operating_system === "windows";
}
// Send the initial size.
websocket?.send(
new TextEncoder().encode(
@@ -349,10 +415,12 @@ const TerminalPage: FC = () => {
});
websocket.addEventListener(WebsocketEvent.error, (_, event) => {
console.error("WebSocket error:", event);
// Disable input on error - both terminals support this
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.close, () => {
// Disable input on close - both terminals support this
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
@@ -459,6 +527,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 +550,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>>;

BIN
site/static/ghostty-vt.wasm Executable file

Binary file not shown.

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,