mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-09 14:55:37 +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:
@@ -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<string, { category: string; descriptio
|
||||
'tab': { category: 'Tabs', description: 'Switch to tab', usage: 'tab <id>' },
|
||||
'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 <command> [args...]' },
|
||||
// Server
|
||||
'status': { category: 'Server', description: 'Health check' },
|
||||
'stop': { category: 'Server', description: 'Shutdown server' },
|
||||
|
||||
@@ -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 <command> [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 <command> [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();
|
||||
|
||||
@@ -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 <stateDir>/tabs.json + active-tab.json
|
||||
* (updated continuously by the gstack browser extension).
|
||||
* 2. Run $B tab, $B tabs, $B tab-each <command> 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 <id> — switch to a tab',
|
||||
' $B newtab [url] — open a new tab',
|
||||
' $B closetab [id] — close a tab (current if no id)',
|
||||
' $B tab-each <command> — 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 <stateDir>/tabs.json (full list) and updates
|
||||
* <stateDir>/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;
|
||||
|
||||
Reference in New Issue
Block a user