diff --git a/.gitignore b/.gitignore
index 7c2719e..6d8396d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,10 @@ dmypy.json
# textual-webterm specific
textual.log
+
+# Node.js / Bun
+node_modules/
+bun.lockb
+
+# Built JS bundle (regenerate with: bun run build)
+src/textual_webterm/static/js/terminal.js
diff --git a/Makefile b/Makefile
index b3f05b5..c331507 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,11 @@
-.PHONY: help install install-dev lint format test coverage check clean
+.PHONY: help install install-dev lint format test coverage check clean bundle bundle-watch bundle-clean
PYTHON ?= python3
PIP ?= $(PYTHON) -m pip
help:
@echo "Targets: install install-dev lint format test coverage check clean"
+ @echo "Frontend: bundle bundle-watch bundle-clean"
install:
$(PIP) install -e .
@@ -29,3 +30,16 @@ check: lint coverage
clean:
rm -rf .pytest_cache .coverage htmlcov .ruff_cache
+
+# Frontend build targets (requires Bun: https://bun.sh)
+node_modules: package.json
+ bun install
+
+bundle: node_modules
+ bun run build
+
+bundle-watch: node_modules
+ bun run watch
+
+bundle-clean:
+ rm -rf node_modules bun.lockb src/textual_webterm/static/js/terminal.js
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..4573060
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,40 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "textual-webterm-frontend",
+ "dependencies": {
+ "@xterm/addon-canvas": "^0.7.0",
+ "@xterm/addon-clipboard": "^0.2.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": "^6.0.0",
+ },
+ "devDependencies": {
+ "typescript": "^5.7.0",
+ },
+ },
+ },
+ "packages": {
+ "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="],
+
+ "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="],
+
+ "@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
+
+ "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
+
+ "@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
+
+ "@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
+
+ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
+
+ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ }
+}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..5b95aa5
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,7 @@
+[install]
+# Prefer offline/cached packages
+prefer-offline = true
+
+[build]
+# Target modern browsers
+target = "browser"
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..df501cd
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,381 @@
+# Roadmap: Migration to xterm.js 6.0 with Bun
+
+This document outlines the plan for bundling xterm.js 6.0 directly, replacing the dependency on textual-serve's bundled `textual.js`.
+
+## Current State Analysis
+
+### What textual-serve Provides
+
+| Asset | Size | What We Use | Required? |
+|-------|------|-------------|-----------|
+| `static/js/textual.js` | 502 KB | xterm.js + WebSocket client | **Yes** |
+| `static/css/xterm.css` | 4.6 KB | Terminal styling | **Yes** |
+| `static/fonts/RobotoMono*.ttf` | 381 KB | Roboto Mono font | No (we override font) |
+| `static/images/background.png` | 58 KB | Background image | No |
+| **Total** | **948 KB** | | |
+
+### What textual.js Bundle Contains
+
+The minified `textual.js` bundles:
+
+```
+xterm.js (core terminal)
+├── @xterm/addon-fit (auto-resize to container)
+├── @xterm/addon-webgl (GPU-accelerated rendering)
+├── @xterm/addon-canvas (fallback 2D canvas renderer)
+├── @xterm/addon-unicode11 (wide character support)
+├── @xterm/addon-web-links (clickable URLs)
+├── @xterm/addon-clipboard (clipboard integration)
+└── WebSocket client wrapper (class w)
+```
+
+### Hardcoded Configuration in textual.js
+
+```javascript
+new Terminal({
+ allowProposedApi: true,
+ fontSize: /* from data-font-size attribute */,
+ scrollback: 0, // ❌ No scrollback history
+ fontFamily: "'Roboto Mono', Monaco, 'Courier New', monospace" // ❌ Hardcoded
+})
+```
+
+### WebSocket Protocol (Fully Compatible)
+
+The protocol is simple JSON arrays. Our server already implements this:
+
+| Direction | Message | Description |
+|-----------|---------|-------------|
+| Client → Server | `["stdin", "data"]` | Terminal input |
+| Client → Server | `["resize", {width: N, height: M}]` | Window resize |
+| Client → Server | `["ping", data]` | Keep-alive |
+| Server → Client | `["stdout", "data"]` | Terminal output (text) |
+| Server → Client | Binary frame | Terminal output (binary) |
+| Server → Client | `["pong", data]` | Keep-alive response |
+
+### Current Workarounds
+
+1. **Font override**: Canvas monkey-patch in HTML to replace hardcoded font family
+2. **No scrollback**: Users cannot scroll back through terminal history
+
+---
+
+## Tradeoffs Analysis
+
+### Option A: Keep textual-serve Dependency
+
+| Pros | Cons |
+|------|------|
+| Zero build tooling | Hardcoded font requires workaround |
+| Automatic updates via pip | No scrollback (scrollback: 0) |
+| Maintained by Textualize | No theme customization |
+| | Carries unused fonts/images (381 KB) |
+| | Tied to textual-serve release cycle |
+| | Unknown xterm.js version (likely 5.x) |
+
+### Option B: Bundle xterm.js 6.0 Directly
+
+| Pros | Cons |
+|------|------|
+| Full configuration control | Requires Bun toolchain |
+| Scrollback history support | ~150-200 KB bundle to maintain |
+| Custom themes/colors | Must track xterm.js updates |
+| Latest xterm.js 6.0 features | Initial setup effort (2-3 days) |
+| Smaller bundle (no unused fonts) | |
+| Can drop textual-serve dependency | |
+
+### xterm.js 6.0 Features We'd Gain
+
+| Feature | Benefit |
+|---------|---------|
+| Synchronized output (DEC 2026) | Smoother rapid output rendering |
+| Ligature support | Better programming font rendering |
+| Progress addon | Visual progress indicators |
+| Shadow DOM support | Better CSS encapsulation |
+| ESM support | Modern module loading |
+| Performance improvements | Faster search, less memory |
+| OSC 52 clipboard | Secure clipboard from terminal |
+
+---
+
+## Implementation Plan
+
+### Phase 1: Tooling Setup
+
+**Goal**: Establish Bun-based build pipeline
+
+```
+src/textual_webterm/
+├── static/
+│ ├── js/
+│ │ └── terminal.ts # New: our xterm wrapper
+│ ├── css/
+│ │ └── xterm.css # Copied from xterm.js package
+│ └── monospace.css # Existing
+├── package.json # New: npm dependencies
+└── bunfig.toml # New: Bun configuration
+```
+
+**Tasks**:
+- [ ] Create `package.json` with xterm.js 6.0 dependencies
+- [ ] Create `bunfig.toml` for build configuration
+- [ ] Add `Makefile` targets: `make bundle`, `make bundle-watch`
+- [ ] Add `.gitignore` entries for `node_modules/`
+- [ ] Document Bun installation in README
+
+**package.json** (draft):
+```json
+{
+ "name": "textual-webterm-frontend",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@xterm/xterm": "^6.0.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-webgl": "^0.18.0",
+ "@xterm/addon-canvas": "^0.7.0",
+ "@xterm/addon-unicode11": "^0.8.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/addon-clipboard": "^0.2.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.3.0"
+ },
+ "scripts": {
+ "build": "bun build src/textual_webterm/static/js/terminal.ts --outdir=src/textual_webterm/static/js --minify --target=browser",
+ "watch": "bun build src/textual_webterm/static/js/terminal.ts --outdir=src/textual_webterm/static/js --watch --target=browser"
+ }
+}
+```
+
+### Phase 2: Terminal Client Implementation
+
+**Goal**: Create `terminal.ts` that replicates textual.js functionality
+
+**Tasks**:
+- [ ] Implement Terminal wrapper class
+- [ ] WebSocket connection with reconnection logic
+- [ ] Message protocol handling (stdin, resize, ping/pong)
+- [ ] Addon initialization (fit, webgl, canvas, unicode11, web-links, clipboard)
+- [ ] Configurable options via data attributes or window config
+
+**terminal.ts** (draft structure):
+```typescript
+import { Terminal } from '@xterm/xterm';
+import { FitAddon } from '@xterm/addon-fit';
+import { WebglAddon } from '@xterm/addon-webgl';
+import { CanvasAddon } from '@xterm/addon-canvas';
+import { Unicode11Addon } from '@xterm/addon-unicode11';
+import { WebLinksAddon } from '@xterm/addon-web-links';
+import { ClipboardAddon } from '@xterm/addon-clipboard';
+
+interface TerminalConfig {
+ fontFamily?: string;
+ fontSize?: number;
+ scrollback?: number;
+ theme?: object;
+}
+
+class WebTerminal {
+ private terminal: Terminal;
+ private socket: WebSocket | null = null;
+ private fitAddon: FitAddon;
+
+ constructor(container: HTMLElement, wsUrl: string, config: TerminalConfig = {}) {
+ this.terminal = new Terminal({
+ allowProposedApi: true,
+ fontFamily: config.fontFamily ?? 'ui-monospace, "Fira Code", monospace',
+ fontSize: config.fontSize ?? 16,
+ scrollback: config.scrollback ?? 1000,
+ theme: config.theme,
+ });
+
+ // Initialize addons
+ this.fitAddon = new FitAddon();
+ this.terminal.loadAddon(this.fitAddon);
+ this.terminal.loadAddon(new WebglAddon());
+ this.terminal.loadAddon(new CanvasAddon());
+ this.terminal.loadAddon(new Unicode11Addon());
+ this.terminal.loadAddon(new WebLinksAddon());
+ this.terminal.loadAddon(new ClipboardAddon());
+
+ this.terminal.open(container);
+ this.connect(wsUrl);
+ }
+
+ // ... WebSocket handling, resize, etc.
+}
+
+// Auto-initialize on page load
+window.addEventListener('load', () => {
+ document.querySelectorAll('.textual-terminal').forEach(el => {
+ const wsUrl = el.dataset.sessionWebsocketUrl;
+ const config = {
+ fontSize: parseInt(el.dataset.fontSize ?? '16'),
+ scrollback: parseInt(el.dataset.scrollback ?? '1000'),
+ fontFamily: el.dataset.fontFamily,
+ };
+ new WebTerminal(el as HTMLElement, wsUrl, config);
+ });
+});
+```
+
+### Phase 3: Server Integration
+
+**Goal**: Update local_server.py to use new bundle
+
+**Tasks**:
+- [ ] Update HTML template to load our bundle instead of textual.js
+- [ ] Remove canvas monkey-patch workaround
+- [ ] Add data attributes for scrollback, theme configuration
+- [ ] Copy xterm.css to our static folder (or bundle inline)
+- [ ] Update static file routes
+
+**HTML template changes**:
+```html
+
+
+
+
+
+
+```
+
+### Phase 4: Configuration Support
+
+**Goal**: Make terminal appearance configurable
+
+**Tasks**:
+- [ ] Add terminal config to CLI (--scrollback, --font-family)
+- [ ] Add terminal config to TOML manifest files
+- [ ] Pass config to HTML template via data attributes
+- [ ] Document configuration options
+
+**Config schema addition**:
+```toml
+[terminal]
+scrollback = 5000
+font_family = "ui-monospace, 'Fira Code', monospace"
+font_size = 16
+theme = "dark" # or custom theme object
+```
+
+### Phase 5: Remove textual-serve Dependency
+
+**Goal**: Eliminate dependency once our bundle is stable
+
+**Tasks**:
+- [ ] Remove `textual-serve` from pyproject.toml dependencies
+- [ ] Update ARCHITECTURE.md to document new frontend
+- [ ] Update README.md with build instructions
+- [ ] Ensure Docker build includes Bun for bundling
+- [ ] Add CI step to verify bundle is up-to-date
+
+**pyproject.toml change**:
+```toml
+# Remove this line:
+textual-serve = "^1.1.0"
+```
+
+### Phase 6: Testing & Polish
+
+**Goal**: Ensure reliability across browsers
+
+**Tasks**:
+- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
+- [ ] Mobile browser testing (iOS Safari, Chrome Android)
+- [ ] WebGL fallback to Canvas testing
+- [ ] Reconnection logic testing
+- [ ] Performance comparison vs textual.js
+- [ ] Bundle size verification (target: <200 KB minified)
+
+---
+
+## Build Integration
+
+### Makefile Additions
+
+```makefile
+# Frontend build
+.PHONY: bundle bundle-watch bundle-clean
+
+bundle: node_modules
+ bun run build
+
+bundle-watch: node_modules
+ bun run watch
+
+bundle-clean:
+ rm -rf node_modules src/textual_webterm/static/js/terminal.js
+
+node_modules: package.json
+ bun install
+```
+
+### Dockerfile Changes
+
+```dockerfile
+# Add Bun for frontend build
+RUN curl -fsSL https://bun.sh/install | bash
+ENV PATH="/root/.bun/bin:${PATH}"
+
+# Build frontend
+COPY package.json bunfig.toml ./
+RUN bun install
+COPY src/textual_webterm/static/js/terminal.ts src/textual_webterm/static/js/
+RUN bun run build
+```
+
+### CI/CD Considerations
+
+- Pre-commit hook to verify `terminal.js` matches `terminal.ts`
+- Or: commit built bundle to repo (simpler for users without Bun)
+- GitHub Actions step to build and verify bundle
+
+---
+
+## Risk Mitigation
+
+| Risk | Mitigation |
+|------|------------|
+| xterm.js 6.0 breaking changes | Pin exact version, test thoroughly |
+| Bun compatibility issues | Fall back to esbuild if needed |
+| WebSocket protocol mismatch | Keep protocol identical to textual.js |
+| Performance regression | Benchmark before/after, keep WebGL |
+| Missing addon features | Test each addon explicitly |
+
+---
+
+## Timeline Estimate
+
+| Phase | Effort | Dependencies |
+|-------|--------|--------------|
+| Phase 1: Tooling Setup | 0.5 days | None |
+| Phase 2: Terminal Client | 1-2 days | Phase 1 |
+| Phase 3: Server Integration | 0.5 days | Phase 2 |
+| Phase 4: Configuration | 0.5 days | Phase 3 |
+| Phase 5: Remove Dependency | 0.5 days | Phase 4 |
+| Phase 6: Testing | 1 day | Phase 5 |
+| **Total** | **4-5 days** | |
+
+---
+
+## Decision Checkpoints
+
+1. **After Phase 2**: Verify terminal.ts works in isolation before integrating
+2. **After Phase 3**: Side-by-side comparison with textual.js
+3. **After Phase 5**: Confirm no regressions before removing dependency
+4. **After Phase 6**: Final sign-off for release
+
+---
+
+## Success Criteria
+
+- [ ] Terminal renders correctly in Chrome, Firefox, Safari
+- [ ] Scrollback history works (configurable limit)
+- [ ] Custom fonts load without workarounds
+- [ ] WebGL rendering enabled with Canvas fallback
+- [ ] Bundle size ≤ 200 KB (minified + gzipped)
+- [ ] No textual-serve dependency in pyproject.toml
+- [ ] All existing tests pass
+- [ ] Documentation updated
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f3e1c49
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "textual-webterm-frontend",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@xterm/xterm": "^6.0.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/addon-webgl": "^0.18.0",
+ "@xterm/addon-canvas": "^0.7.0",
+ "@xterm/addon-unicode11": "^0.8.0",
+ "@xterm/addon-web-links": "^0.11.0",
+ "@xterm/addon-clipboard": "^0.2.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.0"
+ },
+ "scripts": {
+ "build": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser",
+ "watch": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --target=browser"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index 5f631f8..e010fb8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,11 +6,14 @@ authors = ["Will McGugan "]
license = "MIT"
readme = "README.md"
packages = [{include = "textual_webterm", from = "src"}]
-include = [{ path = "src/textual_webterm/static/monospace.css" }]
+include = [
+ { path = "src/textual_webterm/static/monospace.css" },
+ { path = "src/textual_webterm/static/css/xterm.css" },
+ { path = "src/textual_webterm/static/js/terminal.js" },
+]
[tool.poetry.dependencies]
python = "^3.9"
-textual-serve = "^1.1.0"
aiohttp = "^3.13.0"
uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" }
click = "^8.1.7"
diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py
index 42052fb..a0edb90 100644
--- a/src/textual_webterm/local_server.py
+++ b/src/textual_webterm/local_server.py
@@ -37,22 +37,6 @@ SCREENSHOT_MAX_CACHE_SECONDS = 60.0
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
-def _get_static_path() -> Path | None:
- """Get the path to static assets from textual-serve."""
- try:
- import textual_serve
-
- static_path = Path(textual_serve.__file__).parent / "static"
- if static_path.exists():
- return static_path
- except ImportError:
- log.warning("textual-serve not installed - static assets unavailable")
- return None
-
-
-STATIC_PATH = _get_static_path()
-
-
class LocalClientConnector(SessionConnector):
"""Local connector that handles communication between sessions and local server."""
@@ -258,14 +242,11 @@ class LocalServer:
web.get("/", self._handle_root),
]
- if STATIC_PATH is not None and STATIC_PATH.exists():
- routes.append(web.static("/static", STATIC_PATH))
- log.info("Static assets served from: %s", STATIC_PATH)
- else:
- log.error("Static assets not found at %s - terminal UI will not work", STATIC_PATH)
-
if WEBTERM_STATIC_PATH.exists():
- routes.append(web.static("/static-webterm", WEBTERM_STATIC_PATH))
+ routes.append(web.static("/static", WEBTERM_STATIC_PATH))
+ log.info("Static assets served from: %s", WEBTERM_STATIC_PATH)
+ else:
+ log.error("Static assets not found at %s - terminal UI will not work", WEBTERM_STATIC_PATH)
return routes
@@ -838,67 +819,20 @@ class LocalServer:
ws_url = self._get_ws_url_from_request(request, route_key)
page_title = available_app.name if available_app else "Textual Web Terminal"
- # Custom monospace font stack for terminals
- custom_font = (
- 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", '
- '"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, '
- 'Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace'
- )
- # Font to replace in xterm.js canvas rendering
- old_font = "'Roboto Mono', Monaco, 'Courier New', monospace"
html_content = f"""
{page_title}
-
-
-
+
-
-
+
+
"""
return web.Response(text=html_content, content_type="text/html")
diff --git a/src/textual_webterm/static/css/xterm.css b/src/textual_webterm/static/css/xterm.css
new file mode 100644
index 0000000..819654e
--- /dev/null
+++ b/src/textual_webterm/static/css/xterm.css
@@ -0,0 +1,285 @@
+/**
+ * Copyright (c) 2014 The xterm.js authors. All rights reserved.
+ * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
+ * https://github.com/chjj/term.js
+ * @license MIT
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * Originally forked from (with the author's permission):
+ * Fabrice Bellard's javascript vt100 for jslinux:
+ * http://bellard.org/jslinux/
+ * Copyright (c) 2011 Fabrice Bellard
+ * The original design remains. The terminal itself
+ * has been extended to include xterm CSI codes, among
+ * other features.
+ */
+
+/**
+ * Default styles for xterm.js
+ */
+
+.xterm {
+ cursor: text;
+ position: relative;
+ user-select: none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+}
+
+.xterm.focus,
+.xterm:focus {
+ outline: none;
+}
+
+.xterm .xterm-helpers {
+ position: absolute;
+ top: 0;
+ /**
+ * The z-index of the helpers must be higher than the canvases in order for
+ * IMEs to appear on top.
+ */
+ z-index: 5;
+}
+
+.xterm .xterm-helper-textarea {
+ padding: 0;
+ border: 0;
+ margin: 0;
+ /* Move textarea out of the screen to the far left, so that the cursor is not visible */
+ position: absolute;
+ opacity: 0;
+ left: -9999em;
+ top: 0;
+ width: 0;
+ height: 0;
+ z-index: -5;
+ /** Prevent wrapping so the IME appears against the textarea at the correct position */
+ white-space: nowrap;
+ overflow: hidden;
+ resize: none;
+}
+
+.xterm .composition-view {
+ /* TODO: Composition position got messed up somewhere */
+ background: #000;
+ color: #FFF;
+ display: none;
+ position: absolute;
+ white-space: nowrap;
+ z-index: 1;
+}
+
+.xterm .composition-view.active {
+ display: block;
+}
+
+.xterm .xterm-viewport {
+ /* On OS X this is required in order for the scroll bar to appear fully opaque */
+ background-color: #000;
+ overflow-y: scroll;
+ cursor: default;
+ position: absolute;
+ right: 0;
+ left: 0;
+ top: 0;
+ bottom: 0;
+}
+
+.xterm .xterm-screen {
+ position: relative;
+}
+
+.xterm .xterm-screen canvas {
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.xterm-char-measure-element {
+ display: inline-block;
+ visibility: hidden;
+ position: absolute;
+ top: 0;
+ left: -9999em;
+ line-height: normal;
+}
+
+.xterm.enable-mouse-events {
+ /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
+ cursor: default;
+}
+
+.xterm.xterm-cursor-pointer,
+.xterm .xterm-cursor-pointer {
+ cursor: pointer;
+}
+
+.xterm.column-select.focus {
+ /* Column selection mode */
+ cursor: crosshair;
+}
+
+.xterm .xterm-accessibility:not(.debug),
+.xterm .xterm-message {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+ color: transparent;
+ pointer-events: none;
+}
+
+.xterm .xterm-accessibility-tree:not(.debug) *::selection {
+ color: transparent;
+}
+
+.xterm .xterm-accessibility-tree {
+ font-family: monospace;
+ user-select: text;
+ white-space: pre;
+}
+
+.xterm .xterm-accessibility-tree > div {
+ transform-origin: left;
+ width: fit-content;
+}
+
+.xterm .live-region {
+ position: absolute;
+ left: -9999px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.xterm-dim {
+ /* Dim should not apply to background, so the opacity of the foreground color is applied
+ * explicitly in the generated class and reset to 1 here */
+ opacity: 1 !important;
+}
+
+.xterm-underline-1 { text-decoration: underline; }
+.xterm-underline-2 { text-decoration: double underline; }
+.xterm-underline-3 { text-decoration: wavy underline; }
+.xterm-underline-4 { text-decoration: dotted underline; }
+.xterm-underline-5 { text-decoration: dashed underline; }
+
+.xterm-overline {
+ text-decoration: overline;
+}
+
+.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
+.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
+.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
+.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
+.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
+
+.xterm-strikethrough {
+ text-decoration: line-through;
+}
+
+.xterm-screen .xterm-decoration-container .xterm-decoration {
+ z-index: 6;
+ position: absolute;
+}
+
+.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
+ z-index: 7;
+}
+
+.xterm-decoration-overview-ruler {
+ z-index: 8;
+ position: absolute;
+ top: 0;
+ right: 0;
+ pointer-events: none;
+}
+
+.xterm-decoration-top {
+ z-index: 2;
+ position: relative;
+}
+
+
+
+/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
+
+/* xterm.js customization: Override xterm's cursor style */
+.xterm .xterm-scrollable-element > .scrollbar {
+ cursor: default;
+}
+
+/* Arrows */
+.xterm .xterm-scrollable-element > .scrollbar > .scra {
+ cursor: pointer;
+ font-size: 11px !important;
+}
+
+.xterm .xterm-scrollable-element > .visible {
+ opacity: 1;
+
+ /* Background rule added for IE9 - to allow clicks on dom node */
+ background:rgba(0,0,0,0);
+
+ transition: opacity 100ms linear;
+ /* In front of peek view */
+ z-index: 11;
+}
+.xterm .xterm-scrollable-element > .invisible {
+ opacity: 0;
+ pointer-events: none;
+}
+.xterm .xterm-scrollable-element > .invisible.fade {
+ transition: opacity 800ms linear;
+}
+
+/* Scrollable Content Inset Shadow */
+.xterm .xterm-scrollable-element > .shadow {
+ position: absolute;
+ display: none;
+}
+.xterm .xterm-scrollable-element > .shadow.top {
+ display: block;
+ top: 0;
+ left: 3px;
+ height: 3px;
+ width: 100%;
+ box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
+}
+.xterm .xterm-scrollable-element > .shadow.left {
+ display: block;
+ top: 3px;
+ left: 0;
+ height: 100%;
+ width: 3px;
+ box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
+}
+.xterm .xterm-scrollable-element > .shadow.top-left-corner {
+ display: block;
+ top: 0;
+ left: 0;
+ height: 3px;
+ width: 3px;
+}
+.xterm .xterm-scrollable-element > .shadow.top.left {
+ box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
+}
diff --git a/src/textual_webterm/static/js/terminal.ts b/src/textual_webterm/static/js/terminal.ts
new file mode 100644
index 0000000..b920c45
--- /dev/null
+++ b/src/textual_webterm/static/js/terminal.ts
@@ -0,0 +1,261 @@
+/**
+ * xterm.js 6.0 terminal client for textual-webterm.
+ *
+ * Implements the WebSocket protocol compatible with local_server.py:
+ * - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
+ * - Server → Client: ["stdout", data], ["pong", data], or binary frames
+ */
+
+import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+import { WebglAddon } from "@xterm/addon-webgl";
+import { CanvasAddon } from "@xterm/addon-canvas";
+import { Unicode11Addon } from "@xterm/addon-unicode11";
+import { WebLinksAddon } from "@xterm/addon-web-links";
+import { ClipboardAddon } from "@xterm/addon-clipboard";
+
+/** Default font stack - prefers system monospace, falls back through programming fonts */
+const DEFAULT_FONT_FAMILY =
+ 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
+ '"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
+ '"DejaVu Sans Mono", "Courier New", monospace';
+
+/** Configuration options passed via data attributes or window config */
+interface TerminalConfig {
+ fontFamily?: string;
+ fontSize?: number;
+ scrollback?: number;
+ theme?: ITheme;
+}
+
+/** Parse configuration from element data attributes */
+function parseConfig(element: HTMLElement): TerminalConfig {
+ const config: TerminalConfig = {};
+
+ if (element.dataset.fontFamily) {
+ config.fontFamily = element.dataset.fontFamily;
+ }
+ if (element.dataset.fontSize) {
+ config.fontSize = parseInt(element.dataset.fontSize, 10);
+ }
+ if (element.dataset.scrollback) {
+ config.scrollback = parseInt(element.dataset.scrollback, 10);
+ }
+
+ return config;
+}
+
+/**
+ * WebTerminal - wraps xterm.js with WebSocket communication.
+ */
+class WebTerminal {
+ private terminal: Terminal;
+ private socket: WebSocket | null = null;
+ private fitAddon: FitAddon;
+ private element: HTMLElement;
+ private wsUrl: string;
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private reconnectDelay = 1000;
+
+ constructor(container: HTMLElement, wsUrl: string, config: TerminalConfig = {}) {
+ this.element = container;
+ this.wsUrl = wsUrl;
+
+ // Build terminal options
+ const options: ITerminalOptions = {
+ allowProposedApi: true,
+ fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
+ fontSize: config.fontSize ?? 16,
+ scrollback: config.scrollback ?? 1000,
+ cursorBlink: true,
+ cursorStyle: "block",
+ theme: config.theme,
+ };
+
+ this.terminal = new Terminal(options);
+
+ // Initialize addons
+ this.fitAddon = new FitAddon();
+ this.terminal.loadAddon(this.fitAddon);
+
+ // Try WebGL first, fall back to Canvas
+ try {
+ const webglAddon = new WebglAddon();
+ webglAddon.onContextLoss(() => {
+ webglAddon.dispose();
+ this.terminal.loadAddon(new CanvasAddon());
+ });
+ this.terminal.loadAddon(webglAddon);
+ } catch {
+ this.terminal.loadAddon(new CanvasAddon());
+ }
+
+ // Unicode support for wide characters
+ const unicode11 = new Unicode11Addon();
+ this.terminal.loadAddon(unicode11);
+ this.terminal.unicode.activeVersion = "11";
+
+ // Clickable URLs
+ this.terminal.loadAddon(new WebLinksAddon());
+
+ // Clipboard integration
+ this.terminal.loadAddon(new ClipboardAddon());
+
+ // Open terminal in container
+ this.terminal.open(container);
+
+ // Handle terminal input
+ this.terminal.onData((data) => {
+ this.send(["stdin", data]);
+ });
+
+ // Handle resize
+ this.terminal.onResize(({ cols, rows }) => {
+ this.send(["resize", { width: cols, height: rows }]);
+ });
+
+ // Fit to container and handle window resize
+ this.fit();
+ window.addEventListener("resize", () => this.fit());
+
+ // Connect WebSocket
+ this.connect();
+ }
+
+ /** Fit terminal to container size */
+ fit(): void {
+ try {
+ this.fitAddon.fit();
+ } catch {
+ // Ignore fit errors during initialization
+ }
+ }
+
+ /** Connect to WebSocket server */
+ connect(): void {
+ if (this.socket?.readyState === WebSocket.OPEN) {
+ return;
+ }
+
+ this.socket = new WebSocket(this.wsUrl);
+ this.socket.binaryType = "arraybuffer";
+
+ this.socket.addEventListener("open", () => {
+ this.reconnectAttempts = 0;
+ this.element.classList.add("-connected");
+ this.element.classList.remove("-disconnected");
+
+ // Send initial size
+ this.fit();
+ const dims = this.fitAddon.proposeDimensions();
+ if (dims) {
+ this.send(["resize", { width: dims.cols, height: dims.rows }]);
+ }
+
+ // Focus terminal
+ this.terminal.focus();
+ });
+
+ this.socket.addEventListener("close", () => {
+ this.element.classList.remove("-connected");
+ this.element.classList.add("-disconnected");
+ this.scheduleReconnect();
+ });
+
+ this.socket.addEventListener("error", () => {
+ // Error handling - close event will follow
+ });
+
+ this.socket.addEventListener("message", (event) => {
+ this.handleMessage(event.data);
+ });
+ }
+
+ /** Handle incoming WebSocket message */
+ private handleMessage(data: string | ArrayBuffer): void {
+ if (data instanceof ArrayBuffer) {
+ // Binary data - write directly to terminal
+ const text = new TextDecoder().decode(data);
+ this.terminal.write(text);
+ return;
+ }
+
+ // JSON message
+ try {
+ const envelope = JSON.parse(data) as [string, unknown];
+ const [type, payload] = envelope;
+
+ switch (type) {
+ case "stdout":
+ this.terminal.write(payload as string);
+ break;
+ case "pong":
+ // Keep-alive response - nothing to do
+ break;
+ default:
+ console.debug("Unknown message type:", type);
+ }
+ } catch {
+ // Not JSON - treat as raw text
+ this.terminal.write(data);
+ }
+ }
+
+ /** Send message to server */
+ private send(message: [string, unknown]): void {
+ if (this.socket?.readyState === WebSocket.OPEN) {
+ this.socket.send(JSON.stringify(message));
+ }
+ }
+
+ /** Schedule reconnection attempt */
+ private scheduleReconnect(): void {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.error("Max reconnection attempts reached");
+ return;
+ }
+
+ this.reconnectAttempts++;
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
+
+ setTimeout(() => {
+ console.log(`Reconnecting (attempt ${this.reconnectAttempts})...`);
+ this.connect();
+ }, delay);
+ }
+
+ /** Clean up resources */
+ dispose(): void {
+ this.socket?.close();
+ this.terminal.dispose();
+ }
+}
+
+// Store instances for potential external access
+const instances: Map = new Map();
+
+/** Initialize all terminal containers on page load */
+function initTerminals(): void {
+ document.querySelectorAll(".textual-terminal").forEach((el) => {
+ const wsUrl = el.dataset.sessionWebsocketUrl;
+ if (!wsUrl) {
+ console.error("Missing data-session-websocket-url on terminal container");
+ return;
+ }
+
+ const config = parseConfig(el);
+ const terminal = new WebTerminal(el, wsUrl, config);
+ instances.set(el, terminal);
+ });
+}
+
+// Auto-initialize on DOM ready
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initTerminals);
+} else {
+ initTerminals();
+}
+
+// Export for potential external use
+export { WebTerminal, initTerminals, instances };
diff --git a/tests/test_local_server.py b/tests/test_local_server.py
index 88b454d..f51ba7f 100644
--- a/tests/test_local_server.py
+++ b/tests/test_local_server.py
@@ -3,22 +3,22 @@
from __future__ import annotations
from textual_webterm.config import App, Config
-from textual_webterm.local_server import STATIC_PATH, LocalServer
+from textual_webterm.local_server import WEBTERM_STATIC_PATH, LocalServer
class TestLocalServer:
"""Tests for LocalServer."""
def test_static_path_exists(self) -> None:
- """Test that static path is set from textual-serve."""
- assert STATIC_PATH is not None
- assert STATIC_PATH.exists()
+ """Test that static path exists."""
+ assert WEBTERM_STATIC_PATH is not None
+ assert WEBTERM_STATIC_PATH.exists()
def test_static_path_has_required_files(self) -> None:
"""Test that static path contains required assets."""
- assert STATIC_PATH is not None
- assert (STATIC_PATH / "js" / "textual.js").exists()
- assert (STATIC_PATH / "css" / "xterm.css").exists()
+ assert WEBTERM_STATIC_PATH is not None
+ assert (WEBTERM_STATIC_PATH / "js" / "terminal.js").exists()
+ assert (WEBTERM_STATIC_PATH / "css" / "xterm.css").exists()
def test_create_server(self, tmp_path) -> None:
"""Test creating a LocalServer instance."""
diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py
index a4a21ff..c2ff8e6 100644
--- a/tests/test_local_server_unit.py
+++ b/tests/test_local_server_unit.py
@@ -13,30 +13,27 @@ from textual_webterm.local_server import (
class TestGetStaticPath:
- """Tests for static path function."""
+ """Tests for static path."""
def test_static_path_exists(self):
"""Test that static path exists."""
- from textual_webterm.local_server import _get_static_path
+ from textual_webterm.local_server import WEBTERM_STATIC_PATH
- path = _get_static_path()
- assert path is not None and path.exists()
+ assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists()
def test_static_path_has_js(self):
"""Test that static path has JS directory."""
- from textual_webterm.local_server import _get_static_path
+ from textual_webterm.local_server import WEBTERM_STATIC_PATH
- path = _get_static_path()
- assert path is not None
- assert (path / "js").exists()
+ assert WEBTERM_STATIC_PATH is not None
+ assert (WEBTERM_STATIC_PATH / "js").exists()
def test_static_path_has_css(self):
"""Test that static path has CSS directory."""
- from textual_webterm.local_server import _get_static_path
+ from textual_webterm.local_server import WEBTERM_STATIC_PATH
- path = _get_static_path()
- assert path is not None
- assert (path / "css").exists()
+ assert WEBTERM_STATIC_PATH is not None
+ assert (WEBTERM_STATIC_PATH / "css").exists()
class TestLocalServer:
@@ -421,21 +418,6 @@ class TestLocalServerMoreCoverage:
server_with_no_apps.force_exit()
assert server_with_no_apps.exit_event.is_set()
- def test_get_static_path_import_error_returns_none(self, monkeypatch):
- import builtins
-
- from textual_webterm.local_server import _get_static_path
-
- real_import = builtins.__import__
-
- def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
- if name == "textual_serve":
- raise ImportError("nope")
- return real_import(name, globals, locals, fromlist, level)
-
- monkeypatch.setattr(builtins, "__import__", fake_import)
- assert _get_static_path() is None
-
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
from textual_webterm import constants as constants_mod
@@ -483,9 +465,11 @@ class TestLocalServerMoreCoverage:
resp = await server_with_no_apps._handle_root(request)
assert "/static/css/xterm.css" in resp.text
- assert "/static-webterm/monospace.css" in resp.text
+ assert "/static/monospace.css" in resp.text
+ assert "/static/js/terminal.js" in resp.text
assert "data-session-websocket-url" in resp.text
assert "data-font-size" in resp.text
+ assert "data-scrollback" in resp.text
assert "Known" in resp.text
@pytest.mark.asyncio
@@ -677,7 +661,7 @@ class TestLocalServerMoreCoverage:
fake_path = MagicMock()
fake_path.exists.return_value = False
- monkeypatch.setattr(local_server, "STATIC_PATH", fake_path)
+ monkeypatch.setattr(local_server, "WEBTERM_STATIC_PATH", fake_path)
monkeypatch.setattr(local_server.log, "error", MagicMock())
server_with_no_apps._build_routes()