Skip to content

Commit 44ecee8

Browse files
committed
release: publish 0.4.1
1 parent 8a127b5 commit 44ecee8

16 files changed

Lines changed: 359 additions & 77 deletions

.github/workflows/publish.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Publish package
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: read
10+
id-token: write
11+
12+
concurrency:
13+
group: publish-${{ github.ref }}
14+
cancel-in-progress: false
15+
16+
jobs:
17+
publish:
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- name: Check out repository
22+
uses: actions/checkout@v6
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Set up Node.js
27+
uses: actions/setup-node@v6
28+
with:
29+
node-version: "24"
30+
registry-url: "https://registry.npmjs.org"
31+
package-manager-cache: false
32+
33+
- name: Set up Bun
34+
uses: oven-sh/setup-bun@v2
35+
with:
36+
bun-version: "1.3.14"
37+
38+
- name: Verify release tag
39+
run: |
40+
package_version="$(node -p "require('./package.json').version")"
41+
test "${GITHUB_REF_NAME}" = "v${package_version}"
42+
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
43+
44+
- name: Install dependencies
45+
run: bun install --frozen-lockfile
46+
47+
- name: Run release checks
48+
run: bun run check
49+
50+
- name: Check out compatible OpenCode host
51+
uses: actions/checkout@v6
52+
with:
53+
repository: neriousy/opencode
54+
ref: 06e26d89ad0b06663fc4b34241f8661d170c2450
55+
path: .release/opencode
56+
persist-credentials: false
57+
58+
- name: Install OpenCode host dependencies
59+
working-directory: .release/opencode
60+
run: bun install --frozen-lockfile
61+
62+
- name: Verify packed package
63+
run: bun run test:packed
64+
env:
65+
OPENCODE_REPO: ${{ github.workspace }}/.release/opencode
66+
67+
- name: Remove compatible OpenCode host
68+
run: rm -rf "${GITHUB_WORKSPACE}/.release"
69+
70+
- name: Publish to npm
71+
run: npm publish

CONTEXT.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ generation ID so stale disconnects and late work cannot mutate a newer generatio
5656

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
59-
tab-creation policy; use Browser Session or the controller's `new_tab` operation.
59+
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.
61+
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.
6065

6166
## Runtime And Presentation
6267

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ behavior there; `Cmd-W` still closes the page.
107107

108108
Returning to OpenCode hides the browser without closing Electron or its pages, so reopening is fast. **Remove browser
109109
tabs from strip** also hides every browser page item while keeping the runtime ready; `/browser` restores them.
110-
Closing the final tracked page creates a fresh welcome page.
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.
111112

112113
Popup windows are deliberately denied. Use `+` or the controller's `new_tab` tool when a new tracked page is
113114
required.
@@ -139,10 +140,11 @@ The control server exposes eight tools: `show`, `open`, `new_tab`, `list_tabs`,
139140

140141
For reliable agent automation:
141142

142-
1. Use `list_tabs`, `new_tab`, or `open` to select the intended tracked page.
143-
2. Read its stable logical `tabId`, current CDP `targetId`, URL, and title.
144-
3. Call Chrome DevTools MCP `list_pages` and match the live page by URL/title.
145-
4. Use Chrome DevTools MCP's numeric `pageId` for page-scoped DevTools operations.
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.
145+
3. Read its stable logical `tabId`, current CDP `targetId`, URL, and title.
146+
4. Call Chrome DevTools MCP `list_pages` and match the live page by URL/title.
147+
5. Use Chrome DevTools MCP's numeric `pageId` for page-scoped DevTools operations.
146148

147149
`tabId`, `targetId`, and Chrome DevTools MCP's `pageId` are different identities:
148150

