feat(extension): Terminal as default sidebar tab

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:<terminalPort>/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).
This commit is contained in:
Garry Tan
2026-04-25 12:34:12 -07:00
parent 07d4d36edf
commit 1923e1972f
5 changed files with 493 additions and 6 deletions
+1 -1
View File
@@ -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",
+324
View File
@@ -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:<terminalPort>/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 <script src> tags in sidepanel.html (window.Terminal, window.FitAddon).
*/
(function () {
'use strict';
const Terminal = window.Terminal;
const FitAddonModule = window.FitAddon;
if (!Terminal) {
console.error('[gstack terminal] xterm not loaded');
return;
}
const els = {
bootstrap: document.getElementById('terminal-bootstrap'),
bootstrapStatus: document.getElementById('terminal-bootstrap-status'),
installCard: document.getElementById('terminal-install-card'),
installRetry: document.getElementById('terminal-install-retry'),
mount: document.getElementById('terminal-mount'),
ended: document.getElementById('terminal-ended'),
restart: document.getElementById('terminal-restart'),
};
/** State machine. */
const STATE = { IDLE: 'idle', CONNECTING: 'connecting', LIVE: 'live', ENDED: 'ended', NO_CLAUDE: 'no-claude' };
let state = STATE.IDLE;
let term = null;
let fitAddon = null;
let ws = null;
function show(el) { el.style.display = ''; }
function hide(el) { el.style.display = 'none'; }
function setState(next, opts = {}) {
state = next;
switch (next) {
case STATE.IDLE:
show(els.bootstrap);
hide(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = opts.message || 'Press any key to start Claude Code.';
break;
case STATE.CONNECTING:
show(els.bootstrap);
hide(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = 'Connecting...';
break;
case STATE.LIVE:
hide(els.bootstrap);
hide(els.installCard);
show(els.mount);
hide(els.ended);
break;
case STATE.ENDED:
hide(els.bootstrap);
hide(els.installCard);
hide(els.mount);
show(els.ended);
break;
case STATE.NO_CLAUDE:
show(els.bootstrap);
show(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = '';
break;
}
}
/**
* Read auth + terminalPort from the server's /health. We don't fetch this
* here — sidepanel.js already polls /health for connection state and
* exposes the relevant fields on window.gstackHealth (set below in init()).
* If terminalPort is missing, the agent isn't ready yet.
*/
function getHealth() {
return window.gstackHealth || {};
}
function getServerPort() {
return window.gstackServerPort || null;
}
function getAuthToken() {
return window.gstackAuthToken || null;
}
/**
* POST /pty-session to mint the HttpOnly cookie. Returns { terminalPort,
* expiresAt } on success, or null with reason on failure. Note: we do
* NOT receive the cookie value; it lives in the browser's HttpOnly jar
* and travels with the next same-origin request automatically.
*/
async function mintSession() {
const serverPort = getServerPort();
const token = getAuthToken();
if (!serverPort || !token) {
return { error: 'browse server not ready' };
}
try {
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
credentials: 'include',
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
return { error: `${resp.status} ${body || resp.statusText}` };
}
return await resp.json();
} catch (err) {
return { error: err && err.message ? err.message : String(err) };
}
}
async function checkClaudeAvailable(terminalPort) {
try {
const resp = await fetch(`http://127.0.0.1:${terminalPort}/claude-available`, {
credentials: 'include',
});
if (!resp.ok) return { available: false };
return await resp.json();
} catch {
return { available: false };
}
}
function ensureXterm() {
if (term) return;
term = new Terminal({
fontFamily: '"JetBrains Mono", "SF Mono", Menlo, monospace',
fontSize: 13,
theme: { background: '#0a0a0a', foreground: '#e5e5e5' },
cursorBlink: true,
scrollback: 5000,
allowTransparency: false,
convertEol: false,
});
if (FitAddonModule && FitAddonModule.FitAddon) {
fitAddon = new FitAddonModule.FitAddon();
term.loadAddon(fitAddon);
}
term.open(els.mount);
fitAddon && fitAddon.fit();
const ro = new ResizeObserver(() => {
try {
fitAddon && fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
} catch {}
});
ro.observe(els.mount);
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
}
async function connect() {
if (state !== STATE.IDLE) return; // already connecting/live
setState(STATE.CONNECTING);
const minted = await mintSession();
if (minted.error) {
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
return;
}
const { terminalPort } = minted;
// Pre-flight: does claude even exist on PATH?
const claudeStatus = await checkClaudeAvailable(terminalPort);
if (!claudeStatus.available) {
setState(STATE.NO_CLAUDE);
return;
}
ensureXterm();
setState(STATE.LIVE);
fitAddon && fitAddon.fit();
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch {}
// Send a single byte to nudge the agent to spawn claude (lazy-spawn trigger).
try { ws.send(new TextEncoder().encode('\n')); } catch {}
});
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
// Agent control message (rare). Treat as JSON; error frames carry code.
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
setState(STATE.NO_CLAUDE);
try { ws.close(); } catch {}
}
} catch {}
return;
}
// Binary: feed to xterm.
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
term.write(buf);
});
ws.addEventListener('close', () => {
ws = null;
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
});
ws.addEventListener('error', (err) => {
console.error('[gstack terminal] ws error', err);
});
}
function teardown() {
try { ws && ws.close(); } catch {}
ws = null;
if (term) {
try { term.dispose(); } catch {}
term = null;
fitAddon = null;
}
setState(STATE.IDLE);
}
// ─── Wiring ───────────────────────────────────────────────────
function init() {
// First-keystroke trigger on the bootstrap card.
document.addEventListener('keydown', onAnyKey, { once: false, capture: true });
els.installRetry?.addEventListener('click', async () => {
// Re-probe and try connecting again.
const minted = await mintSession();
if (!minted.error) {
const claudeStatus = await checkClaudeAvailable(minted.terminalPort);
if (claudeStatus.available) {
setState(STATE.IDLE);
// Auto-trigger reconnect on next key
}
}
});
els.restart?.addEventListener('click', () => {
// Clean restart. Drop xterm state too — codex 1C: each session is fresh.
if (term) {
try { term.dispose(); } catch {}
term = null;
fitAddon = null;
}
setState(STATE.IDLE);
});
// Tab switching: tell the agent which browser tab is active so claude's
// active-tab.json stays in sync. sidepanel.js owns the active-tab state;
// we listen for its "tab activated" event.
document.addEventListener('gstack:active-tab-changed', (ev) => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'tabSwitch',
tabId: ev.detail?.tabId,
url: ev.detail?.url,
title: ev.detail?.title,
}));
} catch {}
}
});
// Initial state
setState(STATE.IDLE);
}
function onAnyKey(ev) {
// Only trigger if Terminal pane is the active one and we're idle.
const terminalActive = document.getElementById('tab-terminal')?.classList.contains('active');
if (!terminalActive) return;
if (state !== STATE.IDLE) return;
// Ignore pure modifier keys.
if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(ev.key)) return;
connect();
}
// Wait for sidepanel.js to populate window.gstackServerPort + window.gstackAuthToken.
// sidepanel.js already polls /health and resolves the connection; we just need
// to wait for it. If those globals aren't available within 10s, surface a
// "browse server not ready" message — user can reload sidebar.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+88
View File
@@ -675,6 +675,94 @@ body::after {
}
.tab-content.active { display: flex; flex-direction: column; }
/* ─── Primary surface tabs (Terminal | Chat) ──────────────────── */
.primary-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: #0f0f0f;
padding: 0 8px;
flex-shrink: 0;
}
.primary-tab {
background: transparent;
border: none;
color: #71717a;
padding: 8px 14px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.primary-tab:hover { color: #e5e5e5; }
.primary-tab.active {
color: #e5e5e5;
border-bottom-color: #f59e0b;
}
/* ─── Terminal Tab ────────────────────────────────────────────── */
#tab-terminal {
background: #0a0a0a;
padding: 0;
}
.terminal-bootstrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #71717a;
padding: 24px;
}
.terminal-bootstrap-icon {
font-size: 32px;
color: #f59e0b;
margin-bottom: 8px;
}
.terminal-bootstrap p { margin: 4px 0; }
.terminal-install-card {
margin: 24px;
padding: 16px;
border: 1px solid #27272a;
border-radius: 6px;
text-align: center;
}
.terminal-install-card a { color: #f59e0b; }
.install-retry-btn {
margin-top: 12px;
padding: 6px 14px;
background: #f59e0b;
color: #0a0a0a;
border: none;
border-radius: 4px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.install-retry-btn:hover { opacity: 0.9; }
.terminal-mount {
flex: 1;
width: 100%;
background: #0a0a0a;
padding: 8px;
box-sizing: border-box;
}
.terminal-mount .xterm,
.terminal-mount .xterm .xterm-viewport,
.terminal-mount .xterm .xterm-screen {
height: 100% !important;
}
.terminal-ended {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #71717a;
padding: 24px;
}
/* ─── Activity Feed ───────────────────────────────────── */
#activity-feed { flex: 1; }
+35 -2
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="sidepanel.css">
<link rel="stylesheet" href="lib/xterm.css">
</head>
<body>
<!-- Security shield — reflects ~/.gstack/security/session-state.json status.
@@ -59,8 +60,37 @@
<!-- Browser tab bar -->
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
<!-- Chat Tab (default, full height) -->
<main id="tab-chat" class="tab-content active">
<!-- Primary surface tabs: Terminal (default) | Chat. Activity / Refs /
Inspector still exist as a separate debug-tabs strip below. The
Terminal tab is default-active per /plan-eng-review Issue 1B
(subsequently informed by codex's spawn-waste finding: PTY only
spawns when the user types, so default-active is cheap). -->
<nav class="primary-tabs" id="primary-tabs" role="tablist">
<button class="primary-tab active" role="tab" data-pane="terminal" aria-selected="true">Terminal</button>
<button class="primary-tab" role="tab" data-pane="chat" aria-selected="false">Chat</button>
</nav>
<!-- Terminal Tab (default-active) -->
<main id="tab-terminal" class="tab-content active" role="tabpanel" aria-label="Terminal">
<div class="terminal-bootstrap" id="terminal-bootstrap">
<div class="terminal-bootstrap-icon"></div>
<p id="terminal-bootstrap-status">Press any key to start Claude Code.</p>
<p class="muted" id="terminal-bootstrap-hint">Real PTY. Real terminal. Real claude.</p>
</div>
<div class="terminal-install-card" id="terminal-install-card" style="display:none">
<p><strong>Claude Code not found</strong></p>
<p class="muted">Install: <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank">docs.anthropic.com/en/docs/claude-code</a></p>
<button class="install-retry-btn" id="terminal-install-retry">I installed it &mdash; try again</button>
</div>
<div class="terminal-mount" id="terminal-mount" style="display:none"></div>
<div class="terminal-ended" id="terminal-ended" style="display:none">
<p>Session ended.</p>
<button class="install-retry-btn" id="terminal-restart">Start a new session</button>
</div>
</main>
<!-- Chat Tab (the existing claude -p one-shot chat path; preserved verbatim) -->
<main id="tab-chat" class="tab-content" role="tabpanel" aria-label="Chat">
<div class="chat-messages" id="chat-messages">
<div class="chat-loading" id="chat-loading">
<div class="chat-loading-spinner"></div>
@@ -215,6 +245,9 @@
<button class="tab close-debug" id="close-debug" title="Close debug">&times;</button>
</nav>
<script src="lib/xterm.js"></script>
<script src="lib/xterm-addon-fit.js"></script>
<script src="sidepanel.js"></script>
<script src="sidepanel-terminal.js"></script>
</body>
</html>
+45 -3
View File
@@ -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;