Initial import from garrytan/gstack@026751e (main snapshot via local relay)
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled

Source: https://github.com/garrytan/gstack/commit/026751e
This commit is contained in:
Rocky
2026-05-19 21:18:17 +02:00
commit 834c6db075
797 changed files with 267839 additions and 0 deletions

603
extension/background.js Normal file
View File

@@ -0,0 +1,603 @@
/**
* gstack browse — background service worker
*
* Polls /health every 10s to detect browse server.
* Fetches /refs on snapshot completion, relays to content script.
* Proxies commands from sidebar → browse server.
* Updates badge: amber (connected), gray (disconnected).
*/
const DEFAULT_PORT = 34567; // Well-known port used by `$B connect`
let serverPort = null;
let authToken = null;
let isConnected = false;
let healthInterval = null;
// ─── Port Discovery ────────────────────────────────────────────
async function loadPort() {
const data = await chrome.storage.local.get('port');
serverPort = data.port || DEFAULT_PORT;
return serverPort;
}
async function savePort(port) {
serverPort = port;
await chrome.storage.local.set({ port });
}
function getBaseUrl() {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
// ─── Auth Token Bootstrap ─────────────────────────────────────
async function loadAuthToken() {
if (authToken) return;
// Get token from browse server /health endpoint (localhost-only, safe).
// Previously read from .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning.
const base = getBaseUrl();
if (!base) return;
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const data = await resp.json();
if (data.token) authToken = data.token;
}
} catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message);
}
}
// ─── Health Polling ────────────────────────────────────────────
async function checkHealth() {
const base = getBaseUrl();
if (!base) {
setDisconnected();
return;
}
// Retry loading auth token if we don't have one yet
if (!authToken) await loadAuthToken();
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setDisconnected(); return; }
const data = await resp.json();
if (data.status === 'healthy') {
// Always refresh auth token from /health — the server generates a new
// token on each restart, so the old one becomes stale.
if (data.token) authToken = data.token;
// Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else {
setDisconnected();
}
} catch (err) {
console.error('[gstack bg] Health check failed:', err.message);
setDisconnected();
}
}
function setConnected(healthData) {
const wasDisconnected = !isConnected;
isConnected = true;
chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' });
chrome.action.setBadgeText({ text: ' ' });
// Broadcast health to popup and side panel (token excluded — use getToken message instead)
chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch((err) => {
console.debug('[gstack bg] No listener for health broadcast:', err.message);
});
// Notify content scripts on connection change
if (wasDisconnected) {
notifyContentScripts('connected');
}
}
function setDisconnected() {
const wasConnected = isConnected;
isConnected = false;
// Keep authToken — it persists across reconnections
chrome.action.setBadgeText({ text: '' });
chrome.runtime.sendMessage({ type: 'health', data: null }).catch((err) => {
console.debug('[gstack bg] No listener for disconnect broadcast:', err.message);
});
// Notify content scripts on disconnection
if (wasConnected) {
notifyContentScripts('disconnected');
}
}
async function notifyContentScripts(type) {
try {
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch (err) {
console.error('[gstack bg] Failed to query tabs for notification:', err.message);
}
}
// ─── Command Proxy ─────────────────────────────────────────────
async function executeCommand(command, args) {
const base = getBaseUrl();
if (!base || !authToken) {
return { error: 'Not connected to browse server' };
}
try {
const resp = await fetch(`${base}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ command, args }),
signal: AbortSignal.timeout(30000),
});
const data = await resp.json();
return data;
} catch (err) {
return { error: err.message || 'Command failed' };
}
}
// ─── Refs Relay ─────────────────────────────────────────────────
async function fetchAndRelayRefs() {
const base = getBaseUrl();
if (!base || !isConnected) return;
try {
const headers = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000), headers });
if (!resp.ok) {
console.warn(`[gstack bg] Refs endpoint returned ${resp.status}`);
return;
}
const data = await resp.json();
// Send to all tabs' content scripts
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch (err) {
console.error('[gstack bg] Failed to fetch/relay refs:', err.message);
}
}
// ─── Inspector ──────────────────────────────────────────────────
// Track inspector mode per tab — 'full' (inspector.js injected) or 'basic' (content.js fallback)
let inspectorMode = 'full';
async function injectInspector(tabId) {
// Try full inspector injection first
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inspector.js'],
});
// CSS injection failure alone doesn't need fallback
try {
await chrome.scripting.insertCSS({
target: { tabId, allFrames: true },
files: ['inspector.css'],
});
} catch (err) {
console.debug('[gstack bg] Inspector CSS injection failed (non-fatal):', err.message);
}
// Send startPicker to the injected inspector.js
try {
await chrome.tabs.sendMessage(tabId, { type: 'startPicker' });
} catch (err) {
console.warn('[gstack bg] Failed to send startPicker:', err.message);
}
inspectorMode = 'full';
return { ok: true, mode: 'full' };
} catch (err) {
// Script injection failed (CSP, chrome:// page, etc.)
// Fall back to content.js basic picker (loaded by manifest on most pages)
try {
await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' });
inspectorMode = 'basic';
return { ok: true, mode: 'basic' };
} catch (err2) {
console.error('[gstack bg] Inspector injection failed completely:', err.message, '| Basic fallback:', err2.message);
inspectorMode = 'full';
return { error: 'Cannot inspect this page' };
}
}
}
async function stopInspector(tabId) {
try {
await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' });
} catch (err) {
console.debug('[gstack bg] Failed to stop picker on tab', tabId, ':', err.message);
}
return { ok: true };
}
async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) {
const base = getBaseUrl();
if (!base || !authToken) {
// No browse server — return basic data as fallback
return { mode: 'basic', selector, basicData, frameInfo };
}
try {
const resp = await fetch(`${base}/inspector/pick`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ selector, activeTabUrl, frameInfo }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
// Server error — fall back to basic mode
return { mode: 'basic', selector, basicData, frameInfo };
}
const data = await resp.json();
return { mode: 'cdp', ...data };
} catch (err) {
console.debug('[gstack bg] Inspector pick server unavailable, using basic mode:', err.message);
return { mode: 'basic', selector, basicData, frameInfo };
}
}
async function sendToContentScript(tabId, message) {
try {
const response = await chrome.tabs.sendMessage(tabId, message);
return response || { ok: true };
} catch {
return { error: 'Content script not available' };
}
}
// ─── Message Handling ──────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Security: only accept messages from this extension's own scripts
if (sender.id !== chrome.runtime.id) {
console.warn('[gstack] Rejected message from unknown sender:', sender.id);
return;
}
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',
'inspectResult'
]);
if (!ALLOWED_TYPES.has(msg.type)) {
console.warn('[gstack] Rejected unknown message type:', msg.type);
return;
}
if (msg.type === 'getPort') {
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
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();
sendResponse({ ok: true });
});
return true;
}
if (msg.type === 'getServerUrl') {
sendResponse({ url: getBaseUrl() });
return true;
}
// Token delivered via targeted sendResponse, not broadcast — limits exposure.
// Only respond to extension pages (sidepanel/popup) — content scripts have
// sender.tab set, so reject those to prevent token access from injected contexts.
if (msg.type === 'getToken') {
if (sender.tab) {
console.warn('[gstack] Rejected getToken from content script context');
sendResponse({ token: null });
} else {
sendResponse({ token: authToken });
}
return true;
}
if (msg.type === 'fetchRefs') {
fetchAndRelayRefs().then(() => sendResponse({ ok: true }));
return true;
}
// Open side panel from content script pill click
if (msg.type === 'openSidePanel') {
if (chrome.sidePanel?.open && sender.tab) {
chrome.sidePanel.open({ tabId: sender.tab.id }).catch((err) => {
console.warn('[gstack bg] Failed to open side panel:', err.message);
});
}
return;
}
// Sidebar opened — tell active tab's content script so the welcome page
// can hide its arrow hint. Only fires when the sidebar actually connects.
if (msg.type === 'sidebarOpened') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (tabId) {
chrome.tabs.sendMessage(tabId, { type: 'sidebarOpened' }).catch(() => {
// Expected: tab may not have content script
});
}
});
return;
}
// Inspector: inject + start picker
if (msg.type === 'startInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
injectInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: stop picker
if (msg.type === 'stopInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
stopInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: element picked by content script
if (msg.type === 'elementPicked') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const activeTabUrl = tabs?.[0]?.url || null;
const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null;
postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl)
.then(result => {
// Forward enriched result to sidepanel
chrome.runtime.sendMessage({
type: 'inspectResult',
data: {
...result,
selector: msg.selector,
tagName: msg.tagName,
classes: msg.classes,
id: msg.id,
dimensions: msg.dimensions,
basicData: msg.basicData,
frameInfo,
},
}).catch((err) => {
console.warn('[gstack bg] Failed to forward inspectResult to sidepanel:', err.message);
});
sendResponse({ ok: true });
});
});
return true;
}
// Inspector: picker cancelled
if (msg.type === 'pickerCancelled') {
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch((err) => {
console.debug('[gstack bg] No listener for pickerCancelled:', err.message);
});
return;
}
// Inspector: route alteration commands to content script
if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
sendToContentScript(tabId, msg).then(result => sendResponse(result));
});
return true;
}
// Sidebar → browse server command proxy
if (msg.type === 'command') {
executeCommand(msg.command, msg.args).then(result => sendResponse(result));
return true;
}
// Sidebar → Claude Code (file-based message queue)
if (msg.type === 'sidebar-command') {
const base = getBaseUrl();
if (!base || !authToken) {
sendResponse({ error: 'Not connected' });
return true;
}
// Capture the active tab's URL so the sidebar agent knows what page
// the user is actually looking at (Playwright's page.url() can be stale
// if the user navigated manually in headed mode).
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const activeTabUrl = tabs?.[0]?.url || null;
fetch(`${base}/sidebar-command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ message: msg.message, activeTabUrl }),
})
.then(r => {
if (!r.ok) {
console.error(`[gstack bg] sidebar-command failed: ${r.status} ${r.statusText}`);
return r.json().catch(() => ({ error: `Server returned ${r.status}` }));
}
return r.json();
})
.then(data => sendResponse(data))
.catch(err => {
console.error('[gstack bg] sidebar-command error:', err.message);
sendResponse({ error: err.message });
});
});
return true;
}
});
// ─── Side Panel ─────────────────────────────────────────────────
// Click extension icon → open side panel directly (no popup)
if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => {
console.warn('[gstack bg] Failed to set panel behavior:', err.message);
});
}
// Auto-open side panel with retry. chrome.sidePanel.open() can fail silently
// if the window/tab isn't fully ready yet. Retry up to 5 times with backoff.
async function autoOpenSidePanel() {
if (!chrome.sidePanel?.open) return;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const wins = await chrome.windows.getAll({ windowTypes: ['normal'] });
if (wins.length > 0) {
await chrome.sidePanel.open({ windowId: wins[0].id });
console.log(`[gstack] Side panel opened on attempt ${attempt + 1}`);
return; // success
}
} catch (e) {
// May throw if window isn't ready or user gesture required
console.log(`[gstack] Side panel open attempt ${attempt + 1} failed:`, e.message);
}
// Backoff: 500ms, 1000ms, 2000ms, 3000ms, 5000ms
await new Promise(r => setTimeout(r, [500, 1000, 2000, 3000, 5000][attempt]));
}
console.log('[gstack] Side panel auto-open failed after 5 attempts');
}
// Fire on install/update
chrome.runtime.onInstalled.addListener(() => {
autoOpenSidePanel();
});
// Fire on every service worker startup (covers persistent context reuse)
autoOpenSidePanel();
// ─── 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({
type: 'browserTabActivated',
tabId: activeInfo.tabId,
url: tab.url || '',
title: tab.title || '',
}).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 ────────────────────────────────────────────────────
// Fast-retry health check on startup. The server may not be listening yet
// (Chromium launches before Bun.serve starts). Retry every 1s for the
// first 15 seconds, then switch to 10s polling.
loadAuthToken().then(() => {
loadPort().then(() => {
let startupAttempts = 0;
const startupCheck = setInterval(async () => {
startupAttempts++;
await checkHealth();
if (isConnected || startupAttempts >= 15) {
clearInterval(startupCheck);
// Switch to slow polling now that we're connected (or gave up)
if (!healthInterval) {
healthInterval = setInterval(checkHealth, 10000);
}
if (!isConnected) {
console.log('[gstack] Startup health checks failed after 15 attempts, falling back to 10s polling');
}
}
}, 1000);
});
});

