Files
web-terminal/public/tabs.ts
Yaojia Wang 4f66016f02 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.
2026-06-17 12:11:18 +02:00

256 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}
}