Skip to content

Commit 5fac43b

Browse files
committed
feat: refine background browser workflow
1 parent 23b557e commit 5fac43b

28 files changed

Lines changed: 2208 additions & 326 deletions

CONTEXT.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,14 @@ generation ID so stale disconnects and late work cannot mutate a newer generatio
5757
Browser Session owns explicit page lifecycle through Host Chrome and Browser Control. It observes pages created through
5858
the shared runtime API, but it does not implement popup adoption. New windows requested by page content are not a second
5959
tab-creation policy. Browser Control uses `open` for ordinary navigation and reserves `new_tab` for additional pages.
60-
Browser Control and Host Chrome new-tab actions are both append-only.
60+
Browser Control and Host Chrome new-tab actions are both append-only. Browser Control changes logical selection and
61+
page state in the background but never activates Host Chrome; `/browser`, the tab strip, and browser-mode keybindings
62+
are the explicit foreground boundary.
6163

62-
Closing the final tracked page closes Host Chrome while Browser Session prepares a hidden replacement welcome page.
63-
Presentation code renders that internal page as **New tab** with an empty address and never displays its data URL in
64-
Host Chrome. Browser Control and DevTools may still report the underlying runtime URL.
64+
Closing the final tracked page closes Host Chrome and leaves Browser Session empty; it does not invent a hidden
65+
replacement target. The next explicit `/browser`, `open`, or `new_tab` operation creates the next page. A plain
66+
`/browser` creates the local welcome page on demand, renders it as **New tab** with an empty address, and can retry a
67+
failed page creation on the next invocation.
6568

6669
## Runtime And Presentation
6770

@@ -75,6 +78,10 @@ agent but do not continuously send raw frames to Kitty. Presentation remains out
7578
Electron offscreen paint → raw RGBA file → Kitty image → terminal placement
7679
```
7780

81+
Host Chrome foreground navigation waits for the exact selected Browser Surface to finish viewport reconciliation and
82+
attach its Presentation before sending `goto`. Browser Control navigation does not wait for Presentation because agent
83+
work remains offscreen and must not activate the Browser workspace.
84+
7885
The fixed Electron main/GPU/network/profile/DevTools cost is therefore paid once per Browser Generation, page renderer
7986
cost scales with the number and complexity of Shared Browser Pages, and active Presentation cost scales with viewport
8087
pixels and frame rate. The plugin defaults to 30 fps: 960×600 RGBA is about 66 MiB/s and 1920×1080 is about 237 MiB/s
@@ -89,7 +96,8 @@ first-run package download, Chrome installer, browser cache, or system-Chrome di
8996

9097
The two MCP roles stay separate:
9198

92-
- `opencode-browser-control` owns tracked tab lifecycle, selection, visibility, and stable `tabId` policy.
99+
- `opencode-browser-control` owns background tracked-tab lifecycle, logical selection, and stable `tabId` policy. It
100+
does not expose visibility mutations.
93101
- `opencode-browser-devtools` inspects and operates the same live pages through Chrome DevTools MCP.
94102

95103
Use Browser Control for `new_tab` and `close_tab`; letting a DevTools client invent page lifecycle would bypass Browser

README.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,16 @@ Restart `opencode2` after changing the package or its options.
8888

8989
## Use
9090

91-
Run `/browser` or choose **Open browser tab** from the command palette. Run
92-
`/browser https://example.com` to navigate and show the browser in one step.
91+
`/browser` reveals the currently selected browser tab when one exists; it does not create another tab. When the browser
92+
is empty, it creates the local **New tab** page on demand. `/browser https://example.com` reuses the selected tab and
93+
starts navigation only after its terminal surface has the final viewport. Use
94+
`/browser-new` or `/browser-new https://example.com` when you explicitly want an additional tab. Both commands are
95+
available from the slash-command picker and command palette.
9396

9497
| Action | Keyboard or UI |
9598
| --------------------------- | -------------------------------------------------------- |
9699
| Focus the address | `Ctrl-L` / `Cmd-L` |
97-
| New browser tab | `Ctrl-T` / `Cmd-T`, or `+` |
100+
| New browser tab | `/browser-new [url]`, `Ctrl-T` / `Cmd-T`, or `+` |
98101
| Close selected browser tab | `Ctrl-W` / `Cmd-W`, or the tab's `×` |
99102
| Next / previous browser tab | `Ctrl-Tab` / `Ctrl-Shift-Tab` |
100103
| Reload or stop loading | `Ctrl-R` / `Cmd-R`, or the toolbar button |
@@ -107,8 +110,10 @@ behavior there; `Cmd-W` still closes the page.
107110

