diff --git a/SKILL.md b/SKILL.md index a0fc120e..177b98e8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1035,6 +1035,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `closetab [id]` | Close tab | | `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). | | `tab ` | Switch to tab | +| `tab-each [args...]` | Run a command on every open tab. Returns JSON with per-tab results. | | `tabs` | List open tabs | ### Server diff --git a/browse/SKILL.md b/browse/SKILL.md index 1f2224f6..38fad450 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -959,6 +959,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `closetab [id]` | Close tab | | `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). | | `tab ` | Switch to tab | +| `tab-each [args...]` | Run a command on every open tab. Returns JSON with per-tab results. | | `tabs` | List open tabs | ### Server diff --git a/browse/src/commands.ts b/browse/src/commands.ts index e9e60153..bf74833f 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -30,7 +30,7 @@ export const WRITE_COMMANDS = new Set([ ]); export const META_COMMANDS = new Set([ - 'tabs', 'tab', 'newtab', 'closetab', + 'tabs', 'tab', 'tab-each', 'newtab', 'closetab', 'status', 'stop', 'restart', 'screenshot', 'pdf', 'responsive', 'chain', 'diff', @@ -144,6 +144,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'newtab': { category: 'Tabs', description: 'Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf).', usage: 'newtab [url] [--json]' }, 'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' }, + 'tab-each':{ category: 'Tabs', description: 'Run a command on every open tab. Returns JSON with per-tab results.', usage: 'tab-each [args...]' }, // Server 'status': { category: 'Server', description: 'Health check' }, 'stop': { category: 'Server', description: 'Shutdown server' }, diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 3521f05f..328116c2 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -285,6 +285,108 @@ export async function handleMetaCommand( return `Closed tab${id ? ` ${id}` : ''}`; } + case 'tab-each': { + // Fan out a single command across every open tab. Returns a JSON + // object: { results: [{tabId, url, title, status, output}], total }. + // Restores the originally active tab when done so the user's view + // doesn't shift under them. + // + // Usage: $B tab-each [args...] + // $B tab-each snapshot -i → snapshot every tab + // $B tab-each text → grab clean text from every tab + // $B tab-each goto https://x.y → load the same URL in every tab + if (args.length === 0) { + throw new Error( + 'Usage: browse tab-each [args...]\n' + + 'Example: browse tab-each snapshot -i' + ); + } + + const innerRaw = args[0]; + const innerName = canonicalizeCommand(innerRaw); + const innerArgs = args.slice(1); + + // Scope check the inner command before fanning out, so a single + // permission failure aborts the whole batch instead of partially + // mutating tabs. + if (tokenInfo && tokenInfo.clientId !== 'root' && !checkScope(tokenInfo, innerName)) { + throw new Error( + `tab-each rejected: subcommand "${innerRaw}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}).` + ); + } + + const tabs = await bm.getTabListWithTitles(); + const originalActive = tabs.find(t => t.active)?.id ?? bm.getActiveTabId(); + + const executeCmd = opts?.executeCommand; + const results: Array<{ + tabId: number; + url: string; + title: string; + status: number; + output: string; + }> = []; + + try { + for (const tab of tabs) { + // Skip chrome:// internal pages — they aren't useful targets and + // many commands fail outright on them. + if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) { + results.push({ + tabId: tab.id, + url: tab.url, + title: tab.title || '', + status: 0, + output: 'skipped: internal page', + }); + continue; + } + // Switch to the tab. Don't pull focus away — we're a background + // operation; the user shouldn't see the OS window jump. + bm.switchTab(tab.id, { bringToFront: false }); + + let status = 0; + let output = ''; + if (executeCmd) { + const r = await executeCmd( + { command: innerName, args: innerArgs, tabId: tab.id }, + tokenInfo, + ); + status = r.status; + output = r.result; + if (status !== 200) { + try { output = JSON.parse(output).error || output; } catch (err: any) { if (!(err instanceof SyntaxError)) throw err; } + } + } else { + // Fallback path (CLI / test harness without a server context). + // We don't recurse through read/write/meta directly here because + // tab-each is only meaningful with the live server; surface a + // clear error. + status = 500; + output = 'tab-each requires the browse server (no executeCommand context)'; + } + + results.push({ + tabId: tab.id, + url: tab.url, + title: tab.title || '', + status, + output, + }); + } + } finally { + // Restore the original active tab so the user's view is unchanged. + try { bm.switchTab(originalActive, { bringToFront: false }); } catch {} + } + + return JSON.stringify({ + command: innerName, + args: innerArgs, + total: results.length, + results, + }, null, 2); + } + // ─── Server Control ──────────────────────────────── case 'status': { const page = bm.getPage(); diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 21cf359b..9ebc8cbb 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -101,6 +101,45 @@ function writeClaudeAvailable(): void { } } +/** + * System-prompt hint passed to claude via --append-system-prompt. Tells + * claude what tab-awareness affordances exist in this session so it + * doesn't have to discover them by trial. The user can override anything + * here just by saying so — system prompt is a soft hint, not a contract. + * + * Two paths claude has: + * 1. Read live state from /tabs.json + active-tab.json + * (updated continuously by the gstack browser extension). + * 2. Run $B tab, $B tabs, $B tab-each to act on tabs. The + * tab-each helper fans a single command across every open tab and + * returns per-tab results as JSON. + */ +function buildTabAwarenessHint(stateDir: string): string { + const tabsFile = path.join(stateDir, 'tabs.json'); + const activeFile = path.join(stateDir, 'active-tab.json'); + return [ + 'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.', + '', + 'Tab state files (kept fresh automatically by the extension):', + ` ${tabsFile} — all open tabs (id, url, title, active, pinned)`, + ` ${activeFile} — the currently active tab`, + 'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.', + '', + 'Tab manipulation commands (via $B):', + ' $B tab — switch to a tab', + ' $B newtab [url] — open a new tab', + ' $B closetab [id] — close a tab (current if no id)', + ' $B tab-each — fan out a command across every tab; returns JSON results', + '', + 'When the user asks for multi-tab work, prefer $B tab-each. Examples:', + ' $B tab-each snapshot -i — grab a snapshot from every tab', + ' $B tab-each text — pull clean text from every tab', + ' $B tab-each title — list every tab\'s title', + '', + 'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.', + ].join('\n'); +} + /** Spawn claude in a PTY. Returns null if claude not on PATH. */ function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) { const claudePath = findClaude(); @@ -120,7 +159,15 @@ function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void COLORTERM: 'truecolor', }; - const proc = (Bun as any).spawn([claudePath], { + // --append-system-prompt is the right injection surface (per `claude --help`): + // it gets appended to the model's system prompt, so claude treats this as + // contextual guidance, not a user message. Don't use a leading PTY write + // for this — that would show up as if the user typed the hint, polluting + // the visible transcript. + const stateDir = path.dirname(STATE_FILE); + const tabHint = buildTabAwarenessHint(stateDir); + + const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], { terminal: { rows, cols, @@ -303,6 +350,10 @@ function buildServer() { handleTabSwitch(msg); return; } + if (msg?.type === 'tabState') { + handleTabState(msg); + return; + } // Unknown text frame — ignore. return; } @@ -359,6 +410,65 @@ function buildServer() { * and notify the parent server so its activeTabId stays synced. Skips * chrome:// and chrome-extension:// internal pages. */ +/** + * Live tab snapshot. Writes /tabs.json (full list) and updates + * /active-tab.json (current active). claude can read these any + * time without invoking $B tabs — saves a round-trip when the model just + * needs to check the landscape before deciding what to do. + */ +function handleTabState(msg: { + active?: { tabId?: number; url?: string; title?: string } | null; + tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>; + reason?: string; +}): void { + const stateDir = path.dirname(STATE_FILE); + try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {} + + // tabs.json — full list + if (Array.isArray(msg.tabs)) { + const payload = { + updatedAt: new Date().toISOString(), + reason: msg.reason || 'unknown', + tabs: msg.tabs.map(t => ({ + tabId: t.tabId ?? null, + url: t.url || '', + title: t.title || '', + active: !!t.active, + windowId: t.windowId ?? null, + pinned: !!t.pinned, + audible: !!t.audible, + })), + }; + const target = path.join(stateDir, 'tabs.json'); + const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`); + try { + fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, target); + } catch { + safeUnlink(tmp); + } + } + + // active-tab.json — single active tab. Skip chrome-internal pages so + // claude doesn't see chrome:// or chrome-extension:// URLs as + // "current target." + const active = msg.active; + if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) { + const ctxFile = path.join(stateDir, 'active-tab.json'); + const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`); + try { + fs.writeFileSync(tmp, JSON.stringify({ + tabId: active.tabId ?? null, + url: active.url, + title: active.title ?? '', + }), { mode: 0o600 }); + fs.renameSync(tmp, ctxFile); + } catch { + safeUnlink(tmp); + } + } +} + function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void { const url = msg.url || ''; if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return; diff --git a/browse/test/tab-each.test.ts b/browse/test/tab-each.test.ts new file mode 100644 index 00000000..fce50993 --- /dev/null +++ b/browse/test/tab-each.test.ts @@ -0,0 +1,196 @@ +/** + * tab-each — fan-out command for the live Terminal pane. + * + * Source-level guards: command is registered, has a description + usage, + * scope-check the inner command, restore the original active tab in a + * finally block (so a mid-batch exception doesn't leave the user looking + * at a tab they didn't choose). + * + * Behavioral logic test: drive handleMetaCommand directly with a mock + * BrowserManager + executeCommand callback. Verify the iteration order, + * the JSON shape, the tab restore, and the chrome:// skip. + */ + +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { handleMetaCommand } from '../src/meta-commands'; +import { META_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands'; + +const META_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/meta-commands.ts'), 'utf-8'); + +describe('tab-each: registration', () => { + test('command is in META_COMMANDS', () => { + expect(META_COMMANDS.has('tab-each')).toBe(true); + }); + + test('has a description and usage entry', () => { + expect(COMMAND_DESCRIPTIONS['tab-each']).toBeDefined(); + expect(COMMAND_DESCRIPTIONS['tab-each'].usage).toContain('tab-each'); + expect(COMMAND_DESCRIPTIONS['tab-each'].category).toBe('Tabs'); + }); +}); + +describe('tab-each: source-level guards', () => { + test('scope-checks the inner command before fanning out', () => { + const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':")); + expect(block).toContain('checkScope(tokenInfo, innerName)'); + // The scope check must run BEFORE the for-loop. If it ran inside the + // loop, a permission failure on the second tab would leave the first + // tab already mutated. + const checkIdx = block.indexOf('checkScope(tokenInfo, innerName)'); + const loopIdx = block.indexOf('for (const tab of tabs)'); + expect(checkIdx).toBeLessThan(loopIdx); + }); + + test('restores the original active tab in a finally block', () => { + const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000); + expect(block).toContain('finally'); + expect(block).toContain('originalActive'); + expect(block).toContain('switchTab(originalActive'); + }); + + test('uses bringToFront: false so the OS window does NOT jump', () => { + const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000); + // tab-each is a background operation — pulling focus would steal the + // user's foreground app every time claude fans out, which is + // unacceptable. + expect(block).toContain('bringToFront: false'); + }); + + test('skips chrome:// and chrome-extension:// internal pages', () => { + const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000); + expect(block).toContain("startsWith('chrome://')"); + expect(block).toContain("startsWith('chrome-extension://')"); + }); +}); + +describe('tab-each: behavior', () => { + function mockBm(tabs: Array<{ id: number; url: string; title: string; active: boolean }>) { + let activeId = tabs.find(t => t.active)?.id ?? tabs[0]?.id ?? 0; + const switched: number[] = []; + return { + __switched: switched, + __activeId: () => activeId, + getActiveSession: () => ({}), + getActiveTabId: () => activeId, + getTabListWithTitles: async () => tabs.map(t => ({ ...t })), + switchTab: (id: number, _opts?: any) => { switched.push(id); activeId = id; }, + } as any; + } + + test('iterates every tab, calls executeCommand for each, returns JSON results', async () => { + const tabs = [ + { id: 1, url: 'https://news.example.com', title: 'News', active: true }, + { id: 2, url: 'https://docs.example.com', title: 'Docs', active: false }, + { id: 3, url: 'https://github.com', title: 'GitHub', active: false }, + ]; + const bm = mockBm(tabs); + const calls: Array<{ command: string; args?: string[]; tabId?: number }> = []; + const out = await handleMetaCommand( + 'tab-each', + ['snapshot', '-i'], + bm, + async () => {}, + null, + { + executeCommand: async (body) => { + calls.push(body); + return { status: 200, result: `snap-of-${body.tabId}` }; + }, + }, + ); + + const parsed = JSON.parse(out); + expect(parsed.command).toBe('snapshot'); + expect(parsed.args).toEqual(['-i']); + expect(parsed.total).toBe(3); + expect(parsed.results.map((r: any) => r.tabId)).toEqual([1, 2, 3]); + expect(parsed.results.every((r: any) => r.status === 200)).toBe(true); + expect(parsed.results[0].output).toBe('snap-of-1'); + + // Inner command was dispatched 3 times, once per tab, with the right tabId. + expect(calls).toHaveLength(3); + expect(calls.map(c => c.tabId)).toEqual([1, 2, 3]); + expect(calls.every(c => c.command === 'snapshot')).toBe(true); + }); + + test('skips chrome:// pages with status=0 + "skipped" output', async () => { + const tabs = [ + { id: 1, url: 'chrome://newtab', title: 'New Tab', active: true }, + { id: 2, url: 'https://example.com', title: 'Example', active: false }, + { id: 3, url: 'chrome-extension://abc/page.html', title: 'Ext', active: false }, + ]; + const bm = mockBm(tabs); + const calls: any[] = []; + const out = await handleMetaCommand( + 'tab-each', + ['text'], + bm, + async () => {}, + null, + { + executeCommand: async (body) => { + calls.push(body); + return { status: 200, result: `text-of-${body.tabId}` }; + }, + }, + ); + + const parsed = JSON.parse(out); + expect(parsed.total).toBe(3); + // chrome:// and chrome-extension:// → skipped (status 0). + expect(parsed.results[0].status).toBe(0); + expect(parsed.results[0].output).toContain('skipped'); + expect(parsed.results[2].status).toBe(0); + // Only the real tab dispatched. + expect(calls).toHaveLength(1); + expect(calls[0].tabId).toBe(2); + }); + + test('restores the originally active tab even if a tab errors', async () => { + const tabs = [ + { id: 10, url: 'https://a.example', title: 'A', active: false }, + { id: 20, url: 'https://b.example', title: 'B', active: true }, // initially active + { id: 30, url: 'https://c.example', title: 'C', active: false }, + ]; + const bm = mockBm(tabs); + let calls = 0; + const out = await handleMetaCommand( + 'tab-each', + ['text'], + bm, + async () => {}, + null, + { + executeCommand: async (body) => { + calls++; + if (body.tabId === 20) { + return { status: 500, result: JSON.stringify({ error: 'boom' }) }; + } + return { status: 200, result: `ok-${body.tabId}` }; + }, + }, + ); + + const parsed = JSON.parse(out); + expect(parsed.results.find((r: any) => r.tabId === 20).status).toBe(500); + expect(parsed.results.find((r: any) => r.tabId === 20).output).toBe('boom'); + expect(parsed.results.find((r: any) => r.tabId === 10).status).toBe(200); + expect(parsed.results.find((r: any) => r.tabId === 30).status).toBe(200); + // Active tab restored to 20 (the one that was active when we started). + expect(bm.__activeId()).toBe(20); + }); + + test('throws on empty args (no inner command)', async () => { + const bm = mockBm([{ id: 1, url: 'https://x.example', title: 'X', active: true }]); + await expect(handleMetaCommand( + 'tab-each', + [], + bm, + async () => {}, + null, + { executeCommand: async () => ({ status: 200, result: '' }) }, + )).rejects.toThrow(/Usage/); + }); +}); diff --git a/browse/test/terminal-agent.test.ts b/browse/test/terminal-agent.test.ts index 205d6e75..d908052d 100644 --- a/browse/test/terminal-agent.test.ts +++ b/browse/test/terminal-agent.test.ts @@ -169,6 +169,38 @@ describe('Source-level guard: terminal-agent', () => { expect(dispose).toContain("'SIGKILL'"); expect(dispose).toContain('3000'); }); + + test('tabState frames write tabs.json + active-tab.json', () => { + expect(AGENT_SRC).toContain("msg?.type === 'tabState'"); + expect(AGENT_SRC).toContain('function handleTabState'); + const fn = AGENT_SRC.slice(AGENT_SRC.indexOf('function handleTabState')); + // Atomic write via tmp + rename for both files (so claude never reads + // a half-written JSON document). + expect(fn).toContain("'tabs.json'"); + expect(fn).toContain("'active-tab.json'"); + expect(fn).toContain('renameSync'); + // Skip chrome:// and chrome-extension:// pages — they're not useful + // targets for browse commands. + expect(fn).toContain("startsWith('chrome://')"); + expect(fn).toContain("startsWith('chrome-extension://')"); + }); + + test('claude is spawned with --append-system-prompt tab-awareness hint', () => { + expect(AGENT_SRC).toContain('function buildTabAwarenessHint'); + const hint = AGENT_SRC.slice(AGENT_SRC.indexOf('function buildTabAwarenessHint')); + // The hint must mention the live state files and the fanout command — + // those are the two affordances that distinguish a gstack-PTY claude + // from a plain `claude` session. + expect(hint).toContain('tabs.json'); + expect(hint).toContain('active-tab.json'); + expect(hint).toContain('tab-each'); + // And it must be passed via --append-system-prompt at spawn time + // (NOT written into the PTY as user input — that would pollute the + // visible transcript). + const spawn = AGENT_SRC.slice(AGENT_SRC.indexOf('function spawnClaude')); + expect(spawn).toContain("'--append-system-prompt'"); + expect(spawn).toContain('tabHint'); + }); }); describe('Source-level guard: server.ts /pty-session route', () => { diff --git a/extension/background.js b/extension/background.js index b05bf994..d0abe632 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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 ──────────────────────────────────────────────────── diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js index 9f36ffa2..5beff000 100644 --- a/extension/sidepanel-terminal.js +++ b/extension/sidepanel-terminal.js @@ -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 + // /active-tab.json + /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 diff --git a/extension/sidepanel.js b/extension/sidepanel.js index bac0051d..8d216a10 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -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 /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 }, + })); + } });