From 1923e1972f5e1f1a2840cdb41d953431e2de9482 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 25 Apr 2026 12:34:12 -0700 Subject: [PATCH] feat(extension): Terminal as default sidebar tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a primary tab bar (Terminal | Chat) above the existing tab-content panes. Terminal is the default-active tab; clicking Chat returns to the existing claude -p one-shot flow which is preserved verbatim. manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't block the WebSocket upgrade. sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a "Press any key to start Claude Code" bootstrap card, claude-not-found install card, xterm mount point, and "session ended" restart UI. Loads xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no longer the .active default. sidepanel.js: new activePrimaryPaneId() helper that reads which primary tab is selected. Debug-close paths now route back to whichever primary pane is active (was hardcoded to tab-chat). Primary-tab click handler toggles .active classes and aria-selected. window.gstackServerPort and window.gstackAuthToken exposed so sidepanel-terminal.js can build the /pty-session POST and the WS URL. sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first keystroke fires POST /pty-session, then opens ws://127.0.0.1:/ws. Origin + cookie are set automatically by the browser. Resize observer sends {type:"resize"} text frames. ResizeObserver, tab-switch hooks, restart button, install-card retry. On WS close shows "Session ended, click to restart" — no auto-reconnect (codex outside-voice flagged that as session-burning). sidepanel.css: primary-tabs bar + Terminal pane styling (full-height xterm container, install card, ended state). --- extension/manifest.json | 2 +- extension/sidepanel-terminal.js | 324 ++++++++++++++++++++++++++++++++ extension/sidepanel.css | 88 +++++++++ extension/sidepanel.html | 37 +++- extension/sidepanel.js | 48 ++++- 5 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 extension/sidepanel-terminal.js diff --git a/extension/manifest.json b/extension/manifest.json index 81b31804..502c5bb7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -4,7 +4,7 @@ "version": "0.1.0", "description": "Live activity feed and @ref overlays for gstack browse", "permissions": ["sidePanel", "storage", "activeTab", "scripting"], - "host_permissions": ["http://127.0.0.1:*/"], + "host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"], "action": { "default_icon": { "16": "icons/icon-16.png", diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js new file mode 100644 index 00000000..b74406d7 --- /dev/null +++ b/extension/sidepanel-terminal.js @@ -0,0 +1,324 @@ +/** + * Terminal sidebar tab — interactive Claude Code PTY in xterm.js. + * + * Lifecycle (per plan + codex review): + * 1. Sidebar opens. Terminal is the default-active tab. + * 2. Bootstrap card shows "Press any key to start Claude Code." + * 3. On first keystroke (lazy spawn — codex finding #8): the extension + * a) POSTs /pty-session on the browse server with the AUTH_TOKEN to + * mint a short-lived HttpOnly cookie scoped to the terminal-agent. + * b) Opens ws://127.0.0.1:/ws — the cookie travels + * automatically. Terminal-agent validates the cookie + the + * chrome-extension:// Origin (codex finding #9), then spawns + * claude in a PTY. + * 4. Bytes pump both ways. Resize observer sends {type:"resize"} text + * frames; tab-switch hooks send {type:"tabSwitch"} frames. + * 5. PTY exits or WS closes -> we show "Session ended" with a restart + * button. We do NOT auto-reconnect (codex finding #8: auto-reconnect + * = burn fresh claude session every time). + * + * Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded + * via + + diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 6f449990..423f0468 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -914,14 +914,22 @@ const debugTabs = document.getElementById('debug-tabs'); const closeDebug = document.getElementById('close-debug'); let debugOpen = false; +// Resolve the primary surface tab the user expects to see when debug is +// closed. Default-active is Terminal per /plan-eng-review Issue 1B. +function activePrimaryPaneId() { + const sel = document.querySelector('.primary-tab.active'); + const pane = sel?.dataset.pane; + return pane ? `tab-${pane}` : 'tab-terminal'; +} + debugToggle.addEventListener('click', () => { debugOpen = !debugOpen; debugToggle.classList.toggle('active', debugOpen); debugTabs.style.display = debugOpen ? 'flex' : 'none'; if (!debugOpen) { - // Close debug panels, show chat + // Close debug panels, restore the active primary surface (Terminal or Chat). document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById('tab-chat').classList.add('active'); + document.getElementById(activePrimaryPaneId()).classList.add('active'); document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); } }); @@ -931,7 +939,7 @@ closeDebug.addEventListener('click', () => { debugToggle.classList.remove('active'); debugTabs.style.display = 'none'; document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById('tab-chat').classList.add('active'); + document.getElementById(activePrimaryPaneId()).classList.add('active'); }); document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => { @@ -945,6 +953,30 @@ document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => { }); }); +// Primary-tab switching: Terminal | Chat. Pure show/hide. Both panes keep +// their state when hidden — switching to Chat doesn't kill the PTY, and +// switching back to Terminal doesn't reset the chat scroll position. +document.querySelectorAll('.primary-tab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.primary-tab').forEach(b => { + b.classList.remove('active'); + b.setAttribute('aria-selected', 'false'); + }); + btn.classList.add('active'); + btn.setAttribute('aria-selected', 'true'); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + const pane = btn.dataset.pane; + document.getElementById(`tab-${pane}`).classList.add('active'); + // Close debug when switching primary tabs (debug is opt-in). + if (debugOpen) { + debugOpen = false; + debugToggle.classList.remove('active'); + debugTabs.style.display = 'none'; + document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); + } + }); +}); + // ─── Activity Feed ────────────────────────────────────────────── function getEntryClass(entry) { @@ -1660,6 +1692,16 @@ function updateConnection(url, token) { const wasConnected = !!serverUrl; serverUrl = url; serverToken = token || null; + // Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs + // the bootstrap token to POST /pty-session and the port to derive the WS + // URL. We never expose the PTY token — it lives in an HttpOnly cookie. + if (url) { + try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {} + window.gstackAuthToken = token || null; + } else { + window.gstackServerPort = null; + window.gstackAuthToken = null; + } if (url) { document.getElementById('footer-dot').className = 'dot connected'; const port = new URL(url).port;