108111
Returning to OpenCode hides the browser without closing Electron or its pages, so reopening is fast. **Remove browser
109112
tabs from strip** also hides every browser page item while keeping the runtime ready; `/browser` restores them.
110-
Closing the final tracked page closes the browser workspace. The session prepares an internal welcome page so reopening
111-
stays fast; Host Chrome presents it as **New tab** with an empty address instead of exposing its internal data URL.
113+
Closing the final tracked page closes the browser workspace and leaves no hidden replacement target. The Electron
114+
runtime stays ready; the next explicit `/browser`, `open`, or `new_tab` operation creates the next page. A plain
115+
`/browser` creates the local welcome page on demand and presents it as **New tab** with an empty address instead of
116+
exposing its internal data URL.
112117

113118
Popup windows are deliberately denied. Use `+` or the controller's `new_tab` tool when a new tracked page is
114119
required.
@@ -132,16 +137,19 @@ The plugin automatically registers two local MCP servers:
132137

133138
| MCP server | Responsibility |
134139
| --------------------------- | ------------------------------------------------------------------------------------- |
135-
| `opencode-browser-control` | Show, hide, navigate, create, list, select, and close tracked browser tabs. |
140+
| `opencode-browser-control` | Navigate, create, list, select, and close tracked browser tabs in the background. |
136141
| `opencode-browser-devtools` | Inspect and operate the same live Electron pages through `chrome-devtools-mcp@1.6.0`. |
137142

138-
The control server exposes eight tools: `show`, `open`, `new_tab`, `list_tabs`, `select_tab`, `close_tab`,
139-
`hide`, and `status`.
143+
The control server exposes six tools: `open`, `new_tab`, `list_tabs`, `select_tab`, `close_tab`, and `status`. Agent
144+
operations never select the Browser workspace. They can add a browser item to the terminal tab strip and update its
145+
loading state, but OpenCode stays active until the user runs `/browser` or selects that browser tab.
140146

141147
For reliable agent automation:
142148

143-
1. Use `open` for an ordinary single-page request; it reuses the selected tab, including the initial welcome tab.
144-
2. Use `new_tab` only when the user requests an additional tab or the task must keep multiple pages open concurrently.
149+
1. Use `open` for an ordinary single-page request; it reuses the selected tab in the background, including the initial
150+
welcome tab.
151+
2. Use `new_tab` only when the user requests an additional tab or the task must keep multiple pages open concurrently;
152+
the new page is also created and selected in the background.
145153
3. Read its stable logical `tabId`, current CDP `targetId`, URL, and title.
146154
4. Call Chrome DevTools MCP `list_pages` and match the live page by URL/title.
147155
5. Use Chrome DevTools MCP's numeric `pageId` for page-scoped DevTools operations.
@@ -159,6 +167,10 @@ retrying the same timed-out call.
159167
The plugin launches the installed Chrome DevTools MCP entry with Node.js. It does not use `npx`, download an MCP
160168
package during activation, discover system Chrome, or create a second browser runtime.
161169

170+
DevTools screenshots default to WebP at quality 80 and are bounded to 640×640 during capture. The bound keeps even an
171+
explicit PNG capture below Chrome DevTools MCP's inline-image threshold, so screenshot results remain typed image
172+
attachments that OpenCode can render in the session feed instead of opaque temporary-file messages.
173+
162174
If the plugin becomes active during an existing agent turn, begin a new turn so OpenCode can refresh its MCP tool
163175
catalog.
164176

@@ -177,7 +189,8 @@ OpenCode Host UI
177189

178190
Background pages remain available to the user and agent, idle their offscreen paint rate at 1 fps, and do not send
179191
frames to Kitty. Changing terminal geometry updates the selected page viewport and placement without stretching a stale
180-
frame.
192+
frame. Explicit foreground URL commands wait for that viewport and Presentation to be ready before navigation; agent
193+
navigation remains immediate and offscreen.
181194

182195
OpenCode V2 currently exposes session tabs rather than arbitrary native plugin tabs. The plugin therefore renders a
183196
composite strip in the global app slot: OpenCode sessions and logical browser pages remain independently owned, while

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "@neriousy/opencode-browser",
4-
"version": "0.4.2",
4+
"version": "0.5.0",
55
"description": "An Electron browser surface for the OpenCode v2 TUI",
66
"keywords": [
77
"opencode",
@@ -65,7 +65,7 @@
6565
"@opentui/solid": "0.4.5",
6666
"@types/bun": "latest",
6767
"@types/node": "^24.0.0",
68-
"opentui-browser": "0.3.1",
68+
"opentui-browser": "0.3.2",
6969
"oxfmt": "0.41.0",
7070
"solid-js": "1.9.12",
7171
"typescript": "^5"

scripts/smoke-devtools-mcp.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Schema } from "effect"
44
import { ElectronBrowser, type ElectronPage } from "opentui-browser"
55
import { createServer } from "node:http"
66
import { createRequire } from "node:module"
7+
import { DEVTOOLS_SCREENSHOT_ARGUMENTS } from "../src/mcp.js"
78

