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)
+ })
+ })
+ })
+})