124
extension/content.css Normal file
View File

@@ -0,0 +1,124 @@
/* gstack browse — ref overlay + status pill styles
* Design system: DESIGN.md (amber accent, zinc neutrals)
*/
#gstack-ref-overlays {
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace !important;
}
/* Connection status pill — bottom-right corner */
#gstack-status-pill {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 2147483646;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(12, 12, 12, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 9999px;
color: #e0e0e0;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
pointer-events: auto;
cursor: pointer;
transition: opacity 0.5s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
#gstack-status-pill:hover {
opacity: 1 !important;
}
.gstack-pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #F59E0B;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.5);
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
#gstack-status-pill {
transition: none;
}
}
.gstack-ref-badge {
position: absolute;
background: rgba(220, 38, 38, 0.9);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 4px;
line-height: 14px;
pointer-events: none;
z-index: 2147483647;
}
/* Floating ref panel (used when positions are unknown) */
.gstack-ref-panel {
position: fixed;
bottom: 12px;
right: 12px;
width: 220px;
max-height: 300px;
background: rgba(12, 12, 12, 0.95);
border: 1px solid #262626;
border-radius: 8px;
overflow: hidden;
pointer-events: auto;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
font-size: 11px;
}
.gstack-ref-panel-header {
padding: 6px 10px;
background: #141414;
border-bottom: 1px solid #262626;
color: #FAFAFA;
font-weight: 600;
font-size: 11px;
}
.gstack-ref-panel-list {
max-height: 260px;
overflow-y: auto;
}
.gstack-ref-panel-row {
padding: 3px 10px;
border-bottom: 1px solid #1f1f1f;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gstack-ref-panel-id {
color: #FBBF24;
font-weight: 600;
margin-right: 4px;
}
.gstack-ref-panel-role {
color: #A1A1AA;
margin-right: 4px;
}
.gstack-ref-panel-name {
color: #e0e0e0;
}
.gstack-ref-panel-more {
padding: 4px 10px;
color: #52525B;
font-style: italic;
}

378
extension/content.js Normal file
View File

