/** * public/tabs.ts — multi-tab manager + tab bar UI. * * Each tab owns an independent TerminalSession (own WS + server session). * * Titles: auto from the terminal (current folder, via folderFromTitle) → * double-click to rename inline → falls back to "Term N". Manual wins. * Interaction: pointerdown to switch (robust with draggable), drag to reorder, * middle-click / × to close, status dot (connection color), unread dot. * * DOM is built once per tab and updated in place. A full rebuild happens ONLY * on structural changes (add / close / reorder / rename) — never on a plain * activate or on background output — so clicks are never eaten by a re-render. * * Closing a tab only disconnects its WS (a *detach*); the server PTY keeps * running and is reclaimed by IDLE_TTL. * * v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects") * that switches between the session launcher and the projects panel. State is * persisted to localStorage. openProject() spawns a new claude session in a * repo directory, labelled with the repo name (deduped with #N suffixes). */ import { TerminalSession } from './terminal-session.js' import { ICON_TIMELINE } from './icons.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' import { mountLauncher, type Launcher } from './launcher.js' import { mountProjects, type ProjectsPanel } from './projects.js' import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js' import { renderTelemetryGauge } from './preview-grid.js' import { mountPushToggle } from './push.js' import { mountQuickReply } from './quick-reply.js' import { mountTimeline, type TimelineHandle } from './timeline.js' import { createVoiceInput, type VoiceInput } from './voice.js' import { matchCommand, type VoiceMatchContext } from './voice-commands.js' import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js' import { type GridLayout, type GridToggle, isVisibleIndex, layoutCapacity, loadGridLayout, saveGridLayout, } from './grid-layout.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' const HOME_VIEW_KEY = 'web-terminal:home-view' const PERMISSION_MODE_KEY = 'web-terminal:permission-mode' /** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out * (class `tg-stale`) once telemetry is older than this (B2). */ const STATUSLINE_TTL_MS = 30_000 /** VC decision C: how long the "未连接" not-connected flash stays visible. */ const VOICE_NOT_CONNECTED_FLASH_MS = 1500 /** All `--permission-mode` values (B4). 'auto' is high-risk and only offered * when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */ const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto'] type HomeView = 'sessions' | 'projects' interface TabEntry { session: TerminalSession customTitle: string | null // user-set; null = use auto/fallback (persisted) autoTitle: string | null // current folder from the terminal title hasActivity: boolean // inactive tab got output since last viewed el: HTMLDivElement | null // the .tab element (updated in place) // Split-grid: the .term-cell wrapper around session.el (header + terminal + // optional inline-approve footer). One per tab; the grid lays these out. cell: HTMLDivElement | null // A4: live timeline handle while this tab's timeline is open (disposed on // switch/close). Telemetry is NOT cached here — refreshTab reads the single // source of truth session.telemetry (review #15). timelineHandle: TimelineHandle | null } interface StoredTab { id: string | null title: string | null } /** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */ function claudeIcon(cs: ClaudeStatus): string { if (cs === 'working') return '⚙' if (cs === 'waiting') return '⏳' if (cs === 'idle') return '✓' if (cs === 'stuck') return '⚠' return '' } export class TabApp { private readonly tabs: TabEntry[] = [] private activeIndex = -1 private editingIndex = -1 private dragIndex = -1 private notifyAsked = false private settings: Settings = DEFAULT_SETTINGS private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement private readonly approvalBar: HTMLDivElement private readonly launcher: Launcher private readonly projects: ProjectsPanel private homeView: HomeView = 'sessions' private readonly segControl: HTMLElement // Split-grid ("watch board") state. Layout persists in localStorage; the // toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed // back via gridToggle. 'single' preserves the original one-pane behavior. private gridLayout: GridLayout = 'single' private gridToggle: GridToggle | null = null // When true, the home chooser is overlaid on top of the open tabs (so you can // jump back to Sessions/Projects without closing anything). Reset whenever a // tab is activated. Irrelevant while no tab is open (home shows regardless). private homeForced = false private homeBtn: HTMLButtonElement | null = null // B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the // high-risk 'auto' permission mode is hidden/refused (SEC-M5). private allowAutoMode = false private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time) private timelineOpen = false private timelineBtn: HTMLButtonElement | null = null private voice: VoiceInput | null = null // A2: push-to-talk recognizer private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay // VC: context-gated confirm/reject command mapping (PLAN_VOICE_COMMANDS.md). private readonly approveConfirm: ApproveConfirm = createApproveConfirm() // decision B private voiceConfirmOverlay: HTMLElement | null = null // "听到:批准" / "未连接" (B, C) private voicePttSession: TerminalSession | null = null // session captured at PTT-start (safeguard 5) private voicePttEpoch = 0 // session.pendingEpoch captured at PTT-start (safeguard 5) constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost this.tabBar = tabBar this.approvalBar = document.createElement('div') this.approvalBar.id = 'approvalbar' this.approvalBar.style.display = 'none' document.body.appendChild(this.approvalBar) this.launcher = mountLauncher(this.paneHost, { onOpen: (id) => this.openSession(id), onNew: () => this.newTab(), }) // Load persisted home view choice. try { const stored = localStorage.getItem(HOME_VIEW_KEY) if (stored === 'sessions' || stored === 'projects') this.homeView = stored } catch { // localStorage unavailable — use default } this.projects = mountProjects(this.paneHost, { onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd), onEnterSession: (id) => this.openSession(id), }) this.segControl = this.buildSegControl() this.paneHost.appendChild(this.segControl) // Split-grid: restore the persisted layout (forced to 'single' on screens // too narrow for a multi-pane board — see loadGridLayout). this.gridLayout = loadGridLayout() // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds): this.setupPushToggle() // A1 🔔 this.setupQuickReply() // A3 chips above the key bar this.setupTimelinePanel() // A4 activity timeline this.setupVoiceOverlay() // A2 interim-transcript overlay void this.loadUiConfig() // B4 allowAutoMode gate (best-effort) // v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen; // the user picks which session to open. Sessions persist server-side, so // opening one replays its full scrollback. this.rebuild() this.updateHomeView() } /* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */ /** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */ private setupPushToggle(): void { this.pushHost = document.createElement('div') this.pushHost.className = 'push-host' mountPushToggle(this.pushHost) } /** A3: quick-reply chips row, inserted directly above the #keybar. */ private setupQuickReply(): void { const host = document.createElement('div') host.id = 'quickreply' const keybar = document.getElementById('keybar') if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar) else document.body.appendChild(host) mountQuickReply(host, { onSend: (data) => this.sendToActive(data) }) } /** A4: hidden activity-timeline panel; toggled per active session. */ private setupTimelinePanel(): void { this.timelinePanel = document.createElement('div') this.timelinePanel.id = 'timeline-panel' this.timelinePanel.style.display = 'none' document.body.appendChild(this.timelinePanel) } /** A2: overlay that shows the live interim transcript while dictating. VC: a * SEPARATE overlay carries "听到:批准" / "未连接" so hideInterim() (which * fires on PTT release) never clobbers it mid-confirm-window (§6). */ private setupVoiceOverlay(): void { const overlay = document.createElement('div') overlay.id = 'voice-interim' overlay.style.display = 'none' document.body.appendChild(overlay) this.voiceOverlay = overlay const confirmOverlay = document.createElement('div') confirmOverlay.id = 'voice-confirm' confirmOverlay.style.display = 'none' // Tap-to-cancel an armed approve confirm window (§4 cancel trigger). confirmOverlay.addEventListener('click', () => this.cancelArmedApprove()) document.body.appendChild(confirmOverlay) this.voiceConfirmOverlay = confirmOverlay } /** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */ private async loadUiConfig(): Promise { try { if (typeof fetch === 'undefined') return const res = await fetch('/config/ui', { credentials: 'same-origin' }) if (!res.ok) return const data: unknown = await res.json() if ( data !== null && typeof data === 'object' && typeof (data as Record)['allowAutoMode'] === 'boolean' ) { this.allowAutoMode = (data as UiConfig).allowAutoMode } } catch { // best-effort — leave allowAutoMode false (auto hidden) on any failure } } /* ── Home-view visibility ────────────────────────────────────────── */ /** * Controls visibility of the segmented control and both home sub-panels. * When tabs are open: hide everything (the terminal panes own the screen). * When no tab is open: show the seg control and the selected sub-panel. */ private updateHomeView(): void { // Home shows when there is no tab OR the user explicitly forced it (the ⌂ // button), letting you reach Sessions/Projects without closing a session. const showHome = this.tabs.length === 0 || this.homeForced // Sync active-class on segmented control buttons. const btns = this.segControl.querySelectorAll('.home-seg-btn') btns.forEach((btn, i) => { const view: HomeView = i === 0 ? 'sessions' : 'projects' btn.classList.toggle('active', view === this.homeView) }) // The ⌂ button is "pressed" only while overlaying home on top of open tabs. this.homeBtn?.classList.toggle('active', this.homeForced && this.tabs.length > 0) // Hide the bottom key-bar on the home chooser — it only sends keys to an // active terminal, so it's noise on the Sessions/Projects views. document.body.classList.toggle('home-open', showHome) if (showHome) { // Overlay home: the chooser is on top; applyLayout hides every pane below. this.segControl.style.display = 'flex' this.launcher.setVisible(this.homeView === 'sessions') this.projects.setVisible(this.homeView === 'projects') } else { this.segControl.style.display = 'none' this.launcher.setVisible(false) this.projects.setVisible(false) } // Pane/cell visibility, the grid layout class, focus ring and placeholders // are all owned by applyLayout — home just decides whether panes show at all. this.applyLayout(showHome) } /** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op * when no tab is open (home is already showing). */ private toggleHome(): void { if (this.tabs.length === 0) return this.homeForced = !this.homeForced if (this.homeForced) { this.updateHomeView() } else { // Return to the active terminal (activate re-shows its pane + hides home). this.activate(this.activeIndex) } } private saveHomeView(): void { try { localStorage.setItem(HOME_VIEW_KEY, this.homeView) } catch { // localStorage unavailable } } private buildSegControl(): HTMLElement { const seg = document.createElement('div') seg.className = 'home-seg' seg.style.display = 'none' // updateHomeView() will show it when needed const sessBtn = document.createElement('button') sessBtn.className = 'home-seg-btn' sessBtn.textContent = 'Sessions' sessBtn.addEventListener('click', () => { this.homeView = 'sessions' this.saveHomeView() this.updateHomeView() }) const projBtn = document.createElement('button') projBtn.className = 'home-seg-btn' projBtn.textContent = 'Projects' projBtn.addEventListener('click', () => { this.homeView = 'projects' this.saveHomeView() this.updateHomeView() }) seg.append(sessBtn, projBtn) return seg } /* ── Approval bar ───────────────────────────────────────────────── */ /** Show/hide the approve/reject banner (H3). B4: a 'plan' gate renders THREE * choices; an ordinary 'tool' gate keeps the two buttons (no regression). */ private updateApprovalBar(): void { const session = this.tabs[this.activeIndex]?.session if (!session || !session.pendingApproval) { this.approvalBar.style.display = 'none' return } this.approvalBar.replaceChildren() const label = document.createElement('span') label.className = 'approval-label' if (session.pendingGate === 'plan') { label.textContent = 'Claude finished planning — how should it proceed?' this.approvalBar.append(label, ...this.planGateButtons(session)) } else { label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` this.approvalBar.append(label, ...this.toolGateButtons(session)) } this.approvalBar.style.display = 'flex' } /** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */ private toolGateButtons(session: TerminalSession): HTMLButtonElement[] { return [ this.approvalButton('approval-yes', '✓ Approve', () => session.approve()), this.approvalButton('approval-no', '✗ Reject', () => session.reject()), ] } /** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep * Planning (reject — stays in plan mode). Three buttons. */ private planGateButtons(session: TerminalSession): HTMLButtonElement[] { return [ this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')), this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')), this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()), ] } /** Build one approval-bar button that runs `onClick` then re-renders the bar. */ private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement { const btn = document.createElement('button') btn.className = cls btn.textContent = text btn.addEventListener('click', () => { onClick() this.updateApprovalBar() }) return btn } /* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */ /** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via * `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */ handleVoiceTrigger(action: 'start' | 'stop'): void { if (action === 'start') this.startVoice() else this.stopVoice() } private startVoice(): void { if (!this.voice) { this.voice = createVoiceInput( (text, confidence) => this.handleVoiceTranscript(text, confidence), { onInterim: (t) => this.showInterim(t) }, ) } if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3) // VC safeguard 5: bind this utterance to the CURRENTLY active session/epoch // so a slow transcript can't approve a newer gate or a different tab. const active = this.tabs[this.activeIndex]?.session ?? null this.voicePttSession = active this.voicePttEpoch = active?.pendingEpoch ?? 0 this.showInterim('') this.voice.start() } private stopVoice(): void { this.voice?.stop() this.hideInterim() } private showInterim(text: string): void { if (!this.voiceOverlay) return this.voiceOverlay.textContent = text // textContent — transcript is untrusted this.voiceOverlay.style.display = 'block' } private hideInterim(): void { if (this.voiceOverlay) this.voiceOverlay.style.display = 'none' } /** * VC — dispatch a final voice transcript (PLAN_VOICE_COMMANDS.md §6): * context-gated confirm/reject resolve the held permission via the existing * approve()/reject() WS channel; everything else (including a stale gate or * a tab switch mid-utterance) falls through to ordinary dictation, byte-for- * byte unchanged. Raw bytes are NEVER synthesized for approve/reject. */ private handleVoiceTranscript(text: string, confidence?: number): void { // A NEW utterance supersedes any pending approve confirm window: the user // has spoken again, so never let a stale timer commit behind a fresh // command (this is the "say another word" cancel trigger, §4). Combined // with tap-to-cancel and the commit-time re-check below, it closes the // TOCTOU race where a delayed approve could resolve a different gate. this.cancelArmedApprove() const session = this.tabs[this.activeIndex]?.session if (!session) { this.sendToActive(text) return } const ctx: VoiceMatchContext = { pendingApproval: session.pendingApproval, gate: session.pendingGate, confidence, } const action = matchCommand(text, ctx) // Stale-gate guard: a command can only resolve the SAME session+gate it // was spoken against; anything else (tab switch, a new pending flip) is // downgraded to plain dictation rather than silently dropped. const stale = session !== this.voicePttSession || session.pendingEpoch !== this.voicePttEpoch if (action !== 'text' && stale) { this.sendToActive(text) return } if (action === 'reject') { if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS) session.reject() this.updateApprovalBar() return } if (action === 'approve') { if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS) // Decision B: arm a cancellable window instead of committing immediately. // The commit RE-VALIDATES the gate identity (TOCTOU fix): if the held // permission resolved and a NEW gate was raised during the ~1.5s window // (pendingEpoch bumps), or the tab/WS changed, drop the approve — never // silently approve a different, unseen/unheard gate. const armedSession = session const armedEpoch = session.pendingEpoch this.approveConfirm.arm( () => this.flashVoiceStatus('听到:批准(点此取消)'), () => { this.hideVoiceStatus() const fresh = armedSession.pendingApproval && armedSession.pendingGate === 'tool' && armedSession.pendingEpoch === armedEpoch && this.isVoiceConnected(armedSession) if (!fresh) return // gate changed / WS dropped during the window → drop armedSession.approve() this.updateApprovalBar() }, ) return } this.sendToActive(text) // UNCHANGED dictation path } /** Cancel a still-armed approve confirm window and clear its overlay. Invoked * on a new utterance, a reject, and a tap on the overlay (§4 cancel triggers). */ private cancelArmedApprove(): void { if (!this.approveConfirm.isArmed()) return this.approveConfirm.cancel() this.hideVoiceStatus() } /** Decision C: approve()/reject() are silently dropped by sendMsg when the WS * isn't open — flash feedback instead of a silent no-op. */ private isVoiceConnected(session: TerminalSession): boolean { return session.status === 'connected' } /** Transient voice-status text ("听到:批准" / "未连接") on a DIFFERENT overlay * than the interim transcript, so hideInterim() (PTT release) never clears * it mid-confirm-window. XSS-safe: textContent only. */ private flashVoiceStatus(text: string, autoHideMs?: number): void { if (!this.voiceConfirmOverlay) return this.voiceConfirmOverlay.textContent = text this.voiceConfirmOverlay.style.display = 'block' if (autoHideMs !== undefined) setTimeout(() => this.hideVoiceStatus(), autoHideMs) } private hideVoiceStatus(): void { if (this.voiceConfirmOverlay) this.voiceConfirmOverlay.style.display = 'none' } /** Toggle the activity-timeline panel for the active session (A4). */ private toggleTimeline(): void { this.timelineOpen = !this.timelineOpen if (this.timelineOpen) this.openTimelineForActive() else this.closeTimeline() this.timelineBtn?.classList.toggle('active', this.timelineOpen) } /** Mount the timeline for the active session (one panel at a time, A4). */ private openTimelineForActive(): void { for (const t of this.tabs) { t.timelineHandle?.dispose() t.timelineHandle = null } this.timelinePanel.style.display = 'block' this.timelinePanel.replaceChildren() const entry = this.tabs[this.activeIndex] const id = entry?.session.id if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id) } private closeTimeline(): void { this.timelinePanel.style.display = 'none' for (const t of this.tabs) { t.timelineHandle?.dispose() t.timelineHandle = null } } /** Permission modes the user may pick (B4). 'auto' is filtered out unless the * server allows it (SEC-M5). */ availablePermissionModes(): PermissionMode[] { return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode) } /** B4: persisted default permission mode; 'auto' downgrades to 'default' when * the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */ loadDefaultMode(): PermissionMode { let mode: PermissionMode = 'default' try { const stored = localStorage.getItem(PERMISSION_MODE_KEY) if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) { mode = stored as PermissionMode } } catch { // localStorage unavailable — use 'default' } return this.resolveMode(mode) } /** Persist the chosen default permission mode (B4-FR7). */ saveDefaultMode(mode: PermissionMode): void { try { localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { // localStorage unavailable — run without persistence } } /** SEC-M5: never honor 'auto' when the server forbids it. */ private resolveMode(mode: PermissionMode): PermissionMode { return mode === 'auto' && !this.allowAutoMode ? 'default' : mode } /** B4: `claude` launch command for a permission mode (default → plain claude). */ private buildClaudeCmd(mode: PermissionMode): string { return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r` } private displayTitle(entry: TabEntry, idx: number): string { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } /* ── persistence ─────────────────────────────────────────────────── */ 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 } } /** The active tab's server session id (null until it has attached). */ activeSessionId(): string | null { return this.tabs[this.activeIndex]?.session.id ?? null } /** Focus the tab for `sessionId`, or open one that joins it (share-link target). */ openSession(sessionId: string): void { const existing = this.tabs.findIndex((t) => t.session.id === sessionId) if (existing >= 0) { this.activate(existing) return } this.addEntry(sessionId, null) this.persist() this.rebuild() this.activate(this.tabs.length - 1) } /* ── tab lifecycle ───────────────────────────────────────────────── */ private addEntry( sessionId: string | null, customTitle: string | null, cwd?: string, initialInput?: string, ): TabEntry { // Build the session FIRST so `entry` is never typed with a null session. // The callbacks below capture `entry` by reference; they only fire after // construction (async), by which point `entry` is assigned. let entry: TabEntry const session = new TerminalSession({ sessionId, ...(cwd !== undefined ? { cwd } : {}), ...(initialInput !== undefined ? { initialInput } : {}), onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { entry.hasActivity = true this.refreshTab(entry) }, onTitle: (title) => { entry.autoTitle = title.trim() || null this.refreshTab(entry) }, onStatus: () => this.refreshTab(entry), onClaudeStatus: (status) => { this.refreshTab(entry) this.updateApprovalBar() // Notify only when a tab that needs approval is NOT on screen. In a split // grid an on-board quadrant is visible and glows amber in place, so an OS // notification would be redundant — but a pane hidden behind the ⌂ home // overlay is NOT on screen even if isVisible() (which only knows the // layout) would say so, so factor in homeForced too. const onScreen = !this.homeForced && this.isVisible(this.tabs.indexOf(entry)) if (status === 'waiting' && !onScreen) { this.notify(entry) } }, // B2: telemetry is the single source of truth on the session; just re-render. onTelemetry: () => this.refreshTab(entry), // Split-grid: clicking anywhere in this pane makes it the focused quadrant. onFocus: () => this.setFocused(this.tabs.indexOf(entry)), }) entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null, cell: null, timelineHandle: null, } // Wrap the pane in a grid cell (header + terminal + optional inline-approve). // The header is hidden by CSS in single mode, so the classic one-pane look is // unchanged; in a grid it labels each quadrant and carries the focus ring. const cell = document.createElement('div') cell.className = 'term-cell' cell.style.display = 'none' const head = this.buildCellHead() head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry))) cell.append(head, session.el) entry.cell = cell this.paneHost.appendChild(cell) this.tabs.push(entry) session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) session.connect() return entry } newTab(): void { // M6: open the new tab in the active tab's current directory, if known. const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined this.addEntry(null, null, cwd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) } /** O2: open a new tab in `cwd` and run `claude --resume `. */ newTabForResume(cwd: string, sessionId: string): void { this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`) this.persist() this.rebuild() this.activate(this.tabs.length - 1) } /** * v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`. * If tabs with the same base name already exist, appends a #N suffix to * distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in * structure: addEntry → persist → rebuild → activate. */ openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void { const n = this.countOpenWithTitlePrefix(repoName) const label = n === 0 ? repoName : `${repoName} #${n + 1}` // B4-FR2: when a permission mode is chosen, upgrade the launch command to // `claude --permission-mode ` (non-default only); 'auto' is gated (M5). const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd this.addEntry(null, label, repoPath || undefined, effectiveCmd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) } /** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */ private countOpenWithTitlePrefix(name: string): number { return this.tabs.filter( (t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true, ).length } activate(i: number): void { if (i < 0 || i >= this.tabs.length) return // Board-aware focus: in a split grid the focused pane must be ON the board, // else keybar/voice/approval would target an invisible (display:none) session // with no focus ring. Pull an off-board tab onto the last slot first, then // resolve its new index. (Single mode has no board, so this is a no-op.) if (this.gridLayout !== 'single' && !this.isVisible(i)) { const entry = this.tabs[i] this.moveTab(i, layoutCapacity(this.gridLayout) - 1) i = entry ? this.tabs.indexOf(entry) : layoutCapacity(this.gridLayout) - 1 } this.maybeAskNotify() // first switch is a user gesture — request notif permission this.homeForced = false // showing a terminal dismisses any home overlay this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing clears the unread dot this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild // Pane show/hide + focus is owned by applyLayout (via updateHomeView): in // single mode only the active pane shows; in a grid the first-N show and the // active one is the focused quadrant. this.updateHomeView() // hide the seg control + home panels now a tab is active this.updateApprovalBar() if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session this.persist() } closeTab(i: number): void { if (i < 0 || i >= this.tabs.length) return const [entry] = this.tabs.splice(i, 1) entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab entry?.session.dispose() // removes session.el (the .term-pane) entry?.cell?.remove() // also drop the grid cell wrapper if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { // v0.5: closing the last tab returns to the home screen — no auto-blank tab. this.activeIndex = -1 this.homeForced = false // back to the natural "no tabs → home" state this.persist() this.rebuild() // updateHomeView() (in rebuild) shows the chooser this.updateApprovalBar() return } const next = Math.min(i, this.tabs.length - 1) this.persist() this.rebuild() this.activate(next) } private moveTab(from: number, to: number): void { if (from < 0 || to < 0 || from === to || from >= this.tabs.length || to >= this.tabs.length) { return } const activeEntry = this.tabs[this.activeIndex] const [moved] = this.tabs.splice(from, 1) if (!moved) return this.tabs.splice(to, 0, moved) if (activeEntry) this.activeIndex = this.tabs.indexOf(activeEntry) this.persist() this.rebuild() } 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.rebuild() } /** Route key-bar input to the currently active tab. */ sendToActive(data: string): void { this.tabs[this.activeIndex]?.session.send(data) } /** Re-assert the active tab's size (latest-writer-wins) when this device * regains focus, so a shared session snaps back to this screen's size. */ refitActive(): void { this.tabs[this.activeIndex]?.session.refit() } /** Apply theme + font settings to every terminal (M3). */ applySettings(s: Settings): void { this.settings = s const theme = THEMES[s.theme] ?? THEMES['dark']! for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize) } /** A read-only view of all tabs for the dashboard (M7). */ snapshot(): Array<{ idx: number title: string conn: string claude: string active: boolean pending: boolean }> { return this.tabs.map((t, idx) => ({ idx, title: this.displayTitle(t, idx), conn: t.session.status, claude: t.session.claudeStatus, active: idx === this.activeIndex, pending: t.session.pendingApproval, })) } focusTab(idx: number): void { this.activate(idx) } /** Scrollback search in the active tab (M1). */ findInActive(query: string, dir: 'next' | 'prev'): void { const s = this.tabs[this.activeIndex]?.session if (!s) return if (dir === 'next') s.findNext(query) else s.findPrevious(query) } clearActiveSearch(): void { this.tabs[this.activeIndex]?.session.clearSearch() } /** Ask for notification permission once, on a user gesture (H2/H4). */ private maybeAskNotify(): void { if (this.notifyAsked) return this.notifyAsked = true if (typeof Notification !== 'undefined' && Notification.permission === 'default') { void Notification.requestPermission() } } /** Browser notification for a background tab needing approval. */ private notify(entry: TabEntry): void { if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return const title = this.displayTitle(entry, this.tabs.indexOf(entry)) new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' }) } /* ── split-grid (watch board) ────────────────────────────────────── */ /** Whether tab `idx` is on-screen (the focused pane in single mode; any of the * first-N in a grid). Also gates OS-notification suppression. */ private isVisible(idx: number): boolean { return isVisibleIndex(idx, this.activeIndex, this.tabs.length, this.gridLayout) } /** Make tab `idx` the focused pane (via the board-aware activate). */ private setFocused(idx: number): void { if (idx < 0 || idx >= this.tabs.length) return if (idx === this.activeIndex) return // already focused — avoid churn this.activate(idx) // activate() pulls an off-board tab onto the grid first } /** The current split-grid layout (for the toolbar toggle). */ getGridLayout(): GridLayout { return this.gridLayout } /** Switch layout (from the toolbar toggle). Persists, keeps the focused pane * on-board under a smaller capacity, then re-renders. */ setGridLayout(layout: GridLayout): void { if (layout === this.gridLayout) { this.gridToggle?.refresh() return } this.gridLayout = layout saveGridLayout(layout) // Re-activate the focused pane under the new layout: activate() is board-aware, // so if the capacity shrank and the pane is now off-board it gets pulled on; // otherwise it's just a re-render. With no active tab (home), re-apply the class. if (this.activeIndex >= 0 && this.activeIndex < this.tabs.length) { this.activate(this.activeIndex) } else { this.updateHomeView() } this.gridToggle?.refresh() } /** Register the toolbar toggle so setGridLayout can re-sync its pressed state. */ setGridToggle(toggle: GridToggle): void { this.gridToggle = toggle } /** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device * regains focus — in a grid all visible quadrants reclaim size, not just the * focused one. */ refitVisible(): void { this.tabs.forEach((entry, idx) => { if (this.isVisible(idx)) entry.session.refit() }) } /** Show/hide panes, set the grid class, order cells, and render placeholders. * Single source of pane visibility (called via updateHomeView). */ private applyLayout(showHome: boolean): void { const layout: GridLayout = showHome ? 'single' : this.gridLayout this.paneHost.classList.remove('lay-single', 'lay-split-2', 'lay-grid-4') this.paneHost.classList.add(`lay-${layout}`) const cap = layoutCapacity(this.gridLayout) const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap) this.tabs.forEach((entry, idx) => { const cell = entry.cell if (!cell) return const visible = !showHome && this.isVisible(idx) cell.style.order = String(idx) // grid places visible cells in tab order if (visible) { cell.style.display = 'flex' entry.session.show({ focus: idx === this.activeIndex }) } else { cell.style.display = 'none' entry.session.hide() } this.renderCell(entry) }) // Fill empty grid slots (fewer sessions than the layout holds) with a // "+ New session" placeholder so the board reads as a fixed N-up grid. const placeholders = showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount) this.renderPlaceholders(placeholders, visibleCount) } /** Replace the placeholder cells with `count` new ones ordered after the panes. */ private renderPlaceholders(count: number, startOrder: number): void { this.paneHost.querySelectorAll('.term-cell.slot-empty').forEach((e) => e.remove()) for (let i = 0; i < count; i++) { const slot = document.createElement('div') slot.className = 'term-cell slot-empty' slot.style.order = String(startOrder + i) const btn = document.createElement('button') btn.type = 'button' btn.className = 'slot-new' const plus = document.createElement('b') plus.textContent = '+' const label = document.createElement('span') label.textContent = 'New session' btn.append(plus, label) btn.addEventListener('click', () => this.newTab()) slot.appendChild(btn) this.paneHost.appendChild(slot) } } /** Build a cell header (name + Claude-status chip). Hidden by CSS in single mode. */ private buildCellHead(): HTMLDivElement { const head = document.createElement('div') head.className = 'cell-head' const name = document.createElement('span') name.className = 'cell-name' const status = document.createElement('span') status.className = 'cell-status' head.append(name, status) return head } /** Sync one cell's header text, focus ring, pending glow, and inline approve. */ private renderCell(entry: TabEntry): void { const cell = entry.cell if (!cell) return const idx = this.tabs.indexOf(entry) const focused = idx === this.activeIndex const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0 cell.classList.toggle('focused', grid && focused) cell.classList.toggle('pending', grid && entry.session.pendingApproval) const nameEl = cell.querySelector('.cell-name') if (nameEl) nameEl.textContent = this.displayTitle(entry, idx) const chip = cell.querySelector('.cell-status') if (chip) { const cs = entry.session.claudeStatus chip.className = `cell-status cs-${cs}` const glyph = claudeIcon(cs) chip.textContent = cs === 'unknown' ? '' : glyph ? `${glyph} ${cs}` : cs } this.renderInlineApprove(entry, cell, idx, focused, grid) } /** Inline ✓/✗ footer for a visible NON-focused quadrant with a simple 'tool' * gate. The focused pane uses the global approval bar (which also handles the * 3-choice 'plan' gate); a non-focused 'plan' gate just glows amber — click to * focus and resolve via the bar. */ private renderInlineApprove( entry: TabEntry, cell: HTMLDivElement, idx: number, focused: boolean, grid: boolean, ): void { const session = entry.session const wantInline = grid && !focused && this.isVisible(idx) && session.pendingApproval && session.pendingGate === 'tool' let footer = cell.querySelector('.cell-approve') if (!wantInline) { footer?.remove() return } if (!footer) { footer = document.createElement('div') footer.className = 'cell-approve' footer.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't refocus const label = document.createElement('span') label.className = 'cell-approve-label' const btns = document.createElement('span') btns.className = 'cell-approve-btns' const yes = document.createElement('button') yes.type = 'button' yes.className = 'cell-approve-yes' yes.textContent = '✓' yes.title = 'Approve' yes.addEventListener('click', () => { session.approve() this.updateApprovalBar() this.renderCell(entry) }) const no = document.createElement('button') no.type = 'button' no.className = 'cell-approve-no' no.textContent = '✗' no.title = 'Reject' no.addEventListener('click', () => { session.reject() this.updateApprovalBar() this.renderCell(entry) }) btns.append(yes, no) footer.append(label, btns) cell.appendChild(footer) } const label = footer.querySelector('.cell-approve-label') if (label) label.textContent = session.pendingTool ? `Approve ${session.pendingTool}?` : 'Approve?' } /* ── rendering ───────────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot/gauge (never destroys DOM). * Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry * gauge (B2), per SP10/M4. */ private refreshTab(entry: TabEntry): void { // Keep the grid cell (header text, focus ring, pending glow, inline approve) // in sync too — independent of whether the tab-bar element exists yet. this.renderCell(entry) const el = entry.el if (!el) return const idx = this.tabs.indexOf(entry) el.classList.toggle('active', idx === this.activeIndex) el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) const cs = entry.session.claudeStatus el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5 const title = this.displayTitle(entry, idx) el.title = title const dot = el.querySelector('.tab-dot') if (dot) dot.className = `tab-dot dot-${entry.session.status}` const label = el.querySelector('.tab-label') if (label) label.textContent = title const claude = el.querySelector('.tab-claude') if (claude) claude.textContent = claudeIcon(cs) const gauge = el.querySelector('.tab-gauge') if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2 } /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ private rebuild(): void { this.tabBar.replaceChildren() // Home (⌂): overlay the Sessions/Projects chooser over the open tabs so you // can start another session/project without closing the current one. const home = document.createElement('button') home.className = 'tab-home' home.textContent = '⌂' home.title = 'Home — Sessions & Projects' home.setAttribute('aria-label', 'Home — Sessions & Projects') home.addEventListener('click', () => this.toggleHome()) this.tabBar.appendChild(home) this.homeBtn = home this.tabs.forEach((entry, idx) => { const tabEl = document.createElement('div') entry.el = tabEl tabEl.className = 'tab' // Switch on pointerdown (fires before any drag; reliable with draggable, works on touch). tabEl.addEventListener('pointerdown', () => this.activate(idx)) // Middle-click closes. tabEl.addEventListener('auxclick', (e) => { if (e.button === 1) { e.preventDefault() this.closeTab(idx) } }) // Drag-to-reorder (disabled while renaming). if (idx !== this.editingIndex) { tabEl.draggable = true tabEl.addEventListener('dragstart', (e) => { this.dragIndex = idx e.dataTransfer?.setData('text/plain', String(idx)) tabEl.classList.add('dragging') }) tabEl.addEventListener('dragover', (e) => { e.preventDefault() tabEl.classList.add('drag-over') }) tabEl.addEventListener('dragleave', () => tabEl.classList.remove('drag-over')) tabEl.addEventListener('drop', (e) => { e.preventDefault() tabEl.classList.remove('drag-over') this.moveTab(this.dragIndex, idx) }) tabEl.addEventListener('dragend', () => { this.dragIndex = -1 tabEl.classList.remove('dragging') }) } const dot = document.createElement('span') dot.className = `tab-dot dot-${entry.session.status}` tabEl.appendChild(dot) if (idx === this.editingIndex) { const input = document.createElement('input') input.className = 'tab-rename' input.value = this.displayTitle(entry, idx) input.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.commitRename(idx, input.value) else if (e.key === 'Escape') { this.editingIndex = -1 this.rebuild() } e.stopPropagation() }) input.addEventListener('blur', () => this.commitRename(idx, input.value)) input.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't re-activate tabEl.appendChild(input) requestAnimationFrame(() => { input.focus() input.select() }) } else { const label = document.createElement('span') label.className = 'tab-label' label.textContent = this.displayTitle(entry, idx) label.addEventListener('dblclick', () => { this.editingIndex = idx this.rebuild() }) tabEl.appendChild(label) const claude = document.createElement('span') claude.className = 'tab-claude' tabEl.appendChild(claude) // B2: per-tab telemetry gauge container (filled in by refreshTab). const gauge = document.createElement('span') gauge.className = 'tab-gauge' tabEl.appendChild(gauge) 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('pointerdown', (e) => e.stopPropagation()) close.addEventListener('click', (e) => { e.stopPropagation() this.closeTab(idx) }) tabEl.appendChild(close) } // Apply current active/unread state. this.refreshTab(entry) 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) // A4 timeline toggle + A1 push bell — re-appended each rebuild so they // survive tabBar.replaceChildren() (the bell's subtree is mounted once). const tl = document.createElement('button') tl.className = 'tab-timeline' tl.innerHTML = ICON_TIMELINE tl.dataset.tip = 'Activity timeline' tl.setAttribute('aria-label', 'Activity timeline') tl.classList.toggle('active', this.timelineOpen) tl.addEventListener('click', () => this.toggleTimeline()) this.timelineBtn = tl this.tabBar.appendChild(tl) this.tabBar.appendChild(this.pushHost) // Show/hide the home screen based on whether any tab is open. this.updateHomeView() } }