mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
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:
+59
-4
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user