@@ -0,0 +1,378 @@
/**
* gstack browse — content script
*
* Receives ref data from background worker via chrome.runtime.onMessage.
* Renders @ref overlay badges on the page (CDP mode only — positions are accurate).
* In headless mode, shows a floating ref panel instead (positions unknown).
*/
let overlayContainer = null;
let statusPill = null;
let pillFadeTimer = null;
let refCount = 0;
// ─── Connection Status Pill ──────────────────────────────────
function showStatusPill(connected, refs) {
refCount = refs || 0;
if (!statusPill) {
statusPill = document.createElement('div');
statusPill.id = 'gstack-status-pill';
statusPill.style.cursor = 'pointer';
statusPill.addEventListener('click', () => {
// Ask background to open the side panel
chrome.runtime.sendMessage({ type: 'openSidePanel' });
});
document.body.appendChild(statusPill);
}
if (!connected) {
statusPill.style.display = 'none';
return;
}
const refText = refCount > 0 ? ` · ${refCount} refs` : '';
statusPill.innerHTML = `<span class="gstack-pill-dot"></span> gstack${refText}`;
statusPill.style.display = 'flex';
statusPill.style.opacity = '1';
// Fade to subtle after 3s
clearTimeout(pillFadeTimer);
pillFadeTimer = setTimeout(() => {
statusPill.style.opacity = '0.3';
}, 3000);
}
function hideStatusPill() {
if (statusPill) {
statusPill.style.display = 'none';
}
}
function ensureContainer() {
if (overlayContainer) return overlayContainer;
overlayContainer = document.createElement('div');
overlayContainer.id = 'gstack-ref-overlays';
overlayContainer.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
document.body.appendChild(overlayContainer);
return overlayContainer;
}
function clearOverlays() {
if (overlayContainer) {
overlayContainer.innerHTML = '';
}
}
function renderRefBadges(refs) {
clearOverlays();
if (!refs || refs.length === 0) return;
const container = ensureContainer();
for (const ref of refs) {
// Try to find the element using accessible name/role for positioning
// In CDP mode, we could use bounding boxes from the server
// For now, use a floating panel approach
const badge = document.createElement('div');
badge.className = 'gstack-ref-badge';
badge.textContent = ref.ref;
badge.title = `${ref.role}: "${ref.name}"`;
container.appendChild(badge);
}
}
function renderRefPanel(refs) {
clearOverlays();
if (!refs || refs.length === 0) return;
const container = ensureContainer();
const panel = document.createElement('div');
panel.className = 'gstack-ref-panel';
const header = document.createElement('div');
header.className = 'gstack-ref-panel-header';
header.textContent = `gstack refs (${refs.length})`;
header.style.cssText = 'pointer-events: auto; cursor: move;';
panel.appendChild(header);
const list = document.createElement('div');
list.className = 'gstack-ref-panel-list';
for (const ref of refs.slice(0, 30)) { // Show max 30 in panel
const row = document.createElement('div');
row.className = 'gstack-ref-panel-row';
const idSpan = document.createElement('span');
idSpan.className = 'gstack-ref-panel-id';
idSpan.textContent = ref.ref;
const roleSpan = document.createElement('span');
roleSpan.className = 'gstack-ref-panel-role';
roleSpan.textContent = ref.role;
const nameSpan = document.createElement('span');
nameSpan.className = 'gstack-ref-panel-name';
nameSpan.textContent = '"' + ref.name + '"';
row.append(idSpan, document.createTextNode(' '), roleSpan, document.createTextNode(' '), nameSpan);
list.appendChild(row);
}
if (refs.length > 30) {
const more = document.createElement('div');
more.className = 'gstack-ref-panel-more';
more.textContent = `+${refs.length - 30} more`;
list.appendChild(more);
}
panel.appendChild(list);
container.appendChild(panel);
}
// ─── Basic Inspector Picker (CSP fallback) ──────────────────
// When inspector.js can't be injected (CSP, chrome:// pages), content.js
// provides a basic element picker using getComputedStyle + CSSOM.
let basicPickerActive = false;
let basicPickerOverlay = null;
let basicPickerLastEl = null;
let basicPickerSavedOutline = '';
const BASIC_KEY_PROPERTIES = [
'display', 'position', 'top', 'right', 'bottom', 'left',
'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
'color', 'background-color', 'background-image',
'font-family', 'font-size', 'font-weight', 'line-height',
'text-align', 'text-decoration',
'overflow', 'overflow-x', 'overflow-y',
'opacity', 'z-index',
'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
'grid-template-columns', 'grid-template-rows',
'box-shadow', 'border-radius', 'transform',
];
function captureBasicData(el) {
const computed = getComputedStyle(el);
const rect = el.getBoundingClientRect();
const computedStyles = {};
for (const prop of BASIC_KEY_PROPERTIES) {
computedStyles[prop] = computed.getPropertyValue(prop);
}
const boxModel = {
content: { width: rect.width, height: rect.height },
padding: {
top: parseFloat(computed.paddingTop) || 0,
right: parseFloat(computed.paddingRight) || 0,
bottom: parseFloat(computed.paddingBottom) || 0,
left: parseFloat(computed.paddingLeft) || 0,
},
border: {
top: parseFloat(computed.borderTopWidth) || 0,
right: parseFloat(computed.borderRightWidth) || 0,
bottom: parseFloat(computed.borderBottomWidth) || 0,
left: parseFloat(computed.borderLeftWidth) || 0,
},
margin: {
top: parseFloat(computed.marginTop) || 0,
right: parseFloat(computed.marginRight) || 0,
bottom: parseFloat(computed.marginBottom) || 0,
left: parseFloat(computed.marginLeft) || 0,
},
};
// Matched CSS rules via CSSOM (same-origin only)
const matchedRules = [];
try {
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
for (const rule of rules) {
if (rule.type !== CSSRule.STYLE_RULE) continue;
try {
if (el.matches(rule.selectorText)) {
const properties = [];
for (let i = 0; i < rule.style.length; i++) {
const prop = rule.style[i];
properties.push({
name: prop,
value: rule.style.getPropertyValue(prop),
priority: rule.style.getPropertyPriority(prop),
});
}
matchedRules.push({
selector: rule.selectorText,
properties,
source: sheet.href || 'inline',
});
}
} catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; }
}
} catch (e) { if (!(e instanceof DOMException)) throw e; }
}
} catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; }
return { computedStyles, boxModel, matchedRules };
}
function basicBuildSelector(el) {
if (el.id) {
const sel = '#' + CSS.escape(el.id);
try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; }
}
const parts = [];
let current = el;
while (current && current !== document.body && current !== document.documentElement) {
let part = current.tagName.toLowerCase();
if (current.id) {
parts.unshift('#' + CSS.escape(current.id));
break;
}
if (current.className && typeof current.className === 'string') {
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
if (classes.length > 0) part += '.' + classes.map(c => CSS.escape(c)).join('.');
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(s => s.tagName === current.tagName);
if (siblings.length > 1) {
part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`;
}
}
parts.unshift(part);
current = current.parentElement;
}
return parts.join(' > ');
}
function basicPickerHighlight(el) {
// Restore previous element
if (basicPickerLastEl && basicPickerLastEl !== el) {
basicPickerLastEl.style.outline = basicPickerSavedOutline;
}
if (el) {
basicPickerSavedOutline = el.style.outline;
el.style.outline = '2px solid rgba(59, 130, 246, 0.6)';
basicPickerLastEl = el;
}
}
function basicPickerCleanup() {
if (basicPickerLastEl) {
basicPickerLastEl.style.outline = basicPickerSavedOutline;
basicPickerLastEl = null;
basicPickerSavedOutline = '';
}
basicPickerActive = false;
document.removeEventListener('mousemove', onBasicMouseMove, true);
document.removeEventListener('click', onBasicClick, true);
document.removeEventListener('keydown', onBasicKeydown, true);
}
function onBasicMouseMove(e) {
if (!basicPickerActive) return;
e.preventDefault();
e.stopPropagation();
const el = document.elementFromPoint(e.clientX, e.clientY);
if (el && el !== basicPickerLastEl) {
basicPickerHighlight(el);
}
}
function onBasicClick(e) {
if (!basicPickerActive) return;
e.preventDefault();
e.stopPropagation();
const el = e.target;
const basicData = captureBasicData(el);
const selector = basicBuildSelector(el);
const tagName = el.tagName.toLowerCase();
const id = el.id || null;
const classes = el.className && typeof el.className === 'string'
? el.className.trim().split(/\s+/).filter(c => c.length > 0)
: [];
basicPickerCleanup();
chrome.runtime.sendMessage({
type: 'inspectResult',
data: {
selector,
tagName,
id,
classes,
basicData,
mode: 'basic',
boxModel: basicData.boxModel,
computedStyles: basicData.computedStyles,
matchedRules: basicData.matchedRules,
},
});
}
function onBasicKeydown(e) {
if (e.key === 'Escape') {
basicPickerCleanup();
chrome.runtime.sendMessage({ type: 'pickerCancelled' });
}
}
function startBasicPicker() {
basicPickerActive = true;
document.addEventListener('mousemove', onBasicMouseMove, true);
document.addEventListener('click', onBasicClick, true);
document.addEventListener('keydown', onBasicKeydown, true);
}
// Do NOT dispatch gstack-extension-ready here — the extension being loaded
// does not mean the sidebar is open. The welcome page arrow hint should only
// hide when the sidebar is actually open. We dispatch it when we receive
// a 'sidebarOpened' message from background.js.
// Listen for messages from background worker
chrome.runtime.onMessage.addListener((msg) => {
// Sidebar actually opened — now hide the welcome page arrow hint
if (msg.type === 'sidebarOpened') {
document.dispatchEvent(new CustomEvent('gstack-extension-ready'));
return;
}
if (msg.type === 'startBasicPicker') {
startBasicPicker();
return;
}
if (msg.type === 'stopBasicPicker') {
basicPickerCleanup();
return;
}
if (msg.type === 'refs' && msg.data) {
const refs = msg.data.refs || [];
const mode = msg.data.mode;
if (refs.length === 0) {
clearOverlays();
showStatusPill(true, 0);
return;
}
// CDP mode: could use bounding boxes (future)
// For now: floating panel for all modes
renderRefPanel(refs);
showStatusPill(true, refs.length);
}
if (msg.type === 'clearRefs') {
clearOverlays();
showStatusPill(true, 0);
}
if (msg.type === 'connected') {
showStatusPill(true, refCount);
}
if (msg.type === 'disconnected') {
hideStatusPill();
clearOverlays();
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
extension/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

29
extension/inspector.css Normal file
View File

@@ -0,0 +1,29 @@
/* gstack browse — CSS Inspector overlay styles
* Injected alongside inspector.js into the active tab.
* Design system: amber accent, zinc neutrals.
*/
#gstack-inspector-highlight {
position: fixed;
pointer-events: none;
z-index: 2147483647;
background: rgba(59, 130, 246, 0.15);
border: 2px solid rgba(59, 130, 246, 0.6);
border-radius: 2px;
transition: top 50ms ease, left 50ms ease, width 50ms ease, height 50ms ease;
}
#gstack-inspector-tooltip {
position: fixed;
pointer-events: none;
z-index: 2147483647;
background: #27272A;
color: #e0e0e0;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
line-height: 18px;
}

474
extension/inspector.js Normal file
View File

@@ -0,0 +1,474 @@
/**
* gstack browse — CSS Inspector content script
*
* Dynamically injected via chrome.scripting.executeScript.
* Provides element picker, selector generation, basic computed style capture,
* and page alteration handlers for agent-pushed CSS changes.
*/
(() => {
// Guard against double-injection
if (window.__gstackInspectorActive) return;
window.__gstackInspectorActive = true;
// ─── State ──────────────────────────────────────────────────────
let pickerActive = false;
let highlightEl = null;
let tooltipEl = null;
let lastPickTime = 0;
const PICK_DEBOUNCE_MS = 200;
// Track original inline styles for resetAll
const originalStyles = new Map(); // element -> Map<property, value>
const injectedStyleIds = new Set();
// ─── Highlight Overlay ──────────────────────────────────────────
function createHighlight() {
if (highlightEl) return;
highlightEl = document.createElement('div');
highlightEl.id = 'gstack-inspector-highlight';
highlightEl.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 2147483647;
background: rgba(59, 130, 246, 0.15);
border: 2px solid rgba(59, 130, 246, 0.6);
border-radius: 2px;
transition: top 50ms, left 50ms, width 50ms, height 50ms;
`;
document.documentElement.appendChild(highlightEl);
tooltipEl = document.createElement('div');
tooltipEl.id = 'gstack-inspector-tooltip';
tooltipEl.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 2147483647;
background: #27272A;
color: #e0e0e0;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
display: none;
`;
document.documentElement.appendChild(tooltipEl);
}
function removeHighlight() {
if (highlightEl) { highlightEl.remove(); highlightEl = null; }
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
}
function updateHighlight(el) {
if (!highlightEl || !tooltipEl) return;
const rect = el.getBoundingClientRect();
highlightEl.style.top = rect.top + 'px';
highlightEl.style.left = rect.left + 'px';
highlightEl.style.width = rect.width + 'px';
highlightEl.style.height = rect.height + 'px';
highlightEl.style.display = 'block';
// Build tooltip text: <tag> .classes WxH
const tag = el.tagName.toLowerCase();
const classes = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/).join('.')
: '';
const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim();
// Position tooltip above element, or below if no room
const tooltipHeight = 24;
const gap = 6;
let tooltipTop = rect.top - tooltipHeight - gap;
if (tooltipTop < 4) tooltipTop = rect.bottom + gap;
let tooltipLeft = rect.left;
if (tooltipLeft < 4) tooltipLeft = 4;
tooltipEl.style.top = tooltipTop + 'px';
tooltipEl.style.left = tooltipLeft + 'px';
tooltipEl.style.display = 'block';
}
// ─── Selector Generation ────────────────────────────────────────
function buildSelector(el) {
// If element has an id, use it directly
if (el.id) {
const sel = '#' + CSS.escape(el.id);
if (isUnique(sel)) return sel;
}
// Build path from element up to nearest ancestor with id or body
const parts = [];
let current = el;
while (current && current !== document.body && current !== document.documentElement) {
let part = current.tagName.toLowerCase();
// If current has an id, use it and stop
if (current.id) {
part = '#' + CSS.escape(current.id);
parts.unshift(part);
break;
}
// Add classes
if (current.className && typeof current.className === 'string') {
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
if (classes.length > 0) {
part += '.' + classes.map(c => CSS.escape(c)).join('.');
}
}
// Add nth-child if needed to disambiguate
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
s => s.tagName === current.tagName
);
if (siblings.length > 1) {
const idx = siblings.indexOf(current) + 1;
part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`;
}
}
parts.unshift(part);
current = current.parentElement;
}
// If we didn't reach an id, prepend body
if (parts.length > 0 && !parts[0].startsWith('#')) {
// Don't prepend body, just use the path as-is
}
const selector = parts.join(' > ');
// Verify uniqueness
if (isUnique(selector)) return selector;
// Fallback: add nth-child at each level until unique
return selector;
}
function isUnique(selector) {
try {
return document.querySelectorAll(selector).length === 1;
} catch (e) {
if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e;
return false;
}
}
// ─── Basic Mode Data Capture ────────────────────────────────────
const KEY_PROPERTIES = [
'display', 'position', 'top', 'right', 'bottom', 'left',
'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
'color', 'background-color', 'background-image',
'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing',
'text-align', 'text-decoration', 'text-transform',
'overflow', 'overflow-x', 'overflow-y',
'opacity', 'z-index',
'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
'grid-template-columns', 'grid-template-rows',
'box-shadow', 'border-radius',
'transition', 'transform',
];
function captureBasicData(el) {
const computed = getComputedStyle(el);
const rect = el.getBoundingClientRect();
// Capture key computed properties
const computedStyles = {};
for (const prop of KEY_PROPERTIES) {
computedStyles[prop] = computed.getPropertyValue(prop);
}
// Box model from computed
const boxModel = {
content: { width: rect.width, height: rect.height },
padding: {
top: parseFloat(computed.paddingTop) || 0,
right: parseFloat(computed.paddingRight) || 0,
bottom: parseFloat(computed.paddingBottom) || 0,
left: parseFloat(computed.paddingLeft) || 0,
},
border: {
top: parseFloat(computed.borderTopWidth) || 0,
right: parseFloat(computed.borderRightWidth) || 0,
bottom: parseFloat(computed.borderBottomWidth) || 0,
left: parseFloat(computed.borderLeftWidth) || 0,
},
margin: {
top: parseFloat(computed.marginTop) || 0,
right: parseFloat(computed.marginRight) || 0,
bottom: parseFloat(computed.marginBottom) || 0,
left: parseFloat(computed.marginLeft) || 0,
},
};
// Matched CSS rules via CSSOM (same-origin only)
const matchedRules = [];
try {
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
for (const rule of rules) {
if (rule.type !== CSSRule.STYLE_RULE) continue;
try {
if (el.matches(rule.selectorText)) {
const properties = [];
for (let i = 0; i < rule.style.length; i++) {
const prop = rule.style[i];
properties.push({
name: prop,
value: rule.style.getPropertyValue(prop),
priority: rule.style.getPropertyPriority(prop),
});
}
matchedRules.push({
selector: rule.selectorText,
properties,
source: sheet.href || 'inline',
});
}
} catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; }
}
} catch (e) { if (!(e instanceof DOMException)) throw e; }
}
} catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; }
return { computedStyles, boxModel, matchedRules };
}
// ─── Picker Event Handlers ──────────────────────────────────────
function onMouseMove(e) {
if (!pickerActive) return;
// Ignore our own overlay elements
const target = e.target;
if (target === highlightEl || target === tooltipEl) return;
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
updateHighlight(target);
}
function onClick(e) {
if (!pickerActive) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Debounce
const now = Date.now();
if (now - lastPickTime < PICK_DEBOUNCE_MS) return;
lastPickTime = now;
const target = e.target;
if (target === highlightEl || target === tooltipEl) return;
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
const selector = buildSelector(target);
const basicData = captureBasicData(target);
// Frame detection
const frameInfo = {};
if (window !== window.top) {
try {
frameInfo.frameSrc = window.location.href;
frameInfo.frameName = window.name || null;
} catch (e) { if (!(e instanceof DOMException)) throw e; }
}
chrome.runtime.sendMessage({
type: 'elementPicked',
selector,
tagName: target.tagName.toLowerCase(),
classes: target.className && typeof target.className === 'string'
? target.className.trim().split(/\s+/).filter(c => c.length > 0)
: [],
id: target.id || null,
dimensions: {
width: Math.round(target.getBoundingClientRect().width),
height: Math.round(target.getBoundingClientRect().height),
},
basicData,
...frameInfo,
});
// Keep highlight on the picked element
}
function onKeyDown(e) {
if (!pickerActive) return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
stopPicker();
chrome.runtime.sendMessage({ type: 'pickerCancelled' });
}
}
// ─── Picker Start/Stop ──────────────────────────────────────────
function startPicker() {
if (pickerActive) return;
pickerActive = true;
createHighlight();
document.addEventListener('mousemove', onMouseMove, true);
document.addEventListener('click', onClick, true);
document.addEventListener('keydown', onKeyDown, true);
}
function stopPicker() {
if (!pickerActive) return;
pickerActive = false;
removeHighlight();
document.removeEventListener('mousemove', onMouseMove, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('keydown', onKeyDown, true);
}
// ─── Page Alteration Handlers ───────────────────────────────────
function findElement(selector) {
try {
return document.querySelector(selector);
} catch (e) {
if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e;
return null;
}
}
function applyStyle(selector, property, value) {
// Validate property name: alphanumeric + hyphens only
if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' };
// Validate CSS value: block exfiltration vectors (url(), expression(), @import, javascript:, data:)
if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(value)) {
return { error: 'CSS value contains blocked pattern' };
}
const el = findElement(selector);
if (!el) return { error: 'Element not found' };
// Track original value for resetAll
if (!originalStyles.has(el)) {
originalStyles.set(el, new Map());
}
const origMap = originalStyles.get(el);
if (!origMap.has(property)) {
origMap.set(property, el.style.getPropertyValue(property));
}
el.style.setProperty(property, value, 'important');
return { ok: true };
}
function toggleClass(selector, className, action) {
if (!/^[a-zA-Z0-9_-]+$/.test(className)) {
return { error: 'Invalid class name' };
}
const el = findElement(selector);
if (!el) return { error: 'Element not found' };
if (action === 'add') {
el.classList.add(className);
} else if (action === 'remove') {
el.classList.remove(className);
} else {
el.classList.toggle(className);
}
return { ok: true };
}
function injectCSS(id, css) {
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
return { error: 'Invalid CSS injection id' };
}
if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(css)) {
return { error: 'CSS contains blocked pattern (url, expression, @import)' };
}
const styleId = `gstack-inject-${id}`;
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
injectedStyleIds.add(styleId);
return { ok: true };
}
function resetAll() {
// Restore original inline styles
for (const [el, propMap] of originalStyles) {
for (const [prop, origVal] of propMap) {
if (origVal) {
el.style.setProperty(prop, origVal);
} else {
el.style.removeProperty(prop);
}
}
}
originalStyles.clear();
// Remove injected style elements
for (const id of injectedStyleIds) {
const el = document.getElementById(id);
if (el) el.remove();
}
injectedStyleIds.clear();
return { ok: true };
}
// ─── Message Listener ──────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'startPicker') {
startPicker();
sendResponse({ ok: true });
return;
}
if (msg.type === 'stopPicker') {
stopPicker();
sendResponse({ ok: true });
return;
}
if (msg.type === 'applyStyle') {
const result = applyStyle(msg.selector, msg.property, msg.value);
sendResponse(result);
return;
}
if (msg.type === 'toggleClass') {
const result = toggleClass(msg.selector, msg.className, msg.action);
sendResponse(result);
return;
}
if (msg.type === 'injectCSS') {
const result = injectCSS(msg.id, msg.css);
sendResponse(result);
return;
}
if (msg.type === 'resetAll') {
const result = resetAll();
sendResponse(result);
return;
}
});
})();

