mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +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:
@@ -1035,6 +1035,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
|||||||
| `closetab [id]` | Close tab |
|
| `closetab [id]` | Close tab |
|
||||||
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
||||||
| `tab <id>` | Switch to tab |
|
| `tab <id>` | Switch to tab |
|
||||||
|
| `tab-each <command> [args...]` | Run a command on every open tab. Returns JSON with per-tab results. |
|
||||||
| `tabs` | List open tabs |
|
| `tabs` | List open tabs |
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|||||||
@@ -959,6 +959,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
|||||||
| `closetab [id]` | Close tab |
|
| `closetab [id]` | Close tab |
|
||||||
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
|
||||||
| `tab <id>` | Switch to tab |
|
| `tab <id>` | Switch to tab |
|
||||||
|
| `tab-each <command> [args...]` | Run a command on every open tab. Returns JSON with per-tab results. |
|
||||||
| `tabs` | List open tabs |
|
| `tabs` | List open tabs |
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const WRITE_COMMANDS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const META_COMMANDS = new Set([
|
export const META_COMMANDS = new Set([
|
||||||
'tabs', 'tab', 'newtab', 'closetab',
|
'tabs', 'tab', 'tab-each', 'newtab', 'closetab',
|
||||||
'status', 'stop', 'restart',
|
'status', 'stop', 'restart',
|
||||||
'screenshot', 'pdf', 'responsive',
|
'screenshot', 'pdf', 'responsive',
|
||||||
'chain', 'diff',
|
'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>' },
|
'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]' },
|
'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]' },
|
'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
|
// Server
|
||||||
'status': { category: 'Server', description: 'Health check' },
|
'status': { category: 'Server', description: 'Health check' },
|
||||||
'stop': { category: 'Server', description: 'Shutdown server' },
|
'stop': { category: 'Server', description: 'Shutdown server' },
|
||||||
|
|||||||
@@ -285,6 +285,108 @@ export async function handleMetaCommand(
|
|||||||
return `Closed tab${id ? ` ${id}` : ''}`;
|
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 ────────────────────────────────
|
// ─── Server Control ────────────────────────────────
|
||||||
case 'status': {
|
case 'status': {
|
||||||
const page = bm.getPage();
|
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. */
|
/** Spawn claude in a PTY. Returns null if claude not on PATH. */
|
||||||
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
|
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
|
||||||
const claudePath = findClaude();
|
const claudePath = findClaude();
|
||||||
@@ -120,7 +159,15 @@ function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void
|
|||||||
COLORTERM: 'truecolor',
|
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: {
|
terminal: {
|
||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
@@ -303,6 +350,10 @@ function buildServer() {
|
|||||||
handleTabSwitch(msg);
|
handleTabSwitch(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg?.type === 'tabState') {
|
||||||
|
handleTabState(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Unknown text frame — ignore.
|
// Unknown text frame — ignore.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -359,6 +410,65 @@ function buildServer() {
|
|||||||
* and notify the parent server so its activeTabId stays synced. Skips
|
* and notify the parent server so its activeTabId stays synced. Skips
|
||||||
* chrome:// and chrome-extension:// internal pages.
|
* 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 {
|
function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
|
||||||
const url = msg.url || '';
|
const url = msg.url || '';
|
||||||
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
|
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,38 @@ describe('Source-level guard: terminal-agent', () => {
|
|||||||
expect(dispose).toContain("'SIGKILL'");
|
expect(dispose).toContain("'SIGKILL'");
|
||||||
expect(dispose).toContain('3000');
|
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', () => {
|
describe('Source-level guard: server.ts /pty-session route', () => {
|
||||||
|
|||||||
+59
-4
@@ -287,6 +287,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||||
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
|
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
|
||||||
|
'getTabState',
|
||||||
// Inspector message types
|
// Inspector message types
|
||||||
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
|
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
|
||||||
'applyStyle', 'toggleClass', 'injectCSS', 'resetAll',
|
'applyStyle', 'toggleClass', 'injectCSS', 'resetAll',
|
||||||
@@ -302,6 +303,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'getTabState') {
|
||||||
|
snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] }));
|
||||||
|
return true; // async sendResponse
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'setPort') {
|
if (msg.type === 'setPort') {
|
||||||
savePort(msg.port).then(() => {
|
savePort(msg.port).then(() => {
|
||||||
checkHealth();
|
checkHealth();
|
||||||
@@ -506,11 +512,48 @@ chrome.runtime.onInstalled.addListener(() => {
|
|||||||
// Fire on every service worker startup (covers persistent context reuse)
|
// Fire on every service worker startup (covers persistent context reuse)
|
||||||
autoOpenSidePanel();
|
autoOpenSidePanel();
|
||||||
|
|
||||||
// ─── Tab Switch Detection ────────────────────────────────────────
|
// ─── Tab Awareness ───────────────────────────────────────────────
|
||||||
// Notify sidepanel instantly when the user switches tabs in the browser.
|
// Push live tab state to the sidepanel so claude in the Terminal pane
|
||||||
// This is faster than polling — the sidebar swaps chat context immediately.
|
// 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) => {
|
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) => {
|
chrome.tabs.get(activeInfo.tabId, (tab) => {
|
||||||
if (chrome.runtime.lastError || !tab) return;
|
if (chrome.runtime.lastError || !tab) return;
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
@@ -518,8 +561,20 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
|
|||||||
tabId: activeInfo.tabId,
|
tabId: activeInfo.tabId,
|
||||||
url: tab.url || '',
|
url: tab.url || '',
|
||||||
title: tab.title || '',
|
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 ────────────────────────────────────────────────────
|
// ─── Startup ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -240,6 +240,24 @@
|
|||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
} catch {}
|
} 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).
|
// Send a single byte to nudge the agent to spawn claude (lazy-spawn trigger).
|
||||||
try { ws.send(new TextEncoder().encode('\n')); } catch {}
|
try { ws.send(new TextEncoder().encode('\n')); } catch {}
|
||||||
});
|
});
|
||||||
@@ -335,6 +353,22 @@
|
|||||||
els.restartNow?.addEventListener('click', forceRestart);
|
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
|
// Repaint after a debug-tab → primary-pane transition. The debug
|
||||||
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
|
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
|
||||||
// .tab-content { display: none }; xterm doesn't auto-redraw when its
|
// .tab-content { display: none }; xterm doesn't auto-redraw when its
|
||||||
|
|||||||
@@ -1039,4 +1039,13 @@ chrome.runtime.onMessage.addListener((msg) => {
|
|||||||
inspectorPickerActive = false;
|
inspectorPickerActive = false;
|
||||||
inspectorPickBtn.classList.remove('active');
|
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