Replace textual-serve with direct xterm.js 6.0 bundle
- Add package.json with @xterm/xterm 6.0 and all addons - Create terminal.ts client with WebSocket protocol support - Bundle with Bun (bun run build -> terminal.js) - Remove textual-serve dependency from pyproject.toml - Remove canvas monkey-patch workaround (no longer needed) - Add scrollback support (configurable via data-scrollback) - Update static file routing to serve from /static/ - Add Makefile targets: bundle, bundle-watch, bundle-clean - Update tests for new static path structure Benefits: - Full control over xterm.js configuration - Scrollback history now works (default 1000 lines) - Custom font family without workarounds - Smaller footprint (no unused Roboto Mono fonts) - Latest xterm.js 6.0 features available
This commit is contained in:
@@ -129,3 +129,10 @@ dmypy.json
|
|||||||
|
|
||||||
# textual-webterm specific
|
# textual-webterm specific
|
||||||
textual.log
|
textual.log
|
||||||
|
|
||||||
|
# Node.js / Bun
|
||||||
|
node_modules/
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Built JS bundle (regenerate with: bun run build)
|
||||||
|
src/textual_webterm/static/js/terminal.js
|
||||||
|
|||||||
@@ -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
|
PYTHON ?= python3
|
||||||
PIP ?= $(PYTHON) -m pip
|
PIP ?= $(PYTHON) -m pip
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets: install install-dev lint format test coverage check clean"
|
@echo "Targets: install install-dev lint format test coverage check clean"
|
||||||
|
@echo "Frontend: bundle bundle-watch bundle-clean"
|
||||||
|
|
||||||
install:
|
install:
|
||||||
$(PIP) install -e .
|
$(PIP) install -e .
|
||||||
@@ -29,3 +30,16 @@ check: lint coverage
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf .pytest_cache .coverage htmlcov .ruff_cache
|
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
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[install]
|
||||||
|
# Prefer offline/cached packages
|
||||||
|
prefer-offline = true
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Target modern browsers
|
||||||
|
target = "browser"
|
||||||
+381
@@ -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
|
||||||
|
<!-- Before -->
|
||||||
|
<script src="/static/js/textual.js"></script>
|
||||||
|
|
||||||
|
<!-- After -->
|
||||||
|
<link rel="stylesheet" href="/static-webterm/xterm.css">
|
||||||
|
<script src="/static-webterm/terminal.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -6,11 +6,14 @@ authors = ["Will McGugan <will@textualize.io>"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{include = "textual_webterm", from = "src"}]
|
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]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.9"
|
||||||
textual-serve = "^1.1.0"
|
|
||||||
aiohttp = "^3.13.0"
|
aiohttp = "^3.13.0"
|
||||||
uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" }
|
uvloop = { version = "^0.22.0", markers = "sys_platform != 'win32'" }
|
||||||
click = "^8.1.7"
|
click = "^8.1.7"
|
||||||
|
|||||||
@@ -37,22 +37,6 @@ SCREENSHOT_MAX_CACHE_SECONDS = 60.0
|
|||||||
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
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):
|
class LocalClientConnector(SessionConnector):
|
||||||
"""Local connector that handles communication between sessions and local server."""
|
"""Local connector that handles communication between sessions and local server."""
|
||||||
|
|
||||||
@@ -258,14 +242,11 @@ class LocalServer:
|
|||||||
web.get("/", self._handle_root),
|
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():
|
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
|
return routes
|
||||||
|
|
||||||
@@ -838,67 +819,20 @@ class LocalServer:
|
|||||||
ws_url = self._get_ws_url_from_request(request, route_key)
|
ws_url = self._get_ws_url_from_request(request, route_key)
|
||||||
page_title = available_app.name if available_app else "Textual Web Terminal"
|
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"""<!DOCTYPE html>
|
html_content = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{page_title}</title>
|
<title>{page_title}</title>
|
||||||
<link rel=\"stylesheet\" href=\"/static/css/xterm.css\">
|
<link rel=\"stylesheet\" href=\"/static/css/xterm.css\">
|
||||||
<link rel=\"stylesheet\" href=\"/static-webterm/monospace.css\">
|
<link rel=\"stylesheet\" href=\"/static/monospace.css\">
|
||||||
<script>
|
|
||||||
// Intercept canvas font assignments to override xterm.js fontFamily.
|
|
||||||
// xterm.js uses canvas/WebGL rendering which ignores CSS font-family.
|
|
||||||
// We monkey-patch CanvasRenderingContext2D.font setter to replace the default font.
|
|
||||||
(function() {{
|
|
||||||
const OLD_FONT = "{old_font}";
|
|
||||||
const CUSTOM_FONT = '{custom_font}';
|
|
||||||
const fontDescriptor = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'font');
|
|
||||||
if (fontDescriptor && fontDescriptor.set) {{
|
|
||||||
const originalSet = fontDescriptor.set;
|
|
||||||
Object.defineProperty(CanvasRenderingContext2D.prototype, 'font', {{
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
get: fontDescriptor.get,
|
|
||||||
set: function(value) {{
|
|
||||||
// Replace xterm.js default font with our custom stack
|
|
||||||
if (typeof value === 'string' && value.includes(OLD_FONT)) {{
|
|
||||||
value = value.replace(OLD_FONT, CUSTOM_FONT);
|
|
||||||
}}
|
|
||||||
originalSet.call(this, value);
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
<script src=\"/static/js/textual.js\"></script>
|
|
||||||
<style>
|
<style>
|
||||||
body {{ background: #0c181f; margin: 0; padding: 0; }}
|
body {{ background: #0c181f; margin: 0; padding: 0; }}
|
||||||
.textual-terminal {{ width: 100vw; height: 100vh; }}
|
.textual-terminal {{ width: 100vw; height: 100vh; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\"></div>
|
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\" data-scrollback=\"1000\"></div>
|
||||||
<script>
|
<script src=\"/static/js/terminal.js\"></script>
|
||||||
// Focus terminal on load and when switching tabs back
|
|
||||||
(function() {{
|
|
||||||
function focusTerminal() {{
|
|
||||||
const textarea = document.querySelector('.xterm-helper-textarea');
|
|
||||||
if (textarea) {{ textarea.focus(); return; }}
|
|
||||||
const term = document.querySelector('.xterm');
|
|
||||||
if (term) {{ term.focus(); }}
|
|
||||||
}}
|
|
||||||
setTimeout(focusTerminal, 100);
|
|
||||||
setTimeout(focusTerminal, 500);
|
|
||||||
window.addEventListener('focus', focusTerminal);
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
return web.Response(text=html_content, content_type="text/html")
|
return web.Response(text=html_content, content_type="text/html")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HTMLElement, WebTerminal> = new Map();
|
||||||
|
|
||||||
|
/** Initialize all terminal containers on page load */
|
||||||
|
function initTerminals(): void {
|
||||||
|
document.querySelectorAll<HTMLElement>(".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 };
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
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:
|
class TestLocalServer:
|
||||||
"""Tests for LocalServer."""
|
"""Tests for LocalServer."""
|
||||||
|
|
||||||
def test_static_path_exists(self) -> None:
|
def test_static_path_exists(self) -> None:
|
||||||
"""Test that static path is set from textual-serve."""
|
"""Test that static path exists."""
|
||||||
assert STATIC_PATH is not None
|
assert WEBTERM_STATIC_PATH is not None
|
||||||
assert STATIC_PATH.exists()
|
assert WEBTERM_STATIC_PATH.exists()
|
||||||
|
|
||||||
def test_static_path_has_required_files(self) -> None:
|
def test_static_path_has_required_files(self) -> None:
|
||||||
"""Test that static path contains required assets."""
|
"""Test that static path contains required assets."""
|
||||||
assert STATIC_PATH is not None
|
assert WEBTERM_STATIC_PATH is not None
|
||||||
assert (STATIC_PATH / "js" / "textual.js").exists()
|
assert (WEBTERM_STATIC_PATH / "js" / "terminal.js").exists()
|
||||||
assert (STATIC_PATH / "css" / "xterm.css").exists()
|
assert (WEBTERM_STATIC_PATH / "css" / "xterm.css").exists()
|
||||||
|
|
||||||
def test_create_server(self, tmp_path) -> None:
|
def test_create_server(self, tmp_path) -> None:
|
||||||
"""Test creating a LocalServer instance."""
|
"""Test creating a LocalServer instance."""
|
||||||
|
|||||||
@@ -13,30 +13,27 @@ from textual_webterm.local_server import (
|
|||||||
|
|
||||||
|
|
||||||
class TestGetStaticPath:
|
class TestGetStaticPath:
|
||||||
"""Tests for static path function."""
|
"""Tests for static path."""
|
||||||
|
|
||||||
def test_static_path_exists(self):
|
def test_static_path_exists(self):
|
||||||
"""Test that static path exists."""
|
"""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 WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists()
|
||||||
assert path is not None and path.exists()
|
|
||||||
|
|
||||||
def test_static_path_has_js(self):
|
def test_static_path_has_js(self):
|
||||||
"""Test that static path has JS directory."""
|
"""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 WEBTERM_STATIC_PATH is not None
|
||||||
assert path is not None
|
assert (WEBTERM_STATIC_PATH / "js").exists()
|
||||||
assert (path / "js").exists()
|
|
||||||
|
|
||||||
def test_static_path_has_css(self):
|
def test_static_path_has_css(self):
|
||||||
"""Test that static path has CSS directory."""
|
"""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 WEBTERM_STATIC_PATH is not None
|
||||||
assert path is not None
|
assert (WEBTERM_STATIC_PATH / "css").exists()
|
||||||
assert (path / "css").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestLocalServer:
|
class TestLocalServer:
|
||||||
@@ -421,21 +418,6 @@ class TestLocalServerMoreCoverage:
|
|||||||
server_with_no_apps.force_exit()
|
server_with_no_apps.force_exit()
|
||||||
assert server_with_no_apps.exit_event.is_set()
|
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):
|
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
|
||||||
from textual_webterm import constants as constants_mod
|
from textual_webterm import constants as constants_mod
|
||||||
|
|
||||||
@@ -483,9 +465,11 @@ class TestLocalServerMoreCoverage:
|
|||||||
|
|
||||||
resp = await server_with_no_apps._handle_root(request)
|
resp = await server_with_no_apps._handle_root(request)
|
||||||
assert "/static/css/xterm.css" in resp.text
|
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-session-websocket-url" in resp.text
|
||||||
assert "data-font-size" in resp.text
|
assert "data-font-size" in resp.text
|
||||||
|
assert "data-scrollback" in resp.text
|
||||||
assert "<title>Known</title>" in resp.text
|
assert "<title>Known</title>" in resp.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -677,7 +661,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
fake_path = MagicMock()
|
fake_path = MagicMock()
|
||||||
fake_path.exists.return_value = False
|
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())
|
monkeypatch.setattr(local_server.log, "error", MagicMock())
|
||||||
|
|
||||||
server_with_no_apps._build_routes()
|
server_with_no_apps._build_routes()
|
||||||
|
|||||||
Reference in New Issue
Block a user