mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: gstack browser sidebar = interactive Claude Code REPL with live tab awareness (v1.14.0.0) (#1216)
* build: vendor xterm@5 for the Terminal sidebar tab
Adds xterm@5 + xterm-addon-fit as devDependencies and a `vendor:xterm`
build step that copies the assets into `extension/lib/` at build time.
The vendored files are .gitignored so the npm version stays the source
of truth. xterm@5 is eval-free, so no MV3 CSP changes needed.
No runtime callers yet — this just stages the assets.
* feat(server): add pty-session-cookie module for the Terminal tab
Mirrors `sse-session-cookie.ts` exactly. Mints short-lived 30-min HttpOnly
cookies for authenticating the Terminal-tab WebSocket upgrade against
the terminal-agent. Same TTL, same opportunistic-pruning shape, same
"scoped tokens never valid as root" invariant. Two registries instead of
one because the cookie names are different (`gstack_sse` vs `gstack_pty`)
and the token spaces must not overlap.
No callers yet — wired up in the next commit.
* feat(server): add terminal-agent.ts (PTY for the Terminal sidebar tab)
Translates phoenix gbrowser's Go PTY (cmd/gbd/terminal.go) into a Bun
non-compiled process. Lives separately from `sidebar-agent.ts` so a
WS-framing or PTY-cleanup bug can't take down the chat path (codex
outside-voice review caught the coupling risk).
Architecture:
- Bun.serve on 127.0.0.1:0 (never tunneled).
- POST /internal/grant accepts cookie tokens from the parent server over
loopback, authenticated with a per-boot internal token.
- GET /ws upgrades require BOTH (a) Origin: chrome-extension://<id> and
(b) the gstack_pty cookie minted by /pty-session. Either gate alone is
insufficient (CSWSH defense + auth defense).
- Lazy spawn: claude PTY is not started until the WS receives its first
data frame. Idle sidebar opens cost nothing.
- Bun PTY API: `terminal: { rows, cols, data(t, chunk) }` — verified at
impl time on Bun 1.3.10. proc.terminal.write() for input,
proc.terminal.resize() for resize, proc.kill() + 3s SIGKILL fallback
on close.
- process.on('uncaughtException'|'unhandledRejection') handlers so a
framing bug logs but doesn't kill the listener loop.
Test-only `BROWSE_TERMINAL_BINARY` env override lets the integration
tests spawn /bin/bash instead of requiring claude on every CI runner.
Not yet spawned by anything — wired in the next commit.
* feat(server): wire /pty-session route + spawn terminal-agent
Server-side glue connecting the Terminal sidebar tab to the new
terminal-agent process.
server.ts:
- New POST /pty-session route. Validates AUTH_TOKEN, mints a gstack_pty
HttpOnly cookie via pty-session-cookie.ts, posts the cookie value to
the agent's loopback /internal/grant. Returns the terminalPort + Set-Cookie
to the extension.
- /health response gains `terminalPort` (just the port number — never a
shell token). Tokens flow via the cookie path, never /health, because
/health already surfaces AUTH_TOKEN to localhost callers in headed mode
(that's a separate v1.1+ TODO).
- /pty-session and /terminal/* are deliberately NOT added to TUNNEL_PATHS,
so the dual-listener tunnel surface 404s by default-deny.
- Shutdown path now also pkills terminal-agent and unlinks its state files
(terminal-port + terminal-internal-token) so a reconnect doesn't try to
hit a dead port.
cli.ts:
- After spawning sidebar-agent.ts, also spawn terminal-agent.ts. Same
pattern: pkill old instances, Bun.spawn(['bun', 'run', script]) with
BROWSE_STATE_FILE + BROWSE_SERVER_PORT env. Non-fatal if the spawn
fails — chat still works without the terminal agent.
* feat(extension): Terminal as default sidebar tab
Adds a primary tab bar (Terminal | Chat) above the existing tab-content
panes. Terminal is the default-active tab; clicking Chat returns to the
existing claude -p one-shot flow which is preserved verbatim.
manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't
block the WebSocket upgrade.
sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a
"Press any key to start Claude Code" bootstrap card, claude-not-found
install card, xterm mount point, and "session ended" restart UI. Loads
xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no
longer the .active default.
sidepanel.js: new activePrimaryPaneId() helper that reads which primary
tab is selected. Debug-close paths now route back to whichever primary
pane is active (was hardcoded to tab-chat). Primary-tab click handler
toggles .active classes and aria-selected. window.gstackServerPort and
window.gstackAuthToken exposed so sidepanel-terminal.js can build the
/pty-session POST and the WS URL.
sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first
keystroke fires POST /pty-session, then opens
ws://127.0.0.1:<terminalPort>/ws. Origin + cookie are set automatically
by the browser. Resize observer sends {type:"resize"} text frames.
ResizeObserver, tab-switch hooks, restart button, install-card retry.
On WS close shows "Session ended, click to restart" — no auto-reconnect
(codex outside-voice flagged that as session-burning).
sidepanel.css: primary-tabs bar + Terminal pane styling (full-height
xterm container, install card, ended state).
* test: terminal-agent + cookie module + sidebar default-tab regression
Three new test files:
terminal-agent.test.ts (16 tests): pty-session-cookie mint/validate/
revoke, Set-Cookie shape (HttpOnly + SameSite=Strict + Path=/, NO Secure
since 127.0.0.1 over HTTP), source-level guards that /pty-session and
/terminal/* are NOT in TUNNEL_PATHS, /health does NOT surface ptyToken
or gstack_pty, terminal-agent binds 127.0.0.1, /ws upgrade enforces
chrome-extension:// Origin AND gstack_pty cookie, lazy-spawn invariant
(spawnClaude is called from message handler, not upgrade), uncaughtException/
unhandledRejection handlers exist, SIGINT-then-SIGKILL cleanup.
terminal-agent-integration.test.ts (7 tests): spawns the agent as a real
subprocess in a tmp state dir. Verifies /internal/grant accepts/rejects
the loopback token, /ws gates (no Origin → 403, bad Origin → 403, no
cookie → 401), real WebSocket round-trip with /bin/bash via the
BROWSE_TERMINAL_BINARY override (write 'echo hello-pty-world\n', read it
back), and resize message acceptance.
sidebar-tabs.test.ts (13 tests): structural regression suite locking the
load-bearing invariants of the default-tab change — Terminal is .active,
Chat is not, xterm assets are loaded, debug-close path no longer hardcodes
tab-chat (uses activePrimaryPaneId), primary-tab click handler exists,
chat surface is not accidentally deleted, terminal JS does NOT auto-
reconnect on close, manifest declares ws:// + http:// localhost host
permissions, no unsafe-eval.
Plan called for Playwright + extension regression; the codebase doesn't
ship Playwright extension launcher infra, so we follow the existing
extension-test pattern (source-level structural assertions). Same
load-bearing intent — locks the invariants before they regress.
* docs: Terminal flow + threat model + v1.1 follow-ups
SIDEBAR_MESSAGE_FLOW.md: new "Terminal flow" section. Documents the WS
upgrade path (/pty-session cookie mint → /ws Origin + cookie gate →
lazy claude spawn), the dual-token model (AUTH_TOKEN for /pty-session,
gstack_pty cookie for /ws, INTERNAL_TOKEN for server↔agent loopback),
and the threat-model boundary — the Terminal tab bypasses the entire
prompt-injection security stack on purpose; user keystrokes are the
trust source. That trust assumption is load-bearing on three transport
guarantees: local-only listener, Origin gate, cookie auth. Drop any
one of those three and the tab becomes unsafe.
CLAUDE.md: extends the "Sidebar architecture" note to include
terminal-agent.ts in the read-this-first list. Adds a "Terminal tab is
its own process" note so a future contributor doesn't bolt PTY logic
onto sidebar-agent.ts.
TODOS.md: three new follow-ups under a new "Sidebar Terminal" section:
- v1.1: PTY session survives sidebar reload (Issue 1C deferred).
- v1.1+: audit /health AUTH_TOKEN distribution (codex finding #2 —
a pre-existing soft leak that cc-pty-import sidesteps but doesn't
fix).
- v1.1+: apply terminal-agent's process.on exception handlers to
sidebar-agent.ts (codex finding #4 — chat path has no fatal
handlers).
* feat(extension): Terminal-only sidebar — auth fix, UX polish, chat rip
The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.
Three threads of change, all from dogfood iteration on top of
cc-pty-import:
1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
- Browsers can't set Authorization on a WebSocket upgrade. We had
been minting an HttpOnly gstack_pty cookie via /pty-session, but
SameSite=Strict cookies don't survive the cross-port jump from
server.ts:34567 to the agent's random port from a chrome-extension
origin. The WS opened then immediately closed → "Session ended."
- /pty-session now also returns ptySessionToken in the JSON body.
- Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
Browser sends Sec-WebSocket-Protocol on the upgrade.
- Agent reads the protocol header, validates against validTokens,
and MUST echo the protocol back (Chromium closes the connection
immediately if a server doesn't pick one of the offered protocols).
- Cookie path is kept as a fallback for non-browser callers (curl,
integration tests).
- New integration test exercises the full protocol-auth round-trip
via raw fetch+Upgrade so a future regression of this exact class
fails in CI.
2. fix(extension): UX polish on the Terminal pane
- Eager auto-connect when the sidebar opens — no "Press any key to
start" friction every reload.
- Always-visible ↻ Restart button in the terminal toolbar (not
gated on the ENDED state) so the user can force a fresh claude
mid-session.
- MutationObserver on #tab-terminal's class attribute drives a
fitAddon.fit() + term.refresh() when the pane becomes visible
again — xterm doesn't auto-redraw after display:none → display:flex.
3. feat(extension): rip the chat tab + sidebar-agent.ts
- Sidebar is Terminal-only. No more Terminal | Chat primary nav.
- sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
/sidebar-agent/event, /sidebar-tabs* and friends all deleted.
- The pickSidebarModel router (sonnet vs opus) is gone — the live
PTY uses whatever model the user's `claude` CLI is configured with.
- Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
in the Terminal toolbar. Cleanup now injects its prompt into the
live PTY via window.gstackInjectToTerminal — no more
/sidebar-command POST. The Inspector "Send to Code" action uses
the same injection path.
- clear-chat button removed from the footer.
- sidepanel.js shed ~900 lines of chat polling, optimistic UI,
stop-agent, etc.
Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.
* 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.
* chore: drop sidebar-agent test refs after chat rip
Five test files / describe blocks targeted the deleted chat path:
- browse/test/security-e2e-fullstack.test.ts (full-stack chat-pipeline E2E
with mock claude — whole file gone)
- browse/test/security-review-fullstack.test.ts (review-flow E2E with real
classifier — whole file gone)
- browse/test/security-review-sidepanel-e2e.test.ts (Playwright E2E for
the security event banner that was ripped from sidepanel.html)
- browse/test/security-audit-r2.test.ts (5 describe blocks: agent queue
permissions, isValidQueueEntry stateFile traversal, loadSession session-ID
validation, switchChatTab DocumentFragment, pollChat reentrancy guard,
/sidebar-tabs URL sanitization, sidebar-agent SIGTERM→SIGKILL escalation,
AGENT_SRC top-level read converted to graceful fallback)
- browse/test/security-adversarial-fixes.test.ts (canary stream-chunk split
detection on detectCanaryLeak; one tool-output test on sidebar-agent)
- test/skill-validation.test.ts (sidebar agent #584 describe block)
These all assumed sidebar-agent.ts existed and tested chat-queue plumbing,
chat-tab DOM round-trip, chat-polling reentrancy, or per-message classifier
canary detection. With the live PTY there is no chat queue, no chat tab,
no LLM stream to canary-scan, and no per-message subprocess. The Terminal
pane's invariants are covered by the new browse/test/sidebar-tabs.test.ts
(27 structural assertions), browse/test/terminal-agent.test.ts, and
browse/test/terminal-agent-integration.test.ts.
bun test → exit 0, 0 failures.
* chore: bump version and changelog (v1.14.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(extension): xterm fills the full Terminal panel height
The Terminal pane only rendered into the top portion of the panel — most
of the panel below the prompt was an empty black gap. Three layered
issues, all about xterm.js measuring dimensions during a layout state
that wasn't ready yet:
1. order-of-operations in connect(): ensureXterm() ran BEFORE
setState(LIVE), so term.open() measured els.mount while it was still
display:none. xterm caches a 0-size viewport synchronously inside
open() and never auto-recovers when the container goes visible.
Flipped: setState(LIVE) → ensureXterm.
2. first fit() ran synchronously before the browser had applied the
.active class transition. Wrapped in requestAnimationFrame so layout
has settled before fit() reads clientHeight.
3. CSS flex-overflow trap: .terminal-mount has flex:1 inside the
flex-column #tab-terminal, but .tab-content's `overflow-y: auto` and
the lack of `min-height: 0` on .terminal-mount meant the item
couldn't shrink below content size. flex:1 then refused to expand
into available space and xterm rendered into whatever its initial
2x2 measurement happened to be.
Fixes:
- extension/sidepanel-terminal.js: reorder + RAF fit
- extension/sidepanel.css: .terminal-mount gets `flex: 1 1 0` +
`min-height: 0` + `position: relative`. #tab-terminal overrides
.tab-content's `overflow-y: auto` to `overflow: hidden` (xterm has
its own viewport scroll; the parent shouldn't compete) and explicitly
re-declares `display: flex; flex-direction: column` for #tab-terminal.active.
bun test browse/test/sidebar-tabs.test.ts → 27/27 pass.
Manually verified: side panel opens → Terminal fills full panel height,
xterm scrollback works, debug-tab toggle still repaints correctly.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+59
-4
@@ -287,6 +287,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const ALLOWED_TYPES = new Set([
|
||||
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
|
||||
'getTabState',
|
||||
// Inspector message types
|
||||
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
|
||||
'applyStyle', 'toggleClass', 'injectCSS', 'resetAll',
|
||||
@@ -302,6 +303,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'getTabState') {
|
||||
snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] }));
|
||||
return true; // async sendResponse
|
||||
}
|
||||
|
||||
if (msg.type === 'setPort') {
|
||||
savePort(msg.port).then(() => {
|
||||
checkHealth();
|
||||
@@ -506,11 +512,48 @@ chrome.runtime.onInstalled.addListener(() => {
|
||||
// Fire on every service worker startup (covers persistent context reuse)
|
||||
autoOpenSidePanel();
|
||||
|
||||
// ─── Tab Switch Detection ────────────────────────────────────────
|
||||
// Notify sidepanel instantly when the user switches tabs in the browser.
|
||||
// This is faster than polling — the sidebar swaps chat context immediately.
|
||||
// ─── Tab Awareness ───────────────────────────────────────────────
|
||||
// Push live tab state to the sidepanel so claude in the Terminal pane
|
||||
// always has up-to-date tabs.json + active-tab.json on disk. The
|
||||
// sidepanel relays these to terminal-agent.ts over the live WebSocket;
|
||||
// terminal-agent writes the files for claude to read.
|
||||
|
||||
async function snapshotTabs() {
|
||||
try {
|
||||
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const all = await chrome.tabs.query({});
|
||||
const slim = all.map(t => ({
|
||||
tabId: t.id,
|
||||
url: t.url || '',
|
||||
title: t.title || '',
|
||||
active: !!t.active,
|
||||
windowId: t.windowId,
|
||||
pinned: !!t.pinned,
|
||||
audible: !!t.audible,
|
||||
}));
|
||||
return {
|
||||
active: active ? { tabId: active.id, url: active.url || '', title: active.title || '' } : null,
|
||||
tabs: slim,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pushTabState(reason) {
|
||||
const snapshot = await snapshotTabs();
|
||||
if (!snapshot) return;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'browserTabState',
|
||||
reason,
|
||||
...snapshot,
|
||||
}).catch(() => {}); // expected: sidepanel may not be open
|
||||
}
|
||||
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
// Keep the legacy event for any consumer still listening to it (the chat
|
||||
// path is gone but the message type is harmless), and also fire the new
|
||||
// unified state push so claude's tabs.json reflects the new active tab.
|
||||
chrome.tabs.get(activeInfo.tabId, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab) return;
|
||||
chrome.runtime.sendMessage({
|
||||
@@ -518,8 +561,20 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
tabId: activeInfo.tabId,
|
||||
url: tab.url || '',
|
||||
title: tab.title || '',
|
||||
}).catch(() => {}); // expected: sidepanel may not be open
|
||||
}).catch(() => {});
|
||||
});
|
||||
pushTabState('activated');
|
||||
});
|
||||
|
||||
chrome.tabs.onCreated.addListener(() => pushTabState('created'));
|
||||
chrome.tabs.onRemoved.addListener(() => pushTabState('removed'));
|
||||
chrome.tabs.onUpdated.addListener((_id, changeInfo) => {
|
||||
// Throttle: only re-push on URL or title changes, not on every loading
|
||||
// tick. We don't want to spam claude with a state push every 50ms while
|
||||
// a page loads.
|
||||
if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') {
|
||||
pushTabState('updated');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Startup ────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Live activity feed and @ref overlays for gstack browse",
|
||||
"permissions": ["sidePanel", "storage", "activeTab", "scripting"],
|
||||
"host_permissions": ["http://127.0.0.1:*/"],
|
||||
"host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 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, 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);
|
||||
|
||||
term.onData((data) => {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -675,6 +675,118 @@ body::after {
|
||||
}
|
||||
.tab-content.active { display: flex; flex-direction: column; }
|
||||
|
||||
/* ─── Terminal Tab ────────────────────────────────────────────── */
|
||||
/* The Terminal pane manages its own scrolling (xterm has a viewport with
|
||||
scrollback). The default .tab-content rules above set overflow-y: auto,
|
||||
which collapses min-height for nested flex children — that's why
|
||||
.terminal-mount couldn't grow to fill available space. Override here. */
|
||||
#tab-terminal {
|
||||
background: #0a0a0a;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
#tab-terminal.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #0a0a0a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-toolbar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.terminal-toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #27272a;
|
||||
color: #a1a1aa;
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.terminal-toolbar-btn:hover {
|
||||
color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.terminal-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #71717a;
|
||||
padding: 24px;
|
||||
}
|
||||
.terminal-bootstrap-icon {
|
||||
font-size: 32px;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.terminal-bootstrap p { margin: 4px 0; }
|
||||
.terminal-install-card {
|
||||
margin: 24px;
|
||||
padding: 16px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.terminal-install-card a { color: #f59e0b; }
|
||||
.install-retry-btn {
|
||||
margin-top: 12px;
|
||||
padding: 6px 14px;
|
||||
background: #f59e0b;
|
||||
color: #0a0a0a;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.install-retry-btn:hover { opacity: 0.9; }
|
||||
.terminal-mount {
|
||||
/* min-height: 0 is the standard flex-overflow fix — without it, a flex
|
||||
item with overflowing content can't shrink below its content size,
|
||||
so flex:1 refuses to expand into available space and xterm renders
|
||||
into whatever the content happens to be (i.e. its own initial 2x2
|
||||
measurement). With min-height:0 the item respects the flex parent's
|
||||
remaining space and xterm grows to fill it. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
background: #0a0a0a;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
/* position: relative so xterm's absolutely-positioned helpers (the
|
||||
hidden textarea for input) anchor inside us, not on body. */
|
||||
position: relative;
|
||||
}
|
||||
.terminal-mount .xterm,
|
||||
.terminal-mount .xterm .xterm-viewport,
|
||||
.terminal-mount .xterm .xterm-screen {
|
||||
height: 100% !important;
|
||||
}
|
||||
.terminal-ended {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #71717a;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ─── Activity Feed ───────────────────────────────────── */
|
||||
#activity-feed { flex: 1; }
|
||||
|
||||
|
||||
+32
-64
@@ -3,6 +3,7 @@
|
||||
<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.
|
||||
@@ -24,54 +25,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security event banner — fires on prompt injection detection.
|
||||
Variant A from /plan-design-review 2026-04-19: centered alert-heavy,
|
||||
big red error icon, mono layer scores in expandable details. -->
|
||||
<div class="security-banner" id="security-banner" role="alert" aria-live="assertive" style="display:none">
|
||||
<button class="security-banner-close" id="security-banner-close" aria-label="Dismiss">×</button>
|
||||
<div class="security-banner-icon" aria-hidden="true">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="security-banner-title" id="security-banner-title">Session terminated</div>
|
||||
<div class="security-banner-subtitle" id="security-banner-subtitle">prompt injection detected</div>
|
||||
<button class="security-banner-expand" id="security-banner-expand" aria-expanded="false" aria-controls="security-banner-details">
|
||||
<span>What happened</span>
|
||||
<svg class="security-banner-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="security-banner-details" id="security-banner-details" hidden>
|
||||
<div class="security-banner-section-label">SECURITY LAYERS</div>
|
||||
<div class="security-banner-layers" id="security-banner-layers"></div>
|
||||
<div class="security-banner-section-label" id="security-banner-suspect-label" hidden>SUSPECTED TEXT</div>
|
||||
<pre class="security-banner-suspect" id="security-banner-suspect" hidden></pre>
|
||||
</div>
|
||||
<div class="security-banner-actions" id="security-banner-actions" hidden>
|
||||
<button type="button" class="security-banner-btn security-banner-btn-block" id="security-banner-btn-block">Block session</button>
|
||||
<button type="button" class="security-banner-btn security-banner-btn-allow" id="security-banner-btn-allow">Allow and continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser tab bar -->
|
||||
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
|
||||
|
||||
<!-- Chat Tab (default, full height) -->
|
||||
<main id="tab-chat" class="tab-content active">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="chat-loading" id="chat-loading">
|
||||
<div class="chat-loading-spinner"></div>
|
||||
<p id="loading-status">Looking for browse server...</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="chat-welcome" id="chat-welcome" style="display:none">
|
||||
<div class="chat-welcome-icon">G</div>
|
||||
<p>Send a message to Claude Code.</p>
|
||||
<p class="muted">Your agent will see it and act on it.</p>
|
||||
<!-- 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 — 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>
|
||||
|
||||
@@ -174,30 +159,10 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Experimental chat banner (shown when chatEnabled) -->
|
||||
<div id="experimental-banner" class="experimental-banner" style="display: none;">
|
||||
Browser co-pilot — controls this browser, reports back to your workspace
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Toolbar -->
|
||||
<div class="quick-actions" id="quick-actions">
|
||||
<button id="chat-cleanup-btn" class="quick-action-btn" title="Remove ads, banners, popups">🧹 Cleanup</button>
|
||||
<button id="chat-screenshot-btn" class="quick-action-btn" title="Take a screenshot">📸 Screenshot</button>
|
||||
<button id="chat-cookies-btn" class="quick-action-btn" title="Import cookies from your browser">🍪 Cookies</button>
|
||||
</div>
|
||||
|
||||
<!-- Command Bar -->
|
||||
<div class="command-bar">
|
||||
<button class="stop-btn" id="stop-agent-btn" title="Stop agent" style="display: none;">■</button>
|
||||
<input type="text" class="command-input" id="command-input" placeholder="Ask about this page..." autocomplete="off" spellcheck="false">
|
||||
<button class="send-btn" id="send-btn" title="Send">↑</button>
|
||||
</div>
|
||||
|
||||
<!-- 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="clear-chat" title="Clear chat">clear</button>
|
||||
<button class="footer-btn" id="reload-sidebar" title="Reload sidebar">reload</button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
@@ -215,6 +180,9 @@
|
||||
<button class="tab close-debug" id="close-debug" title="Close debug">×</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>
|
||||
|
||||
+82
-950
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user