feat: W1 frontend — index.html, style.css, keybar, main.ts (esbuild entry)

- 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).
This commit is contained in:
Yaojia Wang
2026-06-16 18:36:32 +02:00
parent de8c366ab2
commit 326d3472dd
5 changed files with 547 additions and 0 deletions

15
public/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Terminal</title>
<link rel="stylesheet" href="./build/main.css">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="term"></div>
<div id="keybar"></div>
<script type="module" src="./build/main.js"></script>
</body>
</html>

79
public/keybar.ts Normal file
View File

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

274
public/main.ts Normal file
View File

@@ -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<typeof setTimeout> | null = null
let isConnecting = false
/** Resize debounce state */
let resizeTimer: ReturnType<typeof setTimeout> | 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<string>) => {
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()

73
public/style.css Normal file
View File

@@ -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;
}
}