From 326d3472dd3575816bcaaf6835df92ef9e75700f Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 16 Jun 2026 18:36:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20W1=20frontend=20=E2=80=94=20index.html,?= =?UTF-8?q?=20style.css,=20keybar,=20main.ts=20(esbuild=20entry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T8 public/index.html — #term/#keybar, loads ./build/main.js (type=module) - T9 public/style.css — fullscreen term, keybar fixed bottom, hidden >768px - T10 public/keybar.ts — key→byte map + mountKeybar (13 tests) - T11 public/main.ts — xterm+fit, WS client (wss on https/M6), reconnect backoff, resize debounce, mountKeybar wiring Verified by orchestrator: backend+frontend tsc clean; full suite 141 tests; npm run build:web bundles main.ts -> public/build/main.js (430kb). Note: main.ts uses one @ts-ignore for the CSS import (minor; future: *.css d.ts). --- public/index.html | 15 +++ public/keybar.ts | 79 +++++++++++++ public/main.ts | 274 ++++++++++++++++++++++++++++++++++++++++++++ public/style.css | 73 ++++++++++++ test/keybar.test.ts | 106 +++++++++++++++++ 5 files changed, 547 insertions(+) create mode 100644 public/index.html create mode 100644 public/keybar.ts create mode 100644 public/main.ts create mode 100644 public/style.css create mode 100644 test/keybar.test.ts diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9b14c36 --- /dev/null +++ b/public/index.html @@ -0,0 +1,15 @@ + + + + + + Web Terminal + + + + +
+
+ + + diff --git a/public/keybar.ts b/public/keybar.ts new file mode 100644 index 0000000..bafb3e4 --- /dev/null +++ b/public/keybar.ts @@ -0,0 +1,79 @@ +/** + * public/keybar.ts — Mobile touch key bar for terminal input (T10). + * + * Exports pure key-to-byte mappings and a mount function that binds DOM buttons + * to onSend callback on touchstart (bypassing xterm to avoid soft keyboard popup). + * + * Key bytes (ARCHITECTURE §5): + * Esc → \x1b (0x1B) + * Shift+Tab → \x1b[Z + * ↑ → \x1b[A + * ↓ → \x1b[B + * ← → \x1b[D + * → → \x1b[C + * Enter → \r (0x0D, not \n) + * Ctrl+C → \x03 (0x03) + * Tab → \t (0x09) + */ + +import type { MountKeybar } from '../src/types.js' + +/** Key name → byte string mapping (pure data, for testing). */ +export const KEY_MAP = { + esc: '\x1b', + shiftTab: '\x1b[Z', + arrowUp: '\x1b[A', + arrowDown: '\x1b[B', + arrowLeft: '\x1b[D', + arrowRight: '\x1b[C', + enter: '\r', + ctrlC: '\x03', + tab: '\t', +} as const + +export type KeyName = keyof typeof KEY_MAP + +/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */ +export const mountKeybar: MountKeybar = (onSend) => { + const keybarEl = document.getElementById('keybar') + if (!keybarEl) return + + // Button labels and their key map entries + const buttons: Array<{ + label: string + keyName: KeyName + }> = [ + { label: 'Esc', keyName: 'esc' }, + { label: 'Shift+Tab', keyName: 'shiftTab' }, + { label: '↑', keyName: 'arrowUp' }, + { label: '↓', keyName: 'arrowDown' }, + { label: '←', keyName: 'arrowLeft' }, + { label: '→', keyName: 'arrowRight' }, + { label: 'Enter', keyName: 'enter' }, + { label: 'Ctrl+C', keyName: 'ctrlC' }, + { label: 'Tab', keyName: 'tab' }, + ] + + for (const { label, keyName } of buttons) { + const btn = document.createElement('button') + btn.textContent = label + btn.classList.add('keybar-btn') + btn.dataset.key = keyName + + const bytes = KEY_MAP[keyName] + + // touchstart: send immediately, prevent default to avoid soft keyboard + btn.addEventListener('touchstart', (e) => { + e.preventDefault() + onSend(bytes) + }) + + // For desktop testing/fallback: click also sends + btn.addEventListener('click', (e) => { + e.preventDefault() + onSend(bytes) + }) + + keybarEl.appendChild(btn) + } +} diff --git a/public/main.ts b/public/main.ts new file mode 100644 index 0000000..c8e55d7 --- /dev/null +++ b/public/main.ts @@ -0,0 +1,274 @@ +/** + * public/main.ts — esbuild entry point for the browser frontend (T11). + * + * Responsibilities: + * - xterm.js Terminal + FitAddon initialization, mounted to #term + * - WebSocket client with scheme following page protocol (M6) + * - Session persistence via localStorage (sessionId) + * - Reconnection with exponential backoff (1s/2s/4s…cap 30s) + * - Resize handling with 100ms debounce + * - Mobile key bar mounting via mountKeybar (T10) + * + * No console.log — status messages are written as ANSI color sequences to the terminal. + */ + +// CSS side-effect import processed by esbuild (→ public/build/main.css). +// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic. +// The import is intentional and required per PLAN §1 / ARCHITECTURE §5. +// @ts-ignore +import '@xterm/xterm/css/xterm.css' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { mountKeybar } from './keybar.js' +import type { ClientMessage, ServerMessage } from '../src/types.js' + +/* ─── ANSI color helpers ─────────────────────────────────────────── */ + +const RESET = '\x1b[0m' +const BOLD = '\x1b[1m' +const DIM = '\x1b[2m' +const YELLOW = '\x1b[33m' +const RED = '\x1b[31m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' + +function statusLine(msg: string): string { + return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n` +} + +/* ─── Message construction helper ───────────────────────────────── */ + +function buildMessage(msg: ClientMessage): string { + return JSON.stringify(msg) +} + +/* ─── Session ID persistence ─────────────────────────────────────── */ + +const SESSION_KEY = 'web-terminal:sessionId' + +function loadSessionId(): string | null { + try { + return localStorage.getItem(SESSION_KEY) + } catch { + return null + } +} + +function saveSessionId(id: string): void { + try { + localStorage.setItem(SESSION_KEY, id) + } catch { + // localStorage unavailable — proceed without persistence + } +} + +/* ─── WS URL (M6: scheme follows page protocol) ─────────────────── */ + +function buildWsUrl(): string { + const scheme = location.protocol === 'https:' ? 'wss' : 'ws' + return `${scheme}://${location.host}/term` +} + +/* ─── Terminal initialization ────────────────────────────────────── */ + +const term = new Terminal({ + scrollback: 5000, + fontFamily: 'Menlo, Consolas, monospace', + theme: { + background: '#1a1a1a', + foreground: '#e8e8e8', + cursor: '#e8e8e8', + }, +}) + +const fitAddon = new FitAddon() +term.loadAddon(fitAddon) + +const termEl = document.getElementById('term') +if (!termEl) { + throw new Error('#term element not found in DOM') +} + +term.open(termEl) + +// fit() must only run when the container has real dimensions (display:none → NaN) +function safefit(): { cols: number; rows: number } | null { + try { + fitAddon.fit() + const cols = term.cols + const rows = term.rows + if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) { + return null + } + return { cols, rows } + } catch { + return null + } +} + +// Initial fit after open — element should have real dimensions by now +const initialDims = safefit() + +/* ─── WebSocket + reconnect state machine ────────────────────────── */ + +let ws: WebSocket | null = null +let reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 ms +let reconnectTimer: ReturnType | null = null +let isConnecting = false + +/** Resize debounce state */ +let resizeTimer: ReturnType | null = null +let lastSentCols = initialDims?.cols ?? 0 +let lastSentRows = initialDims?.rows ?? 0 + +function sendResize(cols: number, rows: number): void { + if (ws === null || ws.readyState !== WebSocket.OPEN) return + if (cols === lastSentCols && rows === lastSentRows) return + lastSentCols = cols + lastSentRows = rows + ws.send(buildMessage({ type: 'resize', cols, rows })) +} + +function scheduleResize(): void { + if (resizeTimer !== null) clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + resizeTimer = null + const dims = safefit() + if (dims === null) return + sendResize(dims.cols, dims.rows) + }, 100) +} + +function connect(): void { + if (isConnecting) return + isConnecting = true + + const sessionId = loadSessionId() + const url = buildWsUrl() + + term.write(statusLine(`${YELLOW}Connecting…${RESET}`)) + + const socket = new WebSocket(url) + ws = socket + + socket.addEventListener('open', () => { + reconnectDelay = 1000 // reset backoff on successful open + term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) + + // First frame must be attach + socket.send(buildMessage({ type: 'attach', sessionId })) + }) + + socket.addEventListener('message', (event: MessageEvent) => { + let msg: ServerMessage + try { + msg = JSON.parse(event.data) as ServerMessage + } catch { + // Unparseable frame — ignore + return + } + + handleServerMessage(msg, socket) + }) + + socket.addEventListener('close', () => { + isConnecting = false + ws = null + scheduleReconnect() + }) + + socket.addEventListener('error', () => { + // 'error' is always followed by 'close'; close handler drives reconnect + isConnecting = false + }) +} + +function handleServerMessage(msg: ServerMessage, socket: WebSocket): void { + switch (msg.type) { + case 'attached': { + saveSessionId(msg.sessionId) + // Send current terminal dimensions after attach + const dims = safefit() + if (dims !== null) { + sendResize(dims.cols, dims.rows) + } + break + } + + case 'output': { + term.write(msg.data) + break + } + + case 'exit': { + const reason = msg.reason ? ` (${msg.reason})` : '' + term.write( + statusLine( + `${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` + + `\r\n${DIM}Press Enter to reconnect…${RESET}`, + ), + ) + + // Enter → start a new session (clear old sessionId so server spawns fresh) + const onEnterReconnect = term.onData((data) => { + if (data === '\r') { + onEnterReconnect.dispose() + // Clear stored session so next attach creates a new one + try { + localStorage.removeItem(SESSION_KEY) + } catch { + // ignore + } + socket.close() + connect() + } + }) + break + } + } +} + +function scheduleReconnect(): void { + if (reconnectTimer !== null) return + + const delay = reconnectDelay + reconnectDelay = Math.min(reconnectDelay * 2, 30_000) + + term.write( + statusLine( + `${YELLOW}Disconnected — reconnecting in ${(delay / 1000).toFixed(0)}s…${RESET}`, + ), + ) + + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, delay) +} + +/* ─── Keyboard input ─────────────────────────────────────────────── */ + +term.onData((data) => { + if (ws === null || ws.readyState !== WebSocket.OPEN) return + ws.send(buildMessage({ type: 'input', data })) +}) + +/* ─── Resize observers ───────────────────────────────────────────── */ + +window.addEventListener('resize', scheduleResize) + +const resizeObserver = new ResizeObserver(() => { + scheduleResize() +}) +resizeObserver.observe(termEl) + +/* ─── Mobile key bar ─────────────────────────────────────────────── */ + +mountKeybar((data) => { + if (ws === null || ws.readyState !== WebSocket.OPEN) return + ws.send(buildMessage({ type: 'input', data })) +}) + +/* ─── Initial connection ─────────────────────────────────────────── */ + +connect() diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..b4a9c37 --- /dev/null +++ b/public/style.css @@ -0,0 +1,73 @@ +/* Web Terminal Styling */ + +/* Full-screen terminal layout */ +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background-color: #1a1a1a; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + overflow: hidden; +} + +#term { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 60px; + width: 100%; + background-color: #1a1a1a; + overflow: hidden; +} + +/* Mobile touch keybar — fixed at bottom, horizontal layout */ +#keybar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 60px; + background-color: #2a2a2a; + border-top: 1px solid #3a3a3a; + display: flex; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + z-index: 1000; + gap: 0; +} + +/* Keybar buttons */ +#keybar button { + flex: 1; + min-width: 50px; + height: 60px; + padding: 0; + border: 1px solid #444; + background-color: #333; + color: #ddd; + font-size: 12px; + cursor: pointer; + border-radius: 0; + transition: background-color 0.2s; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +#keybar button:active { + background-color: #555; +} + +/* Hide keybar on desktop (viewport width > 768px) */ +@media (min-width: 769px) { + #keybar { + display: none; + } + + #term { + bottom: 0; + } +} diff --git a/test/keybar.test.ts b/test/keybar.test.ts new file mode 100644 index 0000000..cb64d94 --- /dev/null +++ b/test/keybar.test.ts @@ -0,0 +1,106 @@ +/** + * test/keybar.test.ts — Unit tests for keybar module (T10). + * + * Tests the pure KEY_MAP data structure and verifies each key + * is mapped to the correct byte string (ARCHITECTURE §5 / §6.3). + * + * Per task spec: "对纯字节表单测,DOM 部分手动验" — + * We test the byte mappings here. DOM behavior (button creation, + * event binding, touchstart vs click) is left to manual E2E verification + * since jsdom is not in the project dependencies. + */ + +import { describe, it, expect } from 'vitest' +import { KEY_MAP } from '../public/keybar.js' + +describe('keybar', () => { + describe('KEY_MAP — pure byte mappings', () => { + it('exports KEY_MAP as a constant object', () => { + expect(KEY_MAP).toBeDefined() + expect(typeof KEY_MAP).toBe('object') + }) + + it('Esc maps to \\x1b (0x1B)', () => { + expect(KEY_MAP.esc).toBe('\x1b') + expect(KEY_MAP.esc.charCodeAt(0)).toBe(0x1b) + }) + + it('Shift+Tab maps to \\x1b[Z', () => { + expect(KEY_MAP.shiftTab).toBe('\x1b[Z') + expect(KEY_MAP.shiftTab).toHaveLength(3) + expect(KEY_MAP.shiftTab.charCodeAt(0)).toBe(0x1b) + expect(KEY_MAP.shiftTab.charCodeAt(1)).toBe('['.charCodeAt(0)) + expect(KEY_MAP.shiftTab.charCodeAt(2)).toBe('Z'.charCodeAt(0)) + }) + + it('arrow up maps to \\x1b[A', () => { + expect(KEY_MAP.arrowUp).toBe('\x1b[A') + expect(KEY_MAP.arrowUp).toHaveLength(3) + }) + + it('arrow down maps to \\x1b[B', () => { + expect(KEY_MAP.arrowDown).toBe('\x1b[B') + expect(KEY_MAP.arrowDown).toHaveLength(3) + }) + + it('arrow left maps to \\x1b[D', () => { + expect(KEY_MAP.arrowLeft).toBe('\x1b[D') + expect(KEY_MAP.arrowLeft).toHaveLength(3) + }) + + it('arrow right maps to \\x1b[C', () => { + expect(KEY_MAP.arrowRight).toBe('\x1b[C') + expect(KEY_MAP.arrowRight).toHaveLength(3) + }) + + it('Enter maps to \\r (0x0D carriage return, not \\n)', () => { + expect(KEY_MAP.enter).toBe('\r') + expect(KEY_MAP.enter).toHaveLength(1) + expect(KEY_MAP.enter.charCodeAt(0)).toBe(0x0d) + expect(KEY_MAP.enter).not.toBe('\n') + expect(KEY_MAP.enter.charCodeAt(0)).not.toBe(0x0a) + }) + + it('Ctrl+C maps to \\x03 (0x03 ETX)', () => { + expect(KEY_MAP.ctrlC).toBe('\x03') + expect(KEY_MAP.ctrlC).toHaveLength(1) + expect(KEY_MAP.ctrlC.charCodeAt(0)).toBe(0x03) + }) + + it('Tab maps to \\t (0x09)', () => { + expect(KEY_MAP.tab).toBe('\t') + expect(KEY_MAP.tab).toHaveLength(1) + expect(KEY_MAP.tab.charCodeAt(0)).toBe(0x09) + }) + + it('all 9 keys are defined', () => { + const keys = Object.keys(KEY_MAP) + expect(keys).toEqual([ + 'esc', + 'shiftTab', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight', + 'enter', + 'ctrlC', + 'tab', + ]) + }) + + it('all mapped bytes are unique strings', () => { + const bytes = Object.values(KEY_MAP) + expect(bytes).toHaveLength(9) + expect(new Set(bytes).size).toBe(9) // all unique + }) + + it('ANSI arrow sequences use correct format', () => { + // All arrow keys follow \x1b[X pattern where X is A/B/C/D + const arrowKeys = [KEY_MAP.arrowUp, KEY_MAP.arrowDown, KEY_MAP.arrowLeft, KEY_MAP.arrowRight] + arrowKeys.forEach((seq) => { + expect(seq).toMatch(/^\x1b\[./) + expect(seq).toHaveLength(3) + }) + }) + }) +})