31
extension/manifest.json Normal file
View File

@@ -0,0 +1,31 @@
{
"manifest_version": 3,
"name": "gstack browse",
"version": "0.1.0",
"description": "Live activity feed and @ref overlays for gstack browse",
"permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"],
"host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"],
"action": {
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"side_panel": {
"default_path": "sidepanel.html"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["content.css"]
}],
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}

98
extension/popup.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 240px;
background: #0C0C0C;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
padding: 16px;
}
h1 {
font-size: 16px;
font-weight: 700;
color: #FAFAFA;
margin-bottom: 16px;
letter-spacing: -0.3px;
}
label {
display: block;
font-size: 12px;
color: #A1A1AA;
margin-bottom: 4px;
}
input {
width: 100%;
padding: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 8px;
color: #FAFAFA;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
outline: none;
transition: border-color 150ms;
}
input:focus { border-color: #F59E0B; }
.status {
margin: 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3f3f46;
flex-shrink: 0;
}
.dot.connected { background: #22C55E; }
.dot.error { background: #EF4444; }
.dot.reconnecting {
background: #F59E0B;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.status-text { color: #A1A1AA; font-size: 12px; }
.status-text.connected { color: #22C55E; }
.details { color: #52525B; font-size: 11px; margin-top: 2px; }
button {
width: 100%;
margin-top: 12px;
padding: 8px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid #F59E0B;
border-radius: 8px;
color: #FBBF24;
font-size: 13px;
cursor: pointer;
transition: all 150ms;
}
button:hover { background: rgba(245, 158, 11, 0.2); }
</style>
</head>
<body>
<h1>gstack</h1>
<label>Port</label>
<input type="text" id="port" placeholder="34567" autocomplete="off">
<div class="status">
<div class="dot" id="dot"></div>
<span class="status-text" id="status-text">Disconnected</span>
</div>
<div class="details" id="details"></div>
<button id="side-panel-btn">Open Side Panel</button>
<script src="popup.js"></script>
</body>
</html>

60
extension/popup.js Normal file
View File

@@ -0,0 +1,60 @@
const portInput = document.getElementById('port');
const dot = document.getElementById('dot');
const statusText = document.getElementById('status-text');
const details = document.getElementById('details');
const sidePanelBtn = document.getElementById('side-panel-btn');
// Load saved port
chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
if (resp && resp.port) {
portInput.value = resp.port;
updateStatus(resp.connected);
}
});
// Save port on change
let saveTimeout;
portInput.addEventListener('input', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const port = parseInt(portInput.value, 10);
if (port > 0 && port < 65536) {
chrome.runtime.sendMessage({ type: 'setPort', port });
}
}, 500);
});
// Listen for health updates
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'health') {
updateStatus(!!msg.data, msg.data);
}
});
function updateStatus(connected, data) {
dot.className = `dot ${connected ? 'connected' : ''}`;
statusText.className = `status-text ${connected ? 'connected' : ''}`;
statusText.textContent = connected ? 'Connected' : 'Disconnected';
if (connected && data) {
const parts = [];
if (data.tabs) parts.push(`${data.tabs} tabs`);
if (data.mode) parts.push(`Mode: ${data.mode}`);
details.textContent = parts.join(' \u00b7 ');
} else {
details.textContent = '';
}
}
// Open side panel
sidePanelBtn.addEventListener('click', async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) {
await chrome.sidePanel.open({ tabId: tab.id });
window.close();
}
} catch (err) {
details.textContent = `Side panel error: ${err.message}`;
}
});