89
const require = createRequire(import.meta.url)
910
const devtoolsMcpEntry = require.resolve("chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js")
@@ -12,15 +13,21 @@ const McpTextContent = Schema.Struct({
1213
type: Schema.Literal("text"),
1314
text: Schema.String,
1415
})
16+
const McpImageContent = Schema.Struct({
17+
type: Schema.Literal("image"),
18+
data: Schema.String,
19+
mimeType: Schema.String,
20+
})
1521
const McpContent = Schema.Array(Schema.Unknown)
1622
const decodeMcpContent = Schema.decodeUnknownSync(McpContent)
1723
const isMcpTextContent = Schema.is(McpTextContent)
24+
const isMcpImageContent = Schema.is(McpImageContent)
1825

1926
const marker = `opencode-browser-mcp-${crypto.randomUUID()}`
2027
const server = createServer((_request, response) => {
2128
response.writeHead(200, { "content-type": "text/html; charset=utf-8" })
2229
response.end(
23-
`<!doctype html><title>OpenCode Browser MCP Smoke</title><script>globalThis.__mcpMarker=${JSON.stringify(marker)}</script>`,
30+
`<!doctype html><style>html,body{margin:0}canvas{display:block}</style><title>OpenCode Browser MCP Smoke</title><canvas width="1200" height="1200"></canvas><script>globalThis.__mcpMarker=${JSON.stringify(marker)};const canvas=document.querySelector("canvas");const context=canvas.getContext("2d");const image=context.createImageData(canvas.width,canvas.height);let value=0x12345678;for(let offset=0;offset<image.data.length;offset+=4){value^=value<<13;value^=value>>>17;value^=value<<5;image.data[offset]=value;image.data[offset+1]=value>>>8;image.data[offset+2]=value>>>16;image.data[offset+3]=255}context.putImageData(image,0,0)</script>`,
2431
)
2532
})
2633

@@ -47,6 +54,7 @@ try {
4754
"--experimentalPageIdRouting",
4855
"--no-usage-statistics",
4956
"--no-performance-crux",
57+
...DEVTOOLS_SCREENSHOT_ARGUMENTS,
5058
],
5159
stderr: "pipe",
5260
})
@@ -55,11 +63,11 @@ try {
5563
stderr += String(chunk)
5664
})
5765

58-
client = new Client({ name: "opencode-browser-smoke", version: "0.4.0" })
66+
client = new Client({ name: "opencode-browser-smoke", version: "0.5.0" })
5967
await client.connect(transport)
6068

6169
const listedTools = await client.listTools()
62-
for (const required of ["list_pages", "evaluate_script"]) {
70+
for (const required of ["list_pages", "evaluate_script", "take_screenshot"]) {
6371
if (!listedTools.tools.some((tool) => tool.name === required)) {
6472
throw new Error(`chrome-devtools-mcp did not expose ${required}\n${stderr}`)
6573
}
@@ -85,13 +93,25 @@ try {
8593
throw new Error(`DevTools evaluated the wrong page:\n${evaluationText}`)
8694
}
8795

96+
const screenshot = await client.callTool({
97+
name: "take_screenshot",
98+
arguments: { pageId, fullPage: true },
99+
})
100+
if (screenshot.isError) throw new Error(textContent(screenshot.content))
101+
const image = decodeMcpContent(screenshot.content).find(isMcpImageContent)
102+
if (!image) throw new Error(`DevTools did not return an inline screenshot:\n${textContent(screenshot.content)}`)
103+
const screenshotBytes = Buffer.from(image.data, "base64").byteLength
104+
if (image.mimeType !== "image/webp") throw new Error(`Unexpected screenshot type: ${image.mimeType}`)
105+
if (screenshotBytes >= 2_000_000) throw new Error(`Screenshot exceeded the inline response limit: ${screenshotBytes}`)
106+
88107
console.log(
89108
JSON.stringify({
90109
chromeDevtoolsMcp: "1.6.0",
91110
pageId,
92111
targetId: page.targetId,
93112
url: pageUrl,
94113
marker,
114+
screenshot: { mimeType: image.mimeType, bytes: screenshotBytes },
95115
}),
96116
)
97117
} finally {

scripts/test-packed.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,13 @@ async function runHostRuntimeSmoke(application: string) {
281281
const tab = probe.createBrowserTabState()
282282
const session = probe.createBrowserSession(browser, page, page.url)
283283
const initialTabId = session.snapshot().selectedTabId
284-
const temporary = await session.newTab("https://temporary.example")
284+
const temporary = await session.newTab({ url: "https://temporary.example" })
285285
if (session.snapshot().selectedTabId !== temporary.tabId || temporary.page.targetId === page.targetId) {
286286
throw new Error("Packed BrowserSession did not select a newly created page")
287287
}
288+
if (temporary.page.url !== "https://temporary.example") {
289+
throw new Error("Packed BrowserSession did not forward the new page URL")
290+
}
288291
if (!(await session.closeTab(temporary.tabId)) || session.snapshot().selectedTabId !== initialTabId) {
289292
throw new Error("Packed BrowserSession did not reconcile a closed page")
290293
}

0 commit comments

Comments
 (0)