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:
@@ -8,6 +8,7 @@
|
|||||||
<link rel="stylesheet" href="./style.css">
|
<link rel="stylesheet" href="./style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="tabbar"></div>
|
||||||
<div id="term"></div>
|
<div id="term"></div>
|
||||||
<div id="keybar"></div>
|
<div id="keybar"></div>
|
||||||
<script type="module" src="./build/main.js"></script>
|
<script type="module" src="./build/main.js"></script>
|
||||||
|
|||||||
@@ -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
|
* 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):
|
* Order/selection follows Claude Code mobile habits — the keys a phone soft
|
||||||
* Esc → \x1b (0x1B)
|
* keyboard can't easily produce, most-used first (leftmost = no scroll needed):
|
||||||
* Shift+Tab → \x1b[Z
|
* Esc \x1b interrupt Claude / dismiss (primary — most used)
|
||||||
* ↑ → \x1b[A
|
* Shift+Tab \x1b[Z cycle plan / auto-accept mode
|
||||||
* ↓ → \x1b[B
|
* ↑ ↓ \x1b[A/B select menu option / history
|
||||||
* ← → \x1b[D
|
* Enter \r confirm
|
||||||
* → → \x1b[C
|
* Ctrl+C \x03 cancel / quit
|
||||||
* Enter → \r (0x0D, not \n)
|
* Tab \t complete / toggle
|
||||||
* Ctrl+C → \x03 (0x03)
|
* ← → \x1b[D/C cursor
|
||||||
* Tab → \t (0x09)
|
* / / slash-command launcher
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MountKeybar } from '../src/types.js'
|
import type { MountKeybar } from '../src/types.js'
|
||||||
@@ -29,46 +29,47 @@ export const KEY_MAP = {
|
|||||||
enter: '\r',
|
enter: '\r',
|
||||||
ctrlC: '\x03',
|
ctrlC: '\x03',
|
||||||
tab: '\t',
|
tab: '\t',
|
||||||
|
slash: '/',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type KeyName = keyof typeof KEY_MAP
|
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. */
|
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
|
||||||
export const mountKeybar: MountKeybar = (onSend) => {
|
export const mountKeybar: MountKeybar = (onSend) => {
|
||||||
const keybarEl = document.getElementById('keybar')
|
const keybarEl = document.getElementById('keybar')
|
||||||
if (!keybarEl) return
|
if (!keybarEl) return
|
||||||
|
|
||||||
// Button labels and their key map entries
|
for (const { label, keyName, primary } of KEYBAR_BUTTONS) {
|
||||||
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')
|
const btn = document.createElement('button')
|
||||||
btn.textContent = label
|
btn.textContent = label
|
||||||
btn.classList.add('keybar-btn')
|
btn.classList.add('keybar-btn')
|
||||||
|
if (primary) btn.classList.add('keybar-btn-primary')
|
||||||
btn.dataset.key = keyName
|
btn.dataset.key = keyName
|
||||||
|
btn.setAttribute('aria-label', label)
|
||||||
|
|
||||||
const bytes = KEY_MAP[keyName]
|
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) => {
|
btn.addEventListener('touchstart', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSend(bytes)
|
onSend(bytes)
|
||||||
})
|
})
|
||||||
|
|
||||||
// For desktop testing/fallback: click also sends
|
// Desktop fallback: click also sends.
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSend(bytes)
|
onSend(bytes)
|
||||||
|
|||||||
275
public/main.ts
275
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:
|
* Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal
|
||||||
* - xterm.js Terminal + FitAddon initialization, mounted to #term
|
* panes (#term), each tab an independent TerminalSession (own WS + server
|
||||||
* - WebSocket client with scheme following page protocol (M6)
|
* session). The mobile key bar routes input to the active tab.
|
||||||
* - 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.
|
* 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).
|
// CSS side-effect import processed by esbuild (→ public/build/main.css).
|
||||||
// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic.
|
// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic.
|
||||||
// The import is intentional and required per PLAN §1 / ARCHITECTURE §5.
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { Terminal } from '@xterm/xterm'
|
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
|
||||||
import { mountKeybar } from './keybar.js'
|
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 app = new TabApp(paneHost, tabBar)
|
||||||
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 {
|
// Key bar (mobile) sends to whichever tab is active.
|
||||||
return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n`
|
mountKeybar((data) => app.sendToActive(data))
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── 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()
|
|
||||||
|
|||||||
164
public/style.css
164
public/style.css
@@ -1,6 +1,20 @@
|
|||||||
/* Web Terminal Styling */
|
/* 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 {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -11,24 +25,128 @@ html, body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#term {
|
/* ── Tab bar (top) ───────────────────────────────────────────────── */
|
||||||
position: absolute;
|
#tabbar {
|
||||||
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 60px;
|
height: var(--tabbar-h);
|
||||||
width: 100%;
|
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;
|
background-color: #1a1a1a;
|
||||||
overflow: hidden;
|
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 {
|
#keybar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: var(--keybar-h);
|
||||||
background-color: #2a2a2a;
|
background-color: #2a2a2a;
|
||||||
border-top: 1px solid #3a3a3a;
|
border-top: 1px solid #3a3a3a;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -37,21 +155,24 @@ html, body {
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keybar buttons */
|
|
||||||
#keybar button {
|
#keybar button {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
min-width: 50px;
|
min-width: 48px;
|
||||||
height: 60px;
|
height: var(--keybar-h);
|
||||||
padding: 0;
|
padding: 0 8px;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
font-size: 12px;
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.12s;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
@@ -61,13 +182,14 @@ html, body {
|
|||||||
background-color: #555;
|
background-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide keybar on desktop (viewport width > 768px) */
|
/* Esc is the most-used (interrupt) — make it prominent. */
|
||||||
@media (min-width: 769px) {
|
#keybar button.keybar-btn-primary {
|
||||||
#keybar {
|
background-color: #5a3a12;
|
||||||
display: none;
|
color: #ffcf8f;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#term {
|
#keybar button.keybar-btn-primary:active {
|
||||||
bottom: 0;
|
background-color: #7a4f18;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
255
public/tabs.ts
Normal file
255
public/tabs.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest'
|
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('keybar', () => {
|
||||||
describe('KEY_MAP — pure byte mappings', () => {
|
describe('KEY_MAP — pure byte mappings', () => {
|
||||||
@@ -73,7 +73,12 @@ describe('keybar', () => {
|
|||||||
expect(KEY_MAP.tab.charCodeAt(0)).toBe(0x09)
|
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)
|
const keys = Object.keys(KEY_MAP)
|
||||||
expect(keys).toEqual([
|
expect(keys).toEqual([
|
||||||
'esc',
|
'esc',
|
||||||
@@ -85,13 +90,14 @@ describe('keybar', () => {
|
|||||||
'enter',
|
'enter',
|
||||||
'ctrlC',
|
'ctrlC',
|
||||||
'tab',
|
'tab',
|
||||||
|
'slash',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('all mapped bytes are unique strings', () => {
|
it('all mapped bytes are unique strings', () => {
|
||||||
const bytes = Object.values(KEY_MAP)
|
const bytes = Object.values(KEY_MAP)
|
||||||
expect(bytes).toHaveLength(9)
|
expect(bytes).toHaveLength(10)
|
||||||
expect(new Set(bytes).size).toBe(9) // all unique
|
expect(new Set(bytes).size).toBe(10) // all unique
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ANSI arrow sequences use correct format', () => {
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user