View File

@@ -0,0 +1,460 @@
/**
* Terminal sidebar tab — interactive Claude Code PTY in xterm.js.
*
* Lifecycle (per plan + codex review):
* 1. Sidebar opens. Terminal is the default-active tab.
* 2. Bootstrap card shows "Press any key to start Claude Code."
* 3. On first keystroke (lazy spawn — codex finding #8): the extension
* a) POSTs /pty-session on the browse server with the AUTH_TOKEN to
* mint a short-lived HttpOnly cookie scoped to the terminal-agent.
* b) Opens ws://127.0.0.1:<terminalPort>/ws — the cookie travels
* automatically. Terminal-agent validates the cookie + the
* chrome-extension:// Origin (codex finding #9), then spawns
* claude in a PTY.
* 4. Bytes pump both ways. Resize observer sends {type:"resize"} text
* frames; tab-switch hooks send {type:"tabSwitch"} frames.
* 5. PTY exits or WS closes -> we show "Session ended" with a restart
* button. We do NOT auto-reconnect (codex finding #8: auto-reconnect
* = burn fresh claude session every time).
*
* Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded
* via <script src> tags in sidepanel.html (window.Terminal, window.FitAddon).
*/
(function () {
'use strict';
const Terminal = window.Terminal;
const FitAddonModule = window.FitAddon;
if (!Terminal) {
console.error('[gstack terminal] xterm not loaded');
return;
}
const els = {
bootstrap: document.getElementById('terminal-bootstrap'),
bootstrapStatus: document.getElementById('terminal-bootstrap-status'),
installCard: document.getElementById('terminal-install-card'),
installRetry: document.getElementById('terminal-install-retry'),
mount: document.getElementById('terminal-mount'),
ended: document.getElementById('terminal-ended'),
restart: document.getElementById('terminal-restart'),
restartNow: document.getElementById('terminal-restart-now'),
};
/** State machine. */
const STATE = { IDLE: 'idle', CONNECTING: 'connecting', LIVE: 'live', ENDED: 'ended', NO_CLAUDE: 'no-claude' };
let state = STATE.IDLE;
let term = null;
let fitAddon = null;
let ws = null;
function show(el) { el.style.display = ''; }
function hide(el) { el.style.display = 'none'; }
function setState(next, opts = {}) {
state = next;
switch (next) {
case STATE.IDLE:
show(els.bootstrap);
hide(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = opts.message || 'Press any key to start Claude Code.';
break;
case STATE.CONNECTING:
show(els.bootstrap);
hide(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = 'Connecting...';
break;
case STATE.LIVE:
hide(els.bootstrap);
hide(els.installCard);
show(els.mount);
hide(els.ended);
break;
case STATE.ENDED:
hide(els.bootstrap);
hide(els.installCard);
hide(els.mount);
show(els.ended);
break;
case STATE.NO_CLAUDE:
show(els.bootstrap);
show(els.installCard);
hide(els.mount);
hide(els.ended);
els.bootstrapStatus.textContent = '';
break;
}
}
/**
* Read auth + terminalPort from the server's /health. We don't fetch this
* here — sidepanel.js already polls /health for connection state and
* exposes the relevant fields on window.gstackHealth (set below in init()).
* If terminalPort is missing, the agent isn't ready yet.
*/
function getHealth() {
return window.gstackHealth || {};
}
function getServerPort() {
return window.gstackServerPort || null;
}
function getAuthToken() {
return window.gstackAuthToken || null;
}
/**
* POST /pty-session to mint a fresh terminal session. Returns
* { terminalPort, ptySessionToken, expiresAt } on success, or
* { error } on failure. The token rides on the WebSocket
* Sec-WebSocket-Protocol header, which is the only auth header
* the browser WebSocket API lets us set. The token is NOT persisted —
* each sidebar load mints a fresh one and discards it on close.
*/
async function mintSession() {
const serverPort = getServerPort();
const token = getAuthToken();
if (!serverPort || !token) {
return { error: 'browse server not ready' };
}
try {
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
credentials: 'include',
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
return { error: `${resp.status} ${body || resp.statusText}` };
}
return await resp.json();
} catch (err) {
return { error: err && err.message ? err.message : String(err) };
}
}
async function checkClaudeAvailable(terminalPort) {
try {
const resp = await fetch(`http://127.0.0.1:${terminalPort}/claude-available`, {
credentials: 'include',
});
if (!resp.ok) return { available: false };
return await resp.json();
} catch {
return { available: false };
}
}
function ensureXterm() {
if (term) return;
term = new Terminal({
fontFamily: '"JetBrains Mono", "SF Mono", Menlo, "Noto Sans Mono CJK KR", "Malgun Gothic", monospace',
fontSize: 13,
theme: { background: '#0a0a0a', foreground: '#e5e5e5' },
cursorBlink: true,
scrollback: 5000,
allowTransparency: false,
convertEol: false,
});
if (FitAddonModule && FitAddonModule.FitAddon) {
fitAddon = new FitAddonModule.FitAddon();
term.loadAddon(fitAddon);
}
// CRITICAL: caller must make els.mount visible BEFORE invoking
// ensureXterm. xterm.js measures the container synchronously inside
// term.open() — if the mount is display:none, xterm caches a 0-size
// viewport and never auto-grows even after the container goes
// visible. The visible-first pattern is enforced by connect()
// calling setState(STATE.LIVE) before us.
term.open(els.mount);
// First fit waits for the next paint frame so the browser has
// applied the .active class transition. Otherwise term.cols/rows
// can come back as the minimum (2x2) when the mount's clientHeight
// is still being computed.
requestAnimationFrame(() => {
try {
fitAddon && fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
} catch {}
});
const ro = new ResizeObserver(() => {
try {
fitAddon && fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
} catch {}
});
ro.observe(els.mount);
// IME composition handling for Korean/CJK input (issue #1272).
// Suppress partial jamo during composition; only send the final
// composed string on compositionend. Without this, Korean IME
// sends fragmented input or doubles characters.
let composing = false;
const ta = term.textarea;
if (ta) {
ta.addEventListener('compositionstart', () => { composing = true; });
ta.addEventListener('compositionend', (e) => {
composing = false;
if (e.data && ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(e.data));
}
});
}
term.onData((data) => {
if (composing) return; // suppress partial input events during IME composition
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
}
/**
* Inject a string into the live PTY (the same way a real keystroke would).
* Used by the toolbar's Cleanup button and the Inspector's "Send to Code"
* action so the user can drive claude from outside-the-keyboard surfaces.
* Returns true if the bytes went out, false if no live session.
*/
window.gstackInjectToTerminal = function (text) {
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false;
try {
ws.send(new TextEncoder().encode(text));
return true;
} catch {
return false;
}
};
async function connect() {
if (state !== STATE.IDLE) return; // already connecting/live
setState(STATE.CONNECTING);
const minted = await mintSession();
if (minted.error) {
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
return;
}
const { terminalPort, ptySessionToken } = minted;
if (!ptySessionToken) {
setState(STATE.IDLE, { message: 'Cannot start: no session token returned' });
return;
}
// Pre-flight: does claude even exist on PATH?
const claudeStatus = await checkClaudeAvailable(terminalPort);
if (!claudeStatus.available) {
setState(STATE.NO_CLAUDE);
return;
}
// setState(LIVE) flips terminal-mount from display:none to display:flex.
// We MUST do that BEFORE ensureXterm() — xterm.js measures the container
// synchronously inside term.open() and a hidden container yields a 0x0
// terminal that never recovers. ensureXterm + the requestAnimationFrame
// fit() inside it run after the browser has applied the layout.
setState(STATE.LIVE);
ensureXterm();
// Token rides on Sec-WebSocket-Protocol — the only auth header the
// browser WebSocket API lets us set. Cross-port HttpOnly cookies with
// SameSite=Strict don't survive the jump from server.ts:34567 to the
// agent's random port from a chrome-extension origin, so cookies
// alone weren't reliable.
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${ptySessionToken}`]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
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 {}
});
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
// Agent control message (rare). Treat as JSON; error frames carry code.
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
setState(STATE.NO_CLAUDE);
try { ws.close(); } catch {}
}
} catch {}
return;
}
// Binary: feed to xterm.
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
term.write(buf);
});
ws.addEventListener('close', () => {
ws = null;
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
});
ws.addEventListener('error', (err) => {
console.error('[gstack terminal] ws error', err);
});
}
function teardown() {
try { ws && ws.close(); } catch {}
ws = null;
if (term) {
try { term.dispose(); } catch {}
term = null;
fitAddon = null;
}
setState(STATE.IDLE);
}
// ─── Wiring ───────────────────────────────────────────────────
/**
* Force a fresh session: close any open WS, dispose xterm, return to
* IDLE, kick off auto-connect. Safe to call from any state.
*/
function forceRestart() {
try { ws && ws.close(); } catch {}
ws = null;
if (term) {
try { term.dispose(); } catch {}
term = null;
fitAddon = null;
}
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
tryAutoConnect();
}
/**
* Repaint xterm when the Terminal pane becomes visible. xterm.js has a
* known issue where its renderer doesn't redraw after a display:none →
* display:flex flip — the canvas/DOM stays blank until something forces
* a layout pass. fit() recomputes dimensions, refresh() redraws.
*/
function repaintIfLive() {
if (state !== STATE.LIVE || !term) return;
try { fitAddon && fitAddon.fit(); } catch {}
try { term.refresh(0, term.rows - 1); } catch {}
try {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
} catch {}
}
function init() {
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
els.installRetry?.addEventListener('click', () => {
// Re-probe claude on PATH, then try a connect.
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
tryAutoConnect();
});
// Two restart buttons:
// - els.restart lives inside the ENDED state card (visible only after
// a session has ended).
// - els.restartNow lives in the always-visible toolbar (lets the user
// force a fresh claude mid-session without waiting for it to exit).
els.restart?.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
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
// .tab-content { display: none }; xterm doesn't auto-redraw when its
// container flips back to visible, so we listen for the close-debug
// event and force a fit + refresh.
const observer = new MutationObserver(() => {
const term = document.getElementById('tab-terminal');
if (term?.classList.contains('active')) {
requestAnimationFrame(repaintIfLive);
}
});
const target = document.getElementById('tab-terminal');
if (target) observer.observe(target, { attributes: true, attributeFilter: ['class'] });
tryAutoConnect();
}
/**
* Eager-connect when the sidebar opens. Polls for sidepanel.js to populate
* window.gstackServerPort + window.gstackAuthToken (which it does as soon
* as /health succeeds), then fires connect() automatically. The user
* doesn't have to press a key — Terminal is the default tab and "tap to
* start" was a needless paper cut on every reload.
*/
function tryAutoConnect() {
if (state !== STATE.IDLE) return;
let waited = 0;
const tick = () => {
// If the user navigated away (Chat tab) or already connected, drop out.
if (state !== STATE.IDLE) return;
if (getServerPort() && getAuthToken()) {
connect();
return;
}
waited += 200;
if (waited > 15000) {
setState(STATE.IDLE, { message: 'Browse server not ready. Reload sidebar to retry.' });
return;
}
setTimeout(tick, 200);
};
tick();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

1749
extension/sidepanel.css Normal file

File diff suppressed because it is too large Load Diff

188
extension/sidepanel.html Normal file
View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="sidepanel.css">
<link rel="stylesheet" href="lib/xterm.css">
</head>
<body>
<!-- Security shield — reflects ~/.gstack/security/session-state.json status.
Hidden until the sidebar knows its state (avoids flicker on first load).
Consumes /health.security — see browse/src/security.ts getStatus(). -->
<div class="security-shield" id="security-shield" role="status" aria-label="Security status: unknown" style="display:none" title="Security">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span class="security-shield-label" id="security-shield-label">SEC</span>
</div>
<!-- Connection status banner -->
<div class="conn-banner" id="conn-banner" style="display:none">
<span class="conn-banner-text" id="conn-banner-text">Reconnecting...</span>
<div class="conn-banner-actions" id="conn-banner-actions" style="display:none">
<button class="conn-btn" id="conn-reconnect">Reconnect</button>
<button class="conn-btn conn-copy" id="conn-copy" title="Copy command">/open-gstack-browser</button>
</div>
</div>
<!-- Browser tab bar -->
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
<!-- Terminal pane is now the sole primary surface. Activity / Refs /
Inspector still exist behind the `debug` toggle in the footer. -->
<main id="tab-terminal" class="tab-content active" role="tabpanel" aria-label="Terminal">
<!-- Toolbar with browser quick-actions on the left, Restart on the right.
Restart is always visible so the user can force a fresh claude any
time, not just from the ENDED state. -->
<div class="terminal-toolbar" id="terminal-toolbar">
<div class="terminal-toolbar-actions">
<button id="chat-cleanup-btn" class="terminal-toolbar-btn" title="Remove ads, banners, popups">🧹 Cleanup</button>
<button id="chat-screenshot-btn" class="terminal-toolbar-btn" title="Take a screenshot">📸 Screenshot</button>
<button id="chat-cookies-btn" class="terminal-toolbar-btn" title="Import cookies from your browser">🍪 Cookies</button>
</div>
<button class="terminal-toolbar-btn" id="terminal-restart-now" title="Restart Claude Code session">↻ Restart</button>
</div>
<div class="terminal-bootstrap" id="terminal-bootstrap">
<div class="terminal-bootstrap-icon"></div>
<p id="terminal-bootstrap-status">Starting Claude Code...</p>
<p class="muted" id="terminal-bootstrap-hint">Real PTY. Real terminal. Real claude.</p>
<pre id="loading-debug" class="muted" style="font-size:11px; font-family:'JetBrains Mono',monospace; white-space:pre-wrap; margin-top:8px; color:#71717A;"></pre>
</div>
<div class="terminal-install-card" id="terminal-install-card" style="display:none">
<p><strong>Claude Code not found</strong></p>
<p class="muted">Install: <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank">docs.anthropic.com/en/docs/claude-code</a></p>
<button class="install-retry-btn" id="terminal-install-retry">I installed it &mdash; try again</button>
</div>
<div class="terminal-mount" id="terminal-mount" style="display:none"></div>
<div class="terminal-ended" id="terminal-ended" style="display:none">
<p>Session ended.</p>
<button class="install-retry-btn" id="terminal-restart">Start a new session</button>
</div>
</main>
<!-- Debug: Activity Tab (hidden by default) -->
<main id="tab-activity" class="tab-content" role="log" aria-live="polite">
<div class="empty-state" id="empty-state">
<p>Waiting for commands...</p>
<p class="muted">Run a browse command to see activity here.</p>
</div>
<div id="activity-feed"></div>
</main>
<!-- Debug: Refs Tab (hidden by default) -->
<main id="tab-refs" class="tab-content">
<div class="empty-state" id="refs-empty">
<p>No refs yet</p>
<p class="muted">Run <code>snapshot</code> to see element refs.</p>
</div>
<div id="refs-list"></div>
<div class="refs-footer" id="refs-footer"></div>
</main>
<!-- Debug: Inspector Tab (hidden by default) -->
<main id="tab-inspector" class="tab-content">
<!-- Toolbar: always visible -->
<div class="inspector-toolbar" id="inspector-toolbar">
<button class="inspector-pick-btn" id="inspector-pick-btn" title="Pick an element (click, then click any element on the page)">
<span class="inspector-pick-icon">&#x271B;</span> Pick
</button>
<span class="inspector-selected" id="inspector-selected"></span>
<span class="inspector-mode-badge" id="inspector-mode-badge" style="display:none"></span>
<div style="flex:1"></div>
<button id="inspector-cleanup-btn" class="inspector-action-btn" title="Remove ads, banners, popups">🧹</button>
<button id="inspector-screenshot-btn" class="inspector-action-btn" title="Take a screenshot">📸</button>
</div>
<!-- Inspector content area -->
<div class="inspector-content" id="inspector-content">
<!-- Empty state (before first pick) -->
<div class="inspector-empty" id="inspector-empty">
<div class="inspector-empty-icon">&#x271B;</div>
<p>Pick an element to inspect</p>
<p class="muted">Click the button above, then click any element on the page</p>
</div>
<!-- Loading state -->
<div class="inspector-loading" id="inspector-loading" style="display:none">
<div class="inspector-loading-text">Inspecting...</div>
<div class="inspector-skeleton">
<div class="inspector-skeleton-bar"></div>
<div class="inspector-skeleton-bar"></div>
<div class="inspector-skeleton-bar"></div>
</div>
</div>
<!-- Error state -->
<div class="inspector-error" id="inspector-error" style="display:none"></div>
<!-- Inspector data panels -->
<div class="inspector-panels" id="inspector-panels" style="display:none">
<!-- Box Model -->
<div class="inspector-section" id="inspector-boxmodel-section">
<div class="inspector-section-header">Box Model</div>
<div class="inspector-boxmodel" id="inspector-boxmodel"></div>
</div>
<!-- Matched Rules -->
<div class="inspector-section" id="inspector-rules-section">
<button class="inspector-section-toggle" data-section="rules" aria-expanded="true">
<span class="inspector-toggle-arrow">&#x25BC;</span>
<span>Matched Rules</span>
<span class="inspector-rule-count" id="inspector-rule-count"></span>
</button>
<div class="inspector-section-body" id="inspector-rules" role="tree"></div>
</div>
<!-- Computed Styles -->
<div class="inspector-section" id="inspector-computed-section">
<button class="inspector-section-toggle collapsed" data-section="computed" aria-expanded="false">
<span class="inspector-toggle-arrow">&#x25B6;</span>
<span>Computed</span>
</button>
<div class="inspector-section-body collapsed" id="inspector-computed"></div>
</div>
<!-- Quick Edit -->
<div class="inspector-section" id="inspector-quickedit-section">
<button class="inspector-section-toggle collapsed" data-section="quickedit" aria-expanded="false">
<span class="inspector-toggle-arrow">&#x25B6;</span>
<span>Quick Edit</span>
</button>
<div class="inspector-section-body collapsed" id="inspector-quickedit"></div>
</div>
</div>
</div>
<!-- Send to Agent: sticky bottom -->
<div class="inspector-send" id="inspector-send" style="display:none">
<button class="inspector-send-btn" id="inspector-send-btn">Send to Agent</button>
</div>
</main>
<!-- Footer with connection + debug toggle -->
<footer>
<div class="footer-left">
<button class="debug-toggle" id="debug-toggle" title="Toggle debug panels">debug</button>
<button class="footer-btn" id="reload-sidebar" title="Reload sidebar">reload</button>
</div>
<div class="footer-right">
<span class="dot" id="footer-dot"></span>
<span class="footer-port" id="footer-port" title="Click to change port"></span>
<input type="text" class="port-input" id="port-input" placeholder="34567" autocomplete="off" style="display:none">
</div>
</footer>
<!-- Debug tab bar (hidden by default) -->
<nav class="tabs debug-tabs" id="debug-tabs" role="tablist" style="display:none">
<button class="tab" role="tab" data-tab="activity">Activity</button>
<button class="tab" role="tab" data-tab="refs">Refs</button>
<button class="tab" role="tab" data-tab="inspector">Inspector</button>
<button class="tab close-debug" id="close-debug" title="Close debug">&times;</button>
</nav>
<script src="lib/xterm.js"></script>
<script src="lib/xterm-addon-fit.js"></script>
<script src="sidepanel.js"></script>
<script src="sidepanel-terminal.js"></script>
</body>
</html>

1051
extension/sidepanel.js Normal file

File diff suppressed because it is too large Load Diff