mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +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 ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user