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()