feat(v0.2): multi-tab terminal + titles + Claude Code shortcut bar

Frontend feature (backend unchanged — manager was already multi-session):
- Multi-tab: each tab = independent WS + Terminal + session (terminal-session.ts,
  tabs.ts); tab bar with +, ×, middle-click close; persisted to localStorage
  (migrates v0.1 single-session key)
- Tab titles: auto from xterm onTitleChange (OSC 0/2), double-click to rename
  inline (manual wins over auto); ellipsis + tooltip
- Shortcut key bar now shown on ALL devices (clickable buttons trigger shortcuts),
  reordered for Claude Code (Esc prominent, ⇧Tab, ↑↓, ⏎, ^C, Tab, ←→, /);
  responsive sizing via @media (pointer:coarse) for touch
- Verified in browser: rename, two independent sessions, keybar on desktop +
  narrow viewport. 191 tests green; both typechecks + esbuild build pass.
This commit is contained in:
Yaojia Wang
2026-06-17 12:11:18 +02:00
parent f3b6ad5a68
commit 4f66016f02
7 changed files with 734 additions and 319 deletions

View File

@@ -1,274 +1,27 @@
/**
* public/main.ts — esbuild entry point for the browser frontend (T11).
* public/main.ts — esbuild entry point for the browser frontend.
*
* 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)
* Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal
* panes (#term), each tab an independent TerminalSession (own WS + server
* session). The mobile key bar routes input to the active tab.
*
* No console.log — status messages are written as ANSI color sequences to the terminal.
* Per-session terminal/WS/reconnect logic lives in terminal-session.ts;
* tab management + persistence in tabs.ts.
*/
// 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'
import { TabApp } from './tabs.js'
/* ─── ANSI color helpers ─────────────────────────────────────────── */
const paneHost = document.getElementById('term')
const tabBar = document.getElementById('tabbar')
if (!paneHost) throw new Error('#term element not found in DOM')
if (!tabBar) throw new Error('#tabbar element not found in DOM')
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'
const app = new TabApp(paneHost, tabBar)
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()
// Key bar (mobile) sends to whichever tab is active.
mountKeybar((data) => app.sendToActive(data))