From 4f66016f02658ac1114bb29a289acfa1c631f228 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 12:11:18 +0200 Subject: [PATCH] feat(v0.2): multi-tab terminal + titles + Claude Code shortcut bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/index.html | 1 + public/keybar.ts | 63 ++++----- public/main.ts | 275 ++----------------------------------- public/style.css | 168 ++++++++++++++++++---- public/tabs.ts | 255 ++++++++++++++++++++++++++++++++++ public/terminal-session.ts | 257 ++++++++++++++++++++++++++++++++++ test/keybar.test.ts | 34 ++++- 7 files changed, 734 insertions(+), 319 deletions(-) create mode 100644 public/tabs.ts create mode 100644 public/terminal-session.ts diff --git a/public/index.html b/public/index.html index 9b14c36..f5ef295 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,7 @@ +
diff --git a/public/keybar.ts b/public/keybar.ts index bafb3e4..2fa408a 100644 --- a/public/keybar.ts +++ b/public/keybar.ts @@ -1,19 +1,19 @@ /** - * public/keybar.ts — Mobile touch key bar for terminal input (T10). + * public/keybar.ts — Mobile touch key bar, tuned for Claude Code. * * 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). + * to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up). * - * 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) + * Order/selection follows Claude Code mobile habits — the keys a phone soft + * keyboard can't easily produce, most-used first (leftmost = no scroll needed): + * Esc \x1b interrupt Claude / dismiss (primary — most used) + * Shift+Tab \x1b[Z cycle plan / auto-accept mode + * ↑ ↓ \x1b[A/B select menu option / history + * Enter \r confirm + * Ctrl+C \x03 cancel / quit + * Tab \t complete / toggle + * ← → \x1b[D/C cursor + * / / slash-command launcher */ import type { MountKeybar } from '../src/types.js' @@ -29,46 +29,47 @@ export const KEY_MAP = { enter: '\r', ctrlC: '\x03', tab: '\t', + slash: '/', } as const export type KeyName = keyof typeof KEY_MAP +/** Button layout: most-used Claude Code keys first. `primary` styles a key as prominent. */ +export const KEYBAR_BUTTONS: Array<{ label: string; keyName: KeyName; primary?: boolean }> = [ + { label: 'Esc', keyName: 'esc', primary: true }, + { label: '⇧Tab', keyName: 'shiftTab' }, + { label: '↑', keyName: 'arrowUp' }, + { label: '↓', keyName: 'arrowDown' }, + { label: '⏎', keyName: 'enter' }, + { label: '^C', keyName: 'ctrlC' }, + { label: 'Tab', keyName: 'tab' }, + { label: '←', keyName: 'arrowLeft' }, + { label: '→', keyName: 'arrowRight' }, + { label: '/', keyName: 'slash' }, +] + /** 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) { + for (const { label, keyName, primary } of KEYBAR_BUTTONS) { const btn = document.createElement('button') btn.textContent = label btn.classList.add('keybar-btn') + if (primary) btn.classList.add('keybar-btn-primary') btn.dataset.key = keyName + btn.setAttribute('aria-label', label) const bytes = KEY_MAP[keyName] - // touchstart: send immediately, prevent default to avoid soft keyboard + // touchstart: send immediately, prevent default so the soft keyboard stays hidden. btn.addEventListener('touchstart', (e) => { e.preventDefault() onSend(bytes) }) - // For desktop testing/fallback: click also sends + // Desktop fallback: click also sends. btn.addEventListener('click', (e) => { e.preventDefault() onSend(bytes) diff --git a/public/main.ts b/public/main.ts index c8e55d7..c65ac05 100644 --- a/public/main.ts +++ b/public/main.ts @@ -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 | 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() +// Key bar (mobile) sends to whichever tab is active. +mountKeybar((data) => app.sendToActive(data)) diff --git a/public/style.css b/public/style.css index b4a9c37..a36c2f8 100644 --- a/public/style.css +++ b/public/style.css @@ -1,6 +1,20 @@ /* Web Terminal Styling */ -/* Full-screen terminal layout */ +:root { + /* Key bar is shown on ALL devices (clickable shortcut buttons). Touch + devices get a taller bar with bigger tap targets. */ + --tabbar-h: 36px; + --keybar-h: 42px; +} + +/* Touch devices: bigger tap targets for tabs and the key bar. */ +@media (pointer: coarse) { + :root { + --tabbar-h: 44px; + --keybar-h: 58px; + } +} + html, body { margin: 0; padding: 0; @@ -11,24 +25,128 @@ html, body { overflow: hidden; } -#term { - position: absolute; +/* ── Tab bar (top) ───────────────────────────────────────────────── */ +#tabbar { + position: fixed; top: 0; left: 0; right: 0; - bottom: 60px; - width: 100%; + height: var(--tabbar-h); + background-color: #222; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: stretch; + overflow-x: auto; + overflow-y: hidden; + z-index: 1001; + scrollbar-width: thin; +} + +.tab { + display: flex; + align-items: center; + padding: 0 4px 0 12px; + color: #aaa; + background-color: #2a2a2a; + border-right: 1px solid #1a1a1a; + cursor: pointer; + white-space: nowrap; + font-size: 13px; + user-select: none; + max-width: 200px; +} + +.tab.active { + background-color: #1a1a1a; + color: #fff; + box-shadow: inset 0 -2px 0 #4a9eff; /* active underline accent */ +} + +.tab-label { + padding-right: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Inline rename input (double-click a tab) */ +.tab-rename { + width: 110px; + margin: 0 4px; + padding: 2px 4px; + font: inherit; + font-size: 13px; + color: #fff; + background: #111; + border: 1px solid #4a9eff; + border-radius: 3px; + outline: none; +} + +.tab-close { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 2px 6px; + border-radius: 3px; + flex: none; +} + +.tab-close:hover { + background-color: #444; + color: #fff; +} + +/* Desktop (mouse): reveal × on hover/active to reduce clutter. + Touch devices keep × always visible (no hover). */ +@media (pointer: fine) { + .tab:not(.active):not(:hover) .tab-close { + opacity: 0; + } +} + +.tab-add { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 18px; + padding: 0 14px; + user-select: none; + flex: none; +} + +.tab-add:hover { + color: #fff; +} + +/* ── Terminal area (holds one .term-pane per tab) ────────────────── */ +#term { + position: absolute; + top: var(--tabbar-h); + left: 0; + right: 0; + bottom: var(--keybar-h); background-color: #1a1a1a; overflow: hidden; } -/* Mobile touch keybar — fixed at bottom, horizontal layout */ +.term-pane { + position: absolute; + inset: 0; + overflow: hidden; +} + +/* ── Shortcut key bar (bottom) — shown on ALL devices ────────────── */ #keybar { position: fixed; bottom: 0; left: 0; right: 0; - height: 60px; + height: var(--keybar-h); background-color: #2a2a2a; border-top: 1px solid #3a3a3a; display: flex; @@ -37,21 +155,24 @@ html, body { overflow-y: hidden; z-index: 1000; gap: 0; + scrollbar-width: thin; } -/* Keybar buttons */ #keybar button { - flex: 1; - min-width: 50px; - height: 60px; - padding: 0; + flex: 1 0 auto; + min-width: 48px; + height: var(--keybar-h); + padding: 0 8px; border: 1px solid #444; + border-top: none; + border-bottom: none; background-color: #333; color: #ddd; - font-size: 12px; + font-family: inherit; + font-size: 14px; cursor: pointer; border-radius: 0; - transition: background-color 0.2s; + transition: background-color 0.12s; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; @@ -61,13 +182,14 @@ html, body { background-color: #555; } -/* Hide keybar on desktop (viewport width > 768px) */ -@media (min-width: 769px) { - #keybar { - display: none; - } - - #term { - bottom: 0; - } +/* Esc is the most-used (interrupt) — make it prominent. */ +#keybar button.keybar-btn-primary { + background-color: #5a3a12; + color: #ffcf8f; + font-weight: 600; + min-width: 56px; +} + +#keybar button.keybar-btn-primary:active { + background-color: #7a4f18; } diff --git a/public/tabs.ts b/public/tabs.ts new file mode 100644 index 0000000..fef8d6f --- /dev/null +++ b/public/tabs.ts @@ -0,0 +1,255 @@ +/** + * public/tabs.ts — multi-tab manager + tab bar UI. + * + * Each tab owns an independent TerminalSession (own WS + server session). + * + * Titles (learned from Warp / VS Code / Arc): + * - auto title from the terminal (xterm onTitleChange = OSC 0/2: cwd/process) + * - double-click a tab to rename inline (Enter commit, Esc cancel) + * - manual title wins over auto; empty rename reverts to auto/"Term N" + * Interaction: + * - middle-click a tab to close (browser/iTerm2 convention) + * - × button (hover on desktop, always shown on touch) + * - long titles ellipsis-truncate with a full-title tooltip + * + * Closing a tab only disconnects its WS (a *detach*); the server PTY keeps + * running and is reclaimed by IDLE_TTL — consistent with session survival. + */ + +import { TerminalSession } from './terminal-session.js' + +const TABS_KEY = 'web-terminal:tabs' +const ACTIVE_KEY = 'web-terminal:active' +const LEGACY_KEY = 'web-terminal:sessionId' // single-session key from v0.1 + +interface TabEntry { + session: TerminalSession + /** User-set title; null = use auto/fallback. Persisted. */ + customTitle: string | null + /** Latest OSC title from the terminal; null until the shell sets one. */ + autoTitle: string | null +} + +/** Persisted shape per tab. */ +interface StoredTab { + id: string | null + title: string | null +} + +export class TabApp { + private readonly tabs: TabEntry[] = [] + private activeIndex = -1 + private editingIndex = -1 + private readonly paneHost: HTMLElement + private readonly tabBar: HTMLElement + + constructor(paneHost: HTMLElement, tabBar: HTMLElement) { + this.paneHost = paneHost + this.tabBar = tabBar + this.restore() + } + + private displayTitle(entry: TabEntry, idx: number): string { + return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` + } + + private persist(): void { + try { + const stored: StoredTab[] = this.tabs.map((t) => ({ + id: t.session.id, + title: t.customTitle, + })) + localStorage.setItem(TABS_KEY, JSON.stringify(stored)) + localStorage.setItem(ACTIVE_KEY, String(this.activeIndex)) + } catch { + // localStorage unavailable — run without persistence + } + } + + private restore(): void { + let stored: StoredTab[] = [] + try { + const raw = localStorage.getItem(TABS_KEY) + if (raw) { + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) { + stored = parsed.map((v): StoredTab => { + // v0.2.0 stored bare ids (string|null); v0.2.1+ stores {id,title}. + if (typeof v === 'string') return { id: v, title: null } + if (v && typeof v === 'object' && 'id' in v) { + const o = v as { id: unknown; title?: unknown } + return { + id: typeof o.id === 'string' ? o.id : null, + title: typeof o.title === 'string' ? o.title : null, + } + } + return { id: null, title: null } + }) + } + } + } catch { + // ignore malformed state + } + + if (stored.length === 0) { + // First run: seed from the v0.1 single-session key if present. + let legacy: string | null = null + try { + legacy = localStorage.getItem(LEGACY_KEY) + } catch { + legacy = null + } + stored = [{ id: legacy, title: null }] + } + + for (const s of stored) { + this.createTab(s.id, s.title, false) + } + + let active = 0 + try { + active = parseInt(localStorage.getItem(ACTIVE_KEY) ?? '0', 10) + } catch { + active = 0 + } + if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) { + active = 0 + } + this.activate(active) + this.render() + } + + private createTab(sessionId: string | null, customTitle: string | null, activate: boolean): void { + const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, autoTitle: null } + entry.session = new TerminalSession({ + sessionId, + onSessionId: () => this.persist(), + onActivity: () => this.render(), + onTitle: (title) => { + entry.autoTitle = title.trim() || null + this.render() + }, + }) + this.paneHost.appendChild(entry.session.el) + this.tabs.push(entry) + entry.session.connect() + if (activate) this.activate(this.tabs.length - 1) + this.persist() + } + + newTab(): void { + this.createTab(null, null, true) + this.render() + } + + activate(i: number): void { + if (i < 0 || i >= this.tabs.length) return + this.activeIndex = i + this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) + this.persist() + this.render() + } + + closeTab(i: number): void { + if (i < 0 || i >= this.tabs.length) return + const [entry] = this.tabs.splice(i, 1) + entry?.session.dispose() + if (this.editingIndex === i) this.editingIndex = -1 + if (this.tabs.length === 0) { + this.createTab(null, null, true) + } else { + this.activate(Math.min(i, this.tabs.length - 1)) + } + this.persist() + this.render() + } + + private commitRename(i: number, value: string): void { + const entry = this.tabs[i] + if (entry) { + const trimmed = value.trim() + entry.customTitle = trimmed.length > 0 ? trimmed : null // empty → revert to auto + } + this.editingIndex = -1 + this.persist() + this.render() + } + + /** Route key-bar input to the currently active tab. */ + sendToActive(data: string): void { + this.tabs[this.activeIndex]?.session.send(data) + } + + private render(): void { + this.tabBar.replaceChildren() + + this.tabs.forEach((entry, idx) => { + const tabEl = document.createElement('div') + tabEl.className = idx === this.activeIndex ? 'tab active' : 'tab' + const title = this.displayTitle(entry, idx) + tabEl.title = title // full-title tooltip (truncation handled by CSS) + + // Middle-click anywhere on the tab closes it. + tabEl.addEventListener('auxclick', (e) => { + if (e.button === 1) { + e.preventDefault() + this.closeTab(idx) + } + }) + + if (idx === this.editingIndex) { + const input = document.createElement('input') + input.className = 'tab-rename' + input.value = title + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') this.commitRename(idx, input.value) + else if (e.key === 'Escape') { + this.editingIndex = -1 + this.render() + } + e.stopPropagation() + }) + input.addEventListener('blur', () => this.commitRename(idx, input.value)) + tabEl.appendChild(input) + this.tabBar.appendChild(tabEl) + // focus + select after it's in the DOM + requestAnimationFrame(() => { + input.focus() + input.select() + }) + return + } + + const label = document.createElement('span') + label.className = 'tab-label' + label.textContent = title + label.addEventListener('click', () => this.activate(idx)) + label.addEventListener('dblclick', () => { + this.editingIndex = idx + this.render() + }) + tabEl.appendChild(label) + + const close = document.createElement('button') + close.className = 'tab-close' + close.textContent = '×' + close.title = 'Close tab (the shell keeps running on the server)' + close.setAttribute('aria-label', 'Close tab') + close.addEventListener('click', (e) => { + e.stopPropagation() + this.closeTab(idx) + }) + tabEl.appendChild(close) + + this.tabBar.appendChild(tabEl) + }) + + const add = document.createElement('button') + add.className = 'tab-add' + add.textContent = '+' + add.title = 'New session' + add.setAttribute('aria-label', 'New session') + add.addEventListener('click', () => this.newTab()) + this.tabBar.appendChild(add) + } +} diff --git a/public/terminal-session.ts b/public/terminal-session.ts new file mode 100644 index 0000000..e01b980 --- /dev/null +++ b/public/terminal-session.ts @@ -0,0 +1,257 @@ +/** + * public/terminal-session.ts — one independent terminal session. + * + * Encapsulates a single xterm Terminal + its own WebSocket + reconnect state. + * One per tab (1 WS ↔ 1 server session; the backend manager already supports + * many concurrent sessions, so tabs need no protocol/backend change). + * + * No console.log — status is written as ANSI to the terminal. + */ + +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import type { ClientMessage, ServerMessage } from '../src/types.js' + +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` +} + +function buildMessage(msg: ClientMessage): string { + return JSON.stringify(msg) +} + +/** M6: scheme follows page protocol so HTTPS/Tailscale deploys use wss. */ +function buildWsUrl(): string { + const scheme = location.protocol === 'https:' ? 'wss' : 'ws' + return `${scheme}://${location.host}/term` +} + +export interface TerminalSessionOpts { + /** Existing session to re-attach to, or null to spawn a fresh one. */ + sessionId: string | null + /** Called whenever the server assigns/confirms this session's id (persist it). */ + onSessionId: (id: string) => void + /** Optional: fired on output to an inactive tab (for an unread indicator). */ + onActivity?: () => void + /** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */ + onTitle?: (title: string) => void +} + +export class TerminalSession { + readonly el: HTMLDivElement + + private readonly term: Terminal + private readonly fitAddon: FitAddon + private readonly resizeObserver: ResizeObserver + private readonly onSessionId: (id: string) => void + private readonly onActivity: (() => void) | undefined + + private ws: WebSocket | null = null + private sessionId: string | null + private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 + private reconnectTimer: ReturnType | null = null + private resizeTimer: ReturnType | null = null + private exitListener: { dispose(): void } | null = null + private isConnecting = false + private disposed = false + private lastCols = 0 + private lastRows = 0 + + constructor(opts: TerminalSessionOpts) { + this.sessionId = opts.sessionId + this.onSessionId = opts.onSessionId + this.onActivity = opts.onActivity + + this.el = document.createElement('div') + this.el.className = 'term-pane' + this.el.style.display = 'none' + + this.term = new Terminal({ + scrollback: 5000, + fontFamily: 'Menlo, Consolas, monospace', + theme: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' }, + }) + this.fitAddon = new FitAddon() + this.term.loadAddon(this.fitAddon) + this.term.open(this.el) + + this.term.onData((data) => this.send(data)) + + this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) + this.resizeObserver.observe(this.el) + } + + /** Current session id (null until the server assigns one). */ + get id(): string | null { + return this.sessionId + } + + /** fit() only when the pane has real dimensions (display:none → NaN, §9). */ + private safefit(): { cols: number; rows: number } | null { + try { + this.fitAddon.fit() + const { cols, rows } = this.term + if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) { + return null + } + return { cols, rows } + } catch { + return null + } + } + + private sendResize(cols: number, rows: number): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return + if (cols === this.lastCols && rows === this.lastRows) return + this.lastCols = cols + this.lastRows = rows + this.ws.send(buildMessage({ type: 'resize', cols, rows })) + } + + private scheduleResize(): void { + if (this.el.style.display === 'none') return // hidden tab: skip (fit→NaN) + if (this.resizeTimer !== null) clearTimeout(this.resizeTimer) + this.resizeTimer = setTimeout(() => { + this.resizeTimer = null + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + }, 100) + } + + connect(): void { + if (this.disposed || this.isConnecting) return + this.isConnecting = true + this.term.write(statusLine(`${YELLOW}Connecting…${RESET}`)) + + const socket = new WebSocket(buildWsUrl()) + this.ws = socket + + socket.addEventListener('open', () => { + this.reconnectDelay = 1000 + this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) + socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) + }) + + socket.addEventListener('message', (event: MessageEvent) => { + let msg: ServerMessage + try { + msg = JSON.parse(event.data) as ServerMessage + } catch { + return + } + this.handle(msg, socket) + }) + + socket.addEventListener('close', () => { + this.isConnecting = false + this.ws = null + if (!this.disposed) this.scheduleReconnect() + }) + + socket.addEventListener('error', () => { + this.isConnecting = false + }) + } + + private handle(msg: ServerMessage, socket: WebSocket): void { + switch (msg.type) { + case 'attached': { + this.sessionId = msg.sessionId + this.onSessionId(msg.sessionId) + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + break + } + case 'output': { + this.term.write(msg.data) + if (this.el.style.display === 'none') this.onActivity?.() + break + } + case 'exit': { + const reason = msg.reason ? ` (${msg.reason})` : '' + this.term.write( + statusLine( + `${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` + + `\r\n${DIM}Press Enter to reconnect…${RESET}`, + ), + ) + if (this.exitListener) this.exitListener.dispose() + this.exitListener = this.term.onData((data) => { + if (data === '\r') { + this.exitListener?.dispose() + this.exitListener = null + this.sessionId = null // spawn a fresh session on reconnect + socket.close() + this.connect() + } + }) + break + } + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const delay = this.reconnectDelay + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000) + this.term.write( + statusLine(`${YELLOW}Disconnected — reconnecting in ${(delay / 1000).toFixed(0)}s…${RESET}`), + ) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + } + + /** Send raw bytes as input (used by term.onData and the key bar). */ + send(data: string): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(buildMessage({ type: 'input', data })) + } + + /** Make this pane visible, fit it, and focus it. */ + show(): void { + this.el.style.display = 'block' + requestAnimationFrame(() => { + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + this.term.focus() + }) + } + + hide(): void { + this.el.style.display = 'none' + } + + /** Tear down: closing the WS detaches — the server PTY keeps running. */ + dispose(): void { + this.disposed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + if (this.resizeTimer !== null) { + clearTimeout(this.resizeTimer) + this.resizeTimer = null + } + this.resizeObserver.disconnect() + if (this.ws !== null) { + try { + this.ws.close() + } catch { + // ignore + } + this.ws = null + } + this.term.dispose() + this.el.remove() + } +} diff --git a/test/keybar.test.ts b/test/keybar.test.ts index cb64d94..d724c63 100644 --- a/test/keybar.test.ts +++ b/test/keybar.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect } from 'vitest' -import { KEY_MAP } from '../public/keybar.js' +import { KEY_MAP, KEYBAR_BUTTONS } from '../public/keybar.js' describe('keybar', () => { describe('KEY_MAP — pure byte mappings', () => { @@ -73,7 +73,12 @@ describe('keybar', () => { expect(KEY_MAP.tab.charCodeAt(0)).toBe(0x09) }) - it('all 9 keys are defined', () => { + it('slash maps to / (Claude Code slash-command launcher)', () => { + expect(KEY_MAP.slash).toBe('/') + expect(KEY_MAP.slash).toHaveLength(1) + }) + + it('all 10 keys are defined', () => { const keys = Object.keys(KEY_MAP) expect(keys).toEqual([ 'esc', @@ -85,13 +90,14 @@ describe('keybar', () => { 'enter', 'ctrlC', 'tab', + 'slash', ]) }) 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 + expect(bytes).toHaveLength(10) + expect(new Set(bytes).size).toBe(10) // all unique }) it('ANSI arrow sequences use correct format', () => { @@ -103,4 +109,24 @@ describe('keybar', () => { }) }) }) + + describe('KEYBAR_BUTTONS — layout', () => { + it('every button references a valid KEY_MAP key', () => { + for (const b of KEYBAR_BUTTONS) { + expect(KEY_MAP[b.keyName]).toBeDefined() + } + }) + + it('Esc is first and marked primary (most-used interrupt)', () => { + expect(KEYBAR_BUTTONS[0]?.keyName).toBe('esc') + expect(KEYBAR_BUTTONS[0]?.primary).toBe(true) + }) + + it('exposes all Claude Code keys as buttons', () => { + const keys = KEYBAR_BUTTONS.map((b) => b.keyName) + expect(keys).toContain('shiftTab') + expect(keys).toContain('slash') + expect(keys).toContain('arrowUp') + }) + }) })