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