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:
257
public/terminal-session.ts
Normal file
257
public/terminal-session.ts
Normal file
@@ -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<typeof setTimeout> | null = null
|
||||
private resizeTimer: ReturnType<typeof setTimeout> | 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<string>) => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user