feat: live tab awareness for the Terminal pane

claude in the PTY now has continuous tab-aware context. Three pieces:

1. Live state files. background.js listens to chrome.tabs.onActivated /
   onCreated / onRemoved / onUpdated (throttled to URL/title/status==
   complete so loading spinners don't spam) and pushes a snapshot. The
   sidepanel relays it as a custom event; sidepanel-terminal.js sends
   {type:"tabState"} text frames over the live PTY WebSocket.
   terminal-agent.ts writes:
     <stateDir>/tabs.json          all open tabs (id, url, title, active,
                                   pinned, audible, windowId)
     <stateDir>/active-tab.json    current active tab (skips chrome:// and
                                   chrome-extension:// internal pages)
   Atomic write via tmp + rename so claude never reads a half-written
   document. A fresh snapshot is pushed on WS open so the files exist by
   the time claude finishes booting.

2. New $B tab-each <command> [args...] meta-command. Fans out a single
   command across every open tab, returns
   {command, args, total, results: [{tabId, url, title, status, output}]}.
   Skips chrome:// pages; restores the originally active tab in a finally
   block (so a mid-batch error doesn't leave the user looking at a
   different tab); uses bringToFront: false so the OS window doesn't
   jump on every fanout. Scope-checks the inner command BEFORE the loop.

3. --append-system-prompt hint at spawn time. Claude is told about both
   the state files and the $B tab-each command up front, so it doesn't
   have to discover the surface by trial. Passed via the --append-system-
   prompt CLI flag, NOT as a leading PTY write — the hint stays out of
   the visible transcript.

Tests:
- browse/test/tab-each.test.ts (new) — registration + source-level
  invariants (scope check before loop, finally-restore, bringToFront:false,
  chrome:// skip) + behavior tests with a mock BrowserManager that verify
  iteration order, JSON shape, error handling, and active-tab restore.
- browse/test/terminal-agent.test.ts — three new assertions for
  tabState handler shape, atomic-write pattern, and the
  --append-system-prompt wiring at spawn.

Verified live: opened 5 tabs, ran $B tab-each url against the live
server, got per-tab JSON results back, original active tab restored
without OS focus stealing.
This commit is contained in:
Garry Tan
2026-04-25 21:06:52 -07:00
parent 006dbe19f1
commit 74fa203fe4
10 changed files with 547 additions and 6 deletions
+59 -4
View File
@@ -287,6 +287,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const ALLOWED_TYPES = new Set([
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
'getTabState',
// Inspector message types
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
'applyStyle', 'toggleClass', 'injectCSS', 'resetAll',
@@ -302,6 +303,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true;
}
if (msg.type === 'getTabState') {
snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] }));
return true; // async sendResponse
}
if (msg.type === 'setPort') {
savePort(msg.port).then(() => {
checkHealth();
@@ -506,11 +512,48 @@ chrome.runtime.onInstalled.addListener(() => {
// Fire on every service worker startup (covers persistent context reuse)
autoOpenSidePanel();
// ─── Tab Switch Detection ────────────────────────────────────────
// Notify sidepanel instantly when the user switches tabs in the browser.
// This is faster than polling — the sidebar swaps chat context immediately.
// ─── Tab Awareness ───────────────────────────────────────────────
// Push live tab state to the sidepanel so claude in the Terminal pane
// always has up-to-date tabs.json + active-tab.json on disk. The
// sidepanel relays these to terminal-agent.ts over the live WebSocket;
// terminal-agent writes the files for claude to read.
async function snapshotTabs() {
try {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
const all = await chrome.tabs.query({});
const slim = all.map(t => ({
tabId: t.id,
url: t.url || '',
title: t.title || '',
active: !!t.active,
windowId: t.windowId,
pinned: !!t.pinned,
audible: !!t.audible,
}));
return {
active: active ? { tabId: active.id, url: active.url || '', title: active.title || '' } : null,
tabs: slim,
};
} catch {
return null;
}
}
async function pushTabState(reason) {
const snapshot = await snapshotTabs();
if (!snapshot) return;
chrome.runtime.sendMessage({
type: 'browserTabState',
reason,
...snapshot,
}).catch(() => {}); // expected: sidepanel may not be open
}
chrome.tabs.onActivated.addListener((activeInfo) => {
// Keep the legacy event for any consumer still listening to it (the chat
// path is gone but the message type is harmless), and also fire the new
// unified state push so claude's tabs.json reflects the new active tab.
chrome.tabs.get(activeInfo.tabId, (tab) => {
if (chrome.runtime.lastError || !tab) return;
chrome.runtime.sendMessage({
@@ -518,8 +561,20 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
tabId: activeInfo.tabId,
url: tab.url || '',
title: tab.title || '',
}).catch(() => {}); // expected: sidepanel may not be open
}).catch(() => {});
});
pushTabState('activated');
});
chrome.tabs.onCreated.addListener(() => pushTabState('created'));
chrome.tabs.onRemoved.addListener(() => pushTabState('removed'));
chrome.tabs.onUpdated.addListener((_id, changeInfo) => {
// Throttle: only re-push on URL or title changes, not on every loading
// tick. We don't want to spam claude with a state push every 50ms while
// a page loads.
if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') {
pushTabState('updated');
}
});
// ─── Startup ────────────────────────────────────────────────────
+34
View File
@@ -240,6 +240,24 @@
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch {}
// Push a fresh tab snapshot so claude's tabs.json is populated by
// the time the lazy spawn finishes booting. Background.js exposes
// the snapshot helper via chrome.runtime; we ask for it here and
// forward whatever comes back.
try {
chrome.runtime.sendMessage({ type: 'getTabState' }, (resp) => {
if (resp && ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'tabState',
active: resp.active,
tabs: resp.tabs,
reason: 'initial',
}));
} catch {}
}
});
} catch {}
// Send a single byte to nudge the agent to spawn claude (lazy-spawn trigger).
try { ws.send(new TextEncoder().encode('\n')); } catch {}
});
@@ -335,6 +353,22 @@
els.restartNow?.addEventListener('click', forceRestart);
// Live browser-tab state. background.js → sidepanel.js → us. We
// forward over the live PTY WebSocket; terminal-agent.ts writes
// <stateDir>/active-tab.json + <stateDir>/tabs.json so claude can
// always read the current tab landscape.
document.addEventListener('gstack:tab-state', (ev) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify({
type: 'tabState',
active: ev.detail?.active,
tabs: ev.detail?.tabs,
reason: ev.detail?.reason,
}));
} catch {}
});
// Repaint after a debug-tab → primary-pane transition. The debug
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
// .tab-content { display: none }; xterm doesn't auto-redraw when its
+9
View File
@@ -1039,4 +1039,13 @@ chrome.runtime.onMessage.addListener((msg) => {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');
}
// browserTabState: full snapshot of all open tabs + the active one,
// pushed by background.js on chrome.tabs events. We forward it as a
// custom event so sidepanel-terminal.js can relay to terminal-agent.ts.
// Result: claude's <stateDir>/tabs.json + active-tab.json stay live.
if (msg.type === 'browserTabState') {
document.dispatchEvent(new CustomEvent('gstack:tab-state', {
detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason },
}));
}
});