@@ -272,5 +274,7 @@ Release verification:
272274
bun run check
273275
bun run test:packed
274276
bun run test:mcp
275-
npm publish --access public
276277
```
278+
279+
Publishing is tag-driven through `.github/workflows/publish.yml`. Set the package version, push `main`, then push the
280+
matching `v<version>` tag. GitHub reruns `check` and `test:packed` before publishing through npm trusted publishing.

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.0",
4+
"version": "0.4.1",
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.0",
68+
"opentui-browser": "0.3.1",
6969
"oxfmt": "0.41.0",
7070
"solid-js": "1.9.12",
7171
"typescript": "^5"

src/browser-tab-strip.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid"
44
import { For, Show, createMemo, createSignal } from "solid-js"
55
import type { BrowserSession } from "./browser-session.js"
66
import type { BrowserTabController, BrowserTabState } from "./browser-tab.js"
7+
import { browserTabLabel } from "./browser-ui.js"
78

89
type BrowserTabItem =
910
| {
@@ -28,17 +29,6 @@ type BrowserTabItem =
2829
readonly close: () => void
2930
}
3031

31-
function browserPageLabel(title: string, url: string): string {
32-
const cleanTitle = title.replace(/[\u0000-\u001f\u007f]/gu, " ").trim()
33-
if (cleanTitle) return cleanTitle
34-
try {
35-
const parsed = new URL(url)
36-
return parsed.hostname || "New tab"
37-
} catch {
38-
return "New tab"
39-
}
40-
}
41-
4232
export function BrowserTabStrip(props: {
4333
readonly context: Context
4434
readonly tab: BrowserTabState
@@ -108,7 +98,7 @@ export function BrowserTabStrip(props: {
10898
? props.sessionSnapshot().tabs.map((item) => ({
10999
type: "browser",
110100
id: `browser:${item.tabId}`,
111-
title: browserPageLabel(item.title, item.url),
101+
title: browserTabLabel(item.title, item.url),
112102
active: props.snapshot().active && item.tabId === props.sessionSnapshot().selectedTabId,
113103
busy: item.loading || (props.snapshot().loading && item.tabId === props.sessionSnapshot().selectedTabId),
114104
attention: false,

src/browser-tab.ts

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -106,39 +106,40 @@ export function createBrowserTabController(
106106
if (!tabId || !page) throw new Error("No browser tab is selected")
107107
return { tabId, page }
108108
}
109-
110-
return {
111-
show() {
112-
tab.show()
113-
return status()
114-
},
115-
async open(url) {
116-
const normalized = normalizeBrowserUrl(url, ["http:", "https:"])
117-
tab.show()
118-
tab.setLoading(true)
119-
const page = session.selectedPage()
120-
if (!page) {
121-
try {
122-
const created = await session.newTab(normalized)
123-
expectedUrls.set(created.tabId, normalized)
124-
tab.setLoading(created.page.loading)
125-
return status()
126-
} catch (error) {
127-
tab.setLoading(false)
128-
throw error
129-
}
130-
}
131-
const tabId = session.snapshot().selectedTabId
132-
if (!tabId) throw new Error("No browser tab is selected")
133-
expectedUrls.set(tabId, normalized)
109+
const open = async (url: string) => {
110+
const normalized = normalizeBrowserUrl(url, ["http:", "https:"])
111+
tab.show()
112+
tab.setLoading(true)
113+
const page = session.selectedPage()
114+
if (!page) {
134115
try {
135-
await page.goto(normalized)
116+
const created = await session.newTab(normalized)
117+
expectedUrls.set(created.tabId, normalized)
118+
tab.setLoading(created.page.loading)
136119
return status()
137120
} catch (error) {
138121
tab.setLoading(false)
139122
throw error
140123
}
124+
}
125+
const tabId = session.snapshot().selectedTabId
126+
if (!tabId) throw new Error("No browser tab is selected")
127+
expectedUrls.set(tabId, normalized)
128+
try {
129+
await page.goto(normalized)
130+
return status()
131+
} catch (error) {
132+
tab.setLoading(false)
133+
throw error
134+
}
135+
}
136+
137+
return {
138+
show() {
139+
tab.show()
140+
return status()
141141
},
142+
open: (url) => open(url),
142143
async newTab(url) {
143144
const normalized = url === undefined ? initialUrl : normalizeBrowserUrl(url, ["http:", "https:"])
144145
tab.show()
@@ -168,7 +169,28 @@ export function createBrowserTabController(
168169
return status()
169170
},
170171
async closeTab(tabId) {
171-
if (!(await session.closeTab(tabId))) throw new Error(`Browser tab not found: ${tabId ?? "selected"}`)
172+
const before = session.snapshot()
173+
const previousTab = tab.snapshot()
174+
const closingTabId = tabId ?? before.selectedTabId
175+
const closingFinalTab = before.tabs.length === 1 && before.tabs[0]?.tabId === closingTabId
176+
const closedTab = closingFinalTab ? tab.close() : undefined
177+
const restoreTab = () => {
178+
if (!closedTab || tab.snapshot() !== closedTab || !previousTab.open) return
179+
tab.show()
180+
if (!previousTab.active) tab.hide()
181+
if (previousTab.loading) tab.setLoading(true)
182+
}
183+
let closed: boolean
184+
try {
185+
closed = await session.closeTab(tabId)
186+
} catch (error) {
187+
restoreTab()
188+
throw error
189+
}
190+
if (!closed) {
191+
restoreTab()
192+
throw new Error(`Browser tab not found: ${tabId ?? "selected"}`)
193+
}
172194
await session.settle()
173195
return status()
174196
},

src/browser-toolbar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CliRenderEvents, InputRenderable, type Renderable } from "@opentui/core
33
import { createEffect, createSignal, onCleanup } from "solid-js"
44
import type { BrowserSessionPage } from "./browser-session.js"
55
import type { BrowserTabController } from "./browser-tab.js"
6+
import { browserAddressValue } from "./browser-ui.js"
67

78
export function BrowserToolbar(props: {
89
readonly context: Context
@@ -11,7 +12,7 @@ export function BrowserToolbar(props: {
1112
readonly focusBrowserSurface: () => void
1213
readonly registerAddressInput: (input: InputRenderable) => () => void
1314
}) {
14-
const [address, setAddress] = createSignal(props.state().url)
15+
const [address, setAddress] = createSignal(browserAddressValue(props.state().url))
1516
const [editing, setEditing] = createSignal(false)
1617
let input: InputRenderable | undefined
1718
let releaseInput: (() => void) | undefined
@@ -35,7 +36,7 @@ export function BrowserToolbar(props: {
3536
try {
3637
const status = await props.controller.open(value)
3738
if (generation !== submissionGeneration || input?.isDestroyed) return
38-
setAddress(status.currentUrl)
39+
setAddress(browserAddressValue(status.currentUrl))
3940
input?.blur()
4041
props.focusBrowserSurface()
4142
} catch (error) {
@@ -47,7 +48,7 @@ export function BrowserToolbar(props: {
4748
}
4849

4950
createEffect(() => {
50-
const current = props.state().url
51+
const current = browserAddressValue(props.state().url)
5152
if (!editing() && current !== address()) setAddress(current)
5253
})
5354
const onFocusedRenderable = (current: Renderable | null) => setEditing(current === input)

src/browser-ui.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,19 @@
1+
import { DEFAULT_BROWSER_URL } from "./constants.js"
2+
13
export const BROWSER_IDLE_FOOTER = " ctrl+l location · ctrl+shift+b back "
4+
5+
export function browserAddressValue(url: string): string {
6+
return url === DEFAULT_BROWSER_URL ? "" : url
7+
}
8+
9+
export function browserTabLabel(title: string, url: string): string {
10+
if (url === DEFAULT_BROWSER_URL) return "New tab"
11+
const cleanTitle = title.replace(/[\u0000-\u001f\u007f]/gu, " ").trim()
12+
if (cleanTitle) return cleanTitle
13+
try {
14+
const parsed = new URL(url)
15+
return parsed.hostname || "New tab"
16+
} catch {
17+
return "New tab"
18+
}
19+
}

src/browser-workspace.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BrowserSurface } from "./browser-surface.js"
88
import { BrowserTabStrip } from "./browser-tab-strip.js"
99
import type { BrowserTabController, BrowserTabState } from "./browser-tab.js"
1010
import { BrowserToolbar } from "./browser-toolbar.js"
11+
import { browserAddressValue } from "./browser-ui.js"
1112
import { BROWSER_MODE } from "./constants.js"
1213
import { createBrowserPresentationTransitions } from "./presentation.js"
1314

@@ -59,7 +60,7 @@ export function BrowserWorkspace(props: {
5960
const focusAddress = () => {
6061
const input = currentAddressInput()
6162
if (!input || input.isDestroyed) return false
62-
input.value = selectedPageState()?.url ?? props.controller.status().currentUrl
63+
input.value = browserAddressValue(selectedPageState()?.url ?? props.controller.status().currentUrl)
6364
input.focus()
6465
input.selectAll()
6566
return true
@@ -71,7 +72,7 @@ export function BrowserWorkspace(props: {
7172
const cancelAddress = () => {
7273
const input = currentAddressInput()
7374
if (!input?.focused) return false
74-
input.value = selectedPageState()?.url ?? props.controller.status().currentUrl
75+
input.value = browserAddressValue(selectedPageState()?.url ?? props.controller.status().currentUrl)
7576
input.blur()
7677
focusBrowserSurface()
7778
return true

0 commit comments

Comments
 (0)