Compare commits

..
9 Commits
60 changed files with 1096 additions and 789 deletions
+19
View File
@@ -358,6 +358,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -605,6 +606,21 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -674,6 +690,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -2143,6 +2160,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -92,8 +92,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
@@ -116,8 +114,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
@@ -67,7 +67,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -88,7 +87,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -30,11 +30,9 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -68,15 +66,6 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -42,7 +42,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
@@ -32,24 +32,6 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -29,7 +29,7 @@ test("new session tab matches neighboring session widths", async ({ page }, test
await page.goto(href)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
@@ -194,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -53,8 +53,9 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -75,23 +76,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+1 -2
View File
@@ -2,7 +2,6 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
@@ -50,7 +50,6 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
+1
View File
@@ -984,6 +984,7 @@ export const dict = {
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.timeline.running": "Running",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
@@ -33,6 +33,7 @@ import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
type BackgroundTask = {
id: string
@@ -634,6 +635,28 @@ function MessageTimelineView(
</div>
</div>
</Show>
<Show
when={
!showWorking() &&
!backgroundHintPresence.present() &&
props.background
.tasks()
.some(
(task) =>
props.data.timelineDetail()[task.type === "subagent" ? "subagents" : "shell"].placement !==
"separate",
)
}
>
<div
role="status"
class={`flex h-9 w-full min-w-0 items-center gap-2 pt-3 text-13-regular text-v2-text-text-muted ${turnPadding()}`}
classList={{ "md:max-w-[1000px] md:mx-auto": props.centered }}
>
<SessionProgressIndicatorV2 />
<span>{language.t("settings.timeline.running")}</span>
</div>
</Show>
</>
}
deferred={(row) => {
+7 -11
View File
@@ -7,17 +7,13 @@
.settings-screen {
display: flex;
flex: 1;
width: calc(100% - 16px);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
margin-inline: 8px;
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
justify-content: center;
overflow: hidden;
border-radius: 10px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
background: var(--v2-background-bg-deep);
outline: none;
container: settings-screen / inline-size;
}
@@ -31,7 +27,7 @@
@media (max-width: 767px) {
.settings-screen {
--settings-mobile-inner-inset: 8px;
margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px));
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
}
}
@@ -55,7 +51,7 @@
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
@@ -353,7 +349,7 @@
gap: 16px;
padding: 8px var(--settings-mobile-inner-inset, 16px);
border-bottom: 0.5px solid var(--v2-border-border-muted);
background: var(--v2-background-bg-base);
background: var(--v2-background-bg-deep);
}
.settings-mobile-nav::after {
@@ -362,7 +358,7 @@
inset-inline: 0;
inset-block-start: 100%;
height: 1px;
background: var(--v2-background-bg-base);
background: var(--v2-background-bg-deep);
pointer-events: none;
}
+24 -13
View File
@@ -1,4 +1,14 @@
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Show,
onCleanup,
onMount,
startTransition,
} from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -44,7 +54,9 @@ const sections = [
],
] as const
export const SettingsScreen: Component = () => {
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
@@ -54,6 +66,7 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
let root: HTMLDivElement | undefined
onMount(() => {
@@ -62,8 +75,10 @@ export const SettingsScreen: Component = () => {
})
onCleanup(() => command.keybinds(true))
createEffect(() => setTab(props.defaultValue ?? "general"))
const server = createMemo(() => {
const route = surface.route()
const route = layout.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
@@ -86,7 +101,7 @@ export const SettingsScreen: Component = () => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = surface.route()
const route = layout.route()
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
@@ -97,7 +112,7 @@ export const SettingsScreen: Component = () => {
const showProviders = () => {
dialog.close()
surface.open("providers")
setTab("providers")
}
return (
@@ -115,8 +130,8 @@ export const SettingsScreen: Component = () => {
<Tabs
orientation="vertical"
variant="settings"
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="settings"
>
<div class="settings-mobile-nav">
@@ -128,18 +143,14 @@ export const SettingsScreen: Component = () => {
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
<For each={sections}>
{(group, index) => (
<>
+16 -36
View File
@@ -1,52 +1,32 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { useLocation } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
gate: false,
init: () => {
const navigate = useNavigate()
const layout = useLayout()
const command = useCommand()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
const location = useLocation()
const [store, setStore] = createStore({ open: false, tab: "general" })
let focus: HTMLElement | undefined
createEffect(
on(
open,
(value) => {
if (value) return
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
},
{ defer: true },
),
)
const close = () => {
if (!store.open) return
setStore("open", false)
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
}
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
return {
active: open,
route: source,
tab: () => location.state?.settings?.tab ?? "general",
store,
open(tab = "general") {
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
}
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
},
close() {
if (open()) command.trigger("common.goBack")
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
setStore({ open: true, tab })
},
close,
}
},
})
-3
View File
@@ -13,7 +13,6 @@ import { requireServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -21,7 +20,6 @@ const TargetSessionRouteContent = lazy(() =>
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -31,7 +29,6 @@ export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
+13 -2
View File
@@ -10,6 +10,7 @@ import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
@@ -85,14 +86,24 @@ export default function Layout(props: ParentProps) {
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
inert={settings.store.open}
aria-hidden={settings.store.open}
>
<Suspense>{props.children}</Suspense>
</div>
<Show when={settings.store.open}>
<Suspense>
<SettingsScreen defaultValue={settings.store.tab} />
</Suspense>
</Show>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
+1 -5
View File
@@ -3,13 +3,9 @@ import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
-2
View File
@@ -66,7 +66,6 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -105,7 +104,6 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
+17 -36
View File
@@ -8,75 +8,56 @@ function history(): TitlebarHistory {
describe("titlebar history", () => {
test("append and trim keeps max bounded", () => {
let state = history()
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.length).toBe(3)
expect(state.index).toBe(2)
})
test("back and forward indexes stay correct after trimming", () => {
let state = history()
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.index).toBe(2)
const back = backPath(state)
expect(back?.to.url).toBe("/b")
expect(back?.to).toBe("/b")
expect(back?.state.index).toBe(1)
const afterBack = applyPath(back!.state, back!.to, 3)
expect(afterBack.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
expect(afterBack.index).toBe(1)
const forward = forwardPath(afterBack)
expect(forward?.to.url).toBe("/c")
expect(forward?.to).toBe("/c")
expect(forward?.state.index).toBe(2)
const afterForward = applyPath(forward!.state, forward!.to, 3)
expect(afterForward.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
expect(afterForward.index).toBe(2)
})
test("action-driven navigation does not push duplicate history entries", () => {
const state: TitlebarHistory = {
stack: [{ url: "/" }, { url: "/a" }, { url: "/b" }],
stack: ["/", "/a", "/b"],
index: 2,
action: undefined,
}
const back = backPath(state)
expect(back?.to.url).toBe("/a")
expect(back?.to).toBe("/a")
const next = applyPath(back!.state, back!.to, 10)
expect(next.stack.map((entry) => entry.url)).toEqual(["/", "/a", "/b"])
expect(next.stack).toEqual(["/", "/a", "/b"])
expect(next.index).toBe(1)
expect(next.action).toBeUndefined()
})
test("settings visits retain their own route state", () => {
const first = { url: "/settings", state: { settings: { type: "draft", draftID: "a" } } }
const second = { url: "/settings", state: { settings: { type: "draft", draftID: "b" } } }
const state = applyPath(applyPath(applyPath(history(), first), { url: "/b" }), second)
const back = backPath(state)!
const previous = backPath(applyPath(back.state, back.to))!
expect(previous.to).toEqual(first)
expect(forwardPath(applyPath(back.state, back.to))?.to).toEqual(second)
})
test("replacing settings state does not add a back navigation", () => {
const initial = applyPath(history(), { url: "/settings", state: { tab: "general" } })
const updated = applyPath(initial, { url: "/settings", state: { tab: "models" } })
expect(updated.stack).toHaveLength(2)
const back = backPath(updated)!
expect(back.to.url).toBe("/")
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
})
})
+8 -18
View File
@@ -2,32 +2,22 @@ export const MAX_TITLEBAR_HISTORY = 100
export type TitlebarAction = "back" | "forward" | undefined
export type HistoryLocation = { url: string; state?: unknown }
export type TitlebarHistory = {
stack: HistoryLocation[]
stack: string[]
index: number
action: TitlebarAction
}
export function applyPath(
state: TitlebarHistory,
current: HistoryLocation,
max = MAX_TITLEBAR_HISTORY,
): TitlebarHistory {
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
if (!state.stack.length) {
const stack = current.url === "/" ? [current] : [{ url: "/" }, current]
const stack = current === "/" ? ["/"] : ["/", current]
return { stack, index: stack.length - 1, action: undefined }
}
const active = state.stack[state.index]
if (current.url === active.url) {
if (!state.action && current.state === active.state) return state
return {
...state,
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
action: undefined,
}
if (current === active) {
if (!state.action) return state
return { ...state, action: undefined }
}
if (state.action) return { ...state, action: undefined }
@@ -35,13 +25,13 @@ export function applyPath(
return pushPath(state, current, max)
}
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
const stack = state.stack.slice(0, state.index + 1).concat(path)
const next = trimHistory(stack, stack.length - 1, max)
return { ...state, ...next, action: undefined }
}
export function trimHistory(stack: HistoryLocation[], index: number, max = MAX_TITLEBAR_HISTORY) {
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
if (stack.length <= max) return { stack, index }
const cut = stack.length - max
return {
+1 -14
View File
@@ -432,20 +432,7 @@ export function DraftTabItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<svg
class="text-v2-icon-icon-muted group-data-[active='true']:text-v2-icon-icon-base"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M9.00002 13.5H14M2.60419 10.9167V13.3958H5.08335L13.3959 5.08333L10.9167 2.60416L2.60419 10.9167Z"
stroke="currentColor"
/>
</svg>
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
@@ -377,7 +377,7 @@ export function TitlebarTabStrip(props: {
index={visibleIndex()}
active={props.currentTab === tab}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("session.tab.session")}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
+21 -44
View File
@@ -1,5 +1,5 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -13,7 +13,7 @@ import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
import { applyPath, backPath, forwardPath } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
@@ -75,7 +75,7 @@ export function Titlebar(props: {
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
const [history, setHistory] = createStore({
stack: [] as HistoryLocation[],
stack: [] as string[],
index: 0,
action: undefined as "back" | "forward" | undefined,
})
@@ -83,7 +83,7 @@ export function Titlebar(props: {
const path = () => `${location.pathname}${location.search}${location.hash}`
createEffect(() => {
const current = { url: path(), state: location.state }
const current = path()
untrack(() => {
const next = applyPath(history, current)
@@ -113,14 +113,14 @@ export function Titlebar(props: {
const next = backPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to.url, { state: unwrap(next.to.state) })
navigate(next.to)
}
const forward = () => {
const next = forwardPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to.url, { state: unwrap(next.to.state) })
navigate(next.to)
}
command.register(() => [
@@ -297,7 +297,6 @@ export function Titlebar(props: {
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "settings":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -433,8 +432,8 @@ export function Titlebar(props: {
"md:pl-4": !macTrafficLights(),
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
<Show when={!mobile() && !props.verticalTabs}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -625,22 +624,10 @@ export function Titlebar(props: {
>
<Show when={macVerticalTabs()}>
<div
class="relative mb-2 w-full shrink-0"
class="relative w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
></div>
</Show>
{homeButton(true)}
<button
@@ -650,10 +637,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
@@ -670,14 +657,13 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
>
<TitlebarRightMount />
<Show when={!macVerticalTabs() && !windows()}>
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
<div class="absolute bottom-0 left-0 flex h-9 items-center">
<ChannelIndicator debugTools={props.debugTools} />
</Show>
</div>
<div class="absolute bottom-0 right-0 flex h-9 items-center">
<TitlebarRightMount />
</div>
</div>
</Portal>
)}
@@ -753,19 +739,13 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
const platform = usePlatform()
const style = () => ({
height: props.height,
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
style={style()}
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
@@ -779,10 +759,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<Show when={label}>
{(value) => (
<div
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
style={style()}
>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{value()}
</div>
)}
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Path } from "@/runtime/server/types"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -26,19 +26,16 @@ import {
displayPickerPath,
pickerParent,
pickerRoot,
listPickerDirectory,
pickerRelativePath,
pickerAbsolutePath,
} from "./domain"
import "./dialog.css"
import { Divider } from "@opencode-ai/ui/divider"
import { getFilename } from "@opencode-ai/util/path"
interface DirectoryPickerDialogProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
location?: LocationRef
mode?: "directory" | "file"
start?: string
}
@@ -70,24 +67,32 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
let navigation = 0
const [fallbackPath] = createResource(
() => (props.location ? undefined : true),
() => sdk.api.location.get().catch(() => undefined),
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
})
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
@@ -95,14 +100,12 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = location()?.directory
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: location(),
query,
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
@@ -110,7 +113,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
]
return {
query: value,
@@ -129,9 +132,15 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
@@ -273,7 +282,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || !location() || root()) return
if (!path || root()) return
void navigate(path)
})
@@ -16,7 +16,6 @@ import {
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
listPickerDirectory,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -131,181 +130,44 @@ test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the browser root without changing location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo", workspace: "workspace_1" }
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
find: (input: unknown) => {
calls.push(input)
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
expect(await search("components")).toEqual(["/repo/src/components"])
await search("components")
base = "/repo/src"
expect(await search("components")).toEqual(["/repo/src/components"])
await search("components")
expect(calls).toEqual([
{ location, query: "components", type: "directory", limit: 50 },
{ location, query: "src/components", type: "directory", limit: 50 },
])
})
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
list: async (input: { path?: string }) => {
calls.push(input)
return {
location,
data:
input.path === "/repo"
? [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
]
: [{ path: "../sibling/src/", type: "directory" }],
}
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "current", absolute: "/repo/current", type: "directory" },
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
])
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
])
expect(calls).toEqual([
{ location, path: "/repo" },
{ location, path: "/repo/sibling" },
])
})
test("uses listings for typed searches outside the current location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.reject(new Error("outside searches must not change location")),
list: async (input: unknown) => {
calls.push(input)
return { location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
expect(await search("sib")).toEqual(["/repo/sibling"])
expect(calls).toEqual([{ location, path: "/repo" }])
})
test("keeps literal tilde directory names in server listing and search results", async () => {
const location = { directory: "/repo" }
const sdk = {
api: {
file: {
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "~", absolute: "/repo/~", type: "directory" },
])
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
expect(await search("nested")).toEqual(["/repo/~/nested"])
})
test("discards stale typed results without changing the request location", async () => {
const location = { directory: "/repo" }
const pending = Promise.withResolvers<{
location: typeof location
data: Array<{ path: string; type: "directory" }>
}>()
const calls: unknown[] = []
const sdk = {
api: {
file: {
find: async (input: { query: string }) => {
calls.push(input)
if (input.query === "old") return pending.promise
return { location, data: [{ path: "new/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
const stale = search("old")
expect(await search("new")).toEqual(["/repo/new"])
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
expect(await stale).toEqual([])
expect(calls).toEqual([
{ location, query: "old", type: "directory", limit: 50 },
{ location, query: "new", type: "directory", limit: 50 },
])
})
test("maps server-native drive and share paths without rebasing the location", async () => {
const calls: unknown[] = []
const sdk = {
api: {
file: {
list: async (input: { location: { directory: string }; path: string }) => {
calls.push(input)
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
])
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
])
expect(calls).toEqual([
{ location: drive, path: "c:/repo" },
{ location: share, path: "//server/share" },
])
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const location = { directory: "/home/luke" }
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
@@ -318,19 +180,13 @@ test("lists the default directory when empty search is unsupported", async () =>
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
@@ -339,14 +195,12 @@ test("lists the default directory when empty search is unsupported", async () =>
})
test("matches the default directory listing when typed search is unsupported", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
location,
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
@@ -355,18 +209,12 @@ test("matches the default directory listing when typed search is unsupported", a
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const location = { directory: "/" }
const directories: string[] = []
const sdk = {
api: {
@@ -374,7 +222,6 @@ test("searches from an absolute root without a default base", async () => {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
@@ -384,7 +231,7 @@ test("searches from an absolute root without a default base", async () => {
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
+19 -43
View File
@@ -76,11 +76,6 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
return pickerAbsolutePath(value, current)
}
export function pickerAbsolutePath(input: string, current: string) {
const value = normalizePickerDrive(input)
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
@@ -326,24 +321,7 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value
}
export async function listPickerDirectory(
sdk: ServerSDK,
location: { directory: string; workspace?: string },
directory: string,
) {
const result = await sdk.api.file.list({ location, path: directory })
return result.data.map((entry) => {
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
return { name: getFilename(absolute), type: entry.type, absolute }
})
}
export function createDirectorySearch(args: {
sdk: ServerSDK
location: () => { directory: string; workspace?: string } | undefined
base: () => string | undefined
home: () => string
}) {
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -361,14 +339,21 @@ export function createDirectorySearch(args: {
}
const directories = async (directory: string) => {
const location = args.location()
if (!location) return []
const key = JSON.stringify([location, trimPickerPath(directory)])
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = listPickerDirectory(args.sdk, location, directory)
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
.then((nodes) => nodes.filter((node) => node.type === "directory"))
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
cache.set(key, request)
return request
}
@@ -384,27 +369,18 @@ export function createDirectorySearch(args: {
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
const location = args.location()
if (!input || !location) return [] as string[]
if (!input) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const relative = pickerRelativePath(location.directory, input.directory)
const results =
relative === undefined
? []
: await args.sdk.api.file
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
.then((result) =>
result.data
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
.filter((path) => treePathWithin(input.directory, path)),
)
.catch(() => [])
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
if (results.length) {
return results.slice(0, 50)
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
@@ -16,7 +16,6 @@ describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -2,7 +2,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode-ai/client/promise"
import { directoryPickerKind } from "./policy"
const DirectoryPickerDialog = lazy(() =>
@@ -11,7 +10,6 @@ const DirectoryPickerDialog = lazy(() =>
type DirectoryPickerInput = {
server: ServerConnection.Any
location?: LocationRef
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
+1 -6
View File
@@ -141,12 +141,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
"--",
],
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
windows: {},
},
define: {
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
@@ -59,7 +59,7 @@ export default Runtime.handler(Commands, (input) =>
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
+2 -2
View File
@@ -6,7 +6,7 @@ import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
@@ -113,7 +113,7 @@ Effect.gen(function* () {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
+2 -2
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
@@ -86,7 +86,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const server = yield* start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
+1 -1
View File
@@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
export type FileListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
readonly path?: RelativePath | undefined
}
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
+1
View File
@@ -122,6 +122,7 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+6 -7
View File
@@ -25,7 +25,7 @@ export const Content = Schema.Struct({
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@@ -93,18 +93,17 @@ const baseLayer = Layer.effect(
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
// Navigation can leave the cwd without activating another Location.
const directory = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(directory).pipe(Effect.orDie)
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory).pipe(
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(directory, item.name)
const relative = path.relative(location.directory, absolute) || "."
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
+2
View File
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+4 -59
View File
@@ -83,34 +83,6 @@ describe("FileSystem", () => {
),
)
it.live("lists parents and siblings with paths relative to the current location", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
expect(parent).toHaveLength(2)
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
expect.arrayContaining([
{ path: "." + path.sep, type: "directory" },
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
]),
)
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
])
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
expect(absolute).toEqual(sibling)
}).pipe(provide(current))
}),
),
)
it.live("canonicalizes local symlinked directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -133,37 +105,10 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
),
)
it.live("allows listing through an external symlink without allowing file reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
const outside = path.join(directory, "outside")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(outside))
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
])
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
)
})
+51
View File
@@ -0,0 +1,51 @@
# Experimental Browser Plugin
`@opencode-ai/plugin-browser` implements the server-side browser tool using only
the public plugin API, public schemas, and Effect. Core registers it as a built-in
plugin; the package does not depend on Core or Server. The shared RPC contract is
`@opencode-ai/schema/browser`; desktop clients do not import Core.
The agent calls the browser through Code Mode's `execute` tool, not a separate
raw tool. Discover its signature with `search({ query: "browser" })`, then call:
```js
await tools.browser({ type: "open" })
await tools.browser({ type: "navigate", url: "https://example.com" })
return await tools.browser({ type: "snapshot" })
```
Results retain their untrusted-content wrapper. Screenshots are attached to the
`execute` result as images. The tool remains discoverable without a desktop
attachment; calls fail with `No desktop browser is connected.` until one connects.
Await actions on the same page in order.
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["-opencode.browser"],
}
```
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
session's location. Subscribe to server events before calling `attach`; wait for
`server.connected`, then the matching `attached` control event. The `attach` call
stays pending for the attachment lifetime. Abort it when its event stream ends or
the desktop owner closes. Completing the attachment also ends that event consumer.
- `attach` holds one browser attachment per session until cancellation, plugin
unload, session deletion, or session movement.
- `state` reports the current page, or `null` when no page is open.
- `result` completes a command with its request ID and outcome.
- `control` events carry attachment confirmation, commands, and cancellation.
Control events use OpenCode's existing authenticated, server-wide event feed.
Consumers filter by `connectionID`; this identifier is correlation, not private
event delivery. State and results use RPC calls rather than broadcast events.
Per-URL permission checks are deferred to the final permission layer (#46530).
Until that layer lands, browser actions do not enforce URL-specific ask or deny
rules. Attachment ownership, page validation, and cancellation remain enforced.
Browser content is untrusted. Pages use the desktop's network, with no server-side
tunnel. The desktop owns Chromium, page isolation, and native controls.
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/plugin-browser"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import pkg from "../package.json"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
await $`bun run typecheck`
await $`bun run build`
const original = await Bun.file("package.json").text()
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
try {
await Bun.write(
"package.json",
JSON.stringify(
{
...pkg,
exports: { ".": { import: "./dist/index.js", types: "./dist/index.d.ts" } },
},
null,
2,
) + "\n",
)
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", original)
await rm(tarball, { force: true })
}
+172
View File
@@ -0,0 +1,172 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Session } from "@opencode-ai/schema/session"
import { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string
state: Browser.State | null
closed: Deferred.Deferred<void>
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
}
export default Plugin.define({
id: "opencode.browser",
effect: (ctx) =>
Effect.gen(function* () {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
attach: (input, call) =>
Effect.gen(function* () {
const session = yield* ctx.session
.get({ sessionID: input.sessionID })
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
if (
session.location.directory !== ctx.location.directory ||
session.location.workspaceID !== ctx.location.workspaceID
)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>()
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
// The newest desktop attachment wins so a re-register that races the
// previous connection's teardown does not leave the session detached.
yield* close(input.sessionID)
const browser: Attachment = {
connectionID: input.connectionID,
state: null,
closed,
pending: new Map(),
}
browsers.set(input.sessionID, browser)
return browser
}),
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
browser.state = input.state
}),
result: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const pending = browser.pending.get(input.requestID)
if (!pending) return
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
Effect.asVoid,
)
yield* Deferred.succeed(pending, input.outcome.result)
}).pipe(Effect.asVoid),
})
.pipe(Effect.orDie)
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "browser",
input: Browser.Action,
options: { codemode: true },
description:
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
execute: (action, tool) =>
Effect.gen(function* () {
const browser = browsers.get(tool.sessionID)
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
if (action.type !== "open" && !browser.state)
return yield* new Tool.Error({ message: "Open the browser first." })
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
browser.pending.set(requestID, pending)
const result = yield* rpc.events
.emit("control", {
type: "command",
connectionID: browser.connectionID,
requestID,
command: { action, generation: browser.state?.generation ?? 0 },
})
.pipe(
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
Effect.andThen(Deferred.await(pending)),
Effect.raceFirst(
Deferred.await(browser.closed).pipe(
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
),
),
Effect.onInterrupt(() =>
rpc.events
.emit("control", {
type: "cancel",
connectionID: browser.connectionID,
requestID,
})
.pipe(Effect.ignore),
),
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
}),
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
)
return render(result)
}),
}),
)
.pipe(Effect.orDie)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
Stream.runForEach((event) => close(event.data.sessionID)),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function render(result: Browser.Result): Tool.Result {
if (result.type === "screenshot")
return {
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{
type: "file",
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
mime: "image/png",
name: "browser-screenshot.png",
},
],
metadata: { url: result.state.url },
}
const content = JSON.stringify(result)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
return {
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
metadata: { url: result.state.url },
}
}
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"noEmit": false
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
+3 -6
View File
@@ -1,15 +1,13 @@
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/schema/location"
import { PositiveInt } from "@opencode-ai/schema/schema"
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: Schema.String.pipe(Schema.optional).annotate({
description: "An absolute path or a path relative to the requested location. Defaults to the location directory.",
}),
path: RelativePath.pipe(Schema.optional),
})
const FindQuery = Schema.Struct({
@@ -44,8 +42,7 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
OpenApi.annotations({
identifier: "v2.fs.list",
summary: "List directory",
description:
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
description: "List direct children of one directory relative to the requested location.",
}),
),
)
+123
View File
@@ -0,0 +1,123 @@
export * as Browser from "./browser.js"
import { Schema } from "effect"
import { Rpc } from "./rpc.js"
import { Session } from "./session.js"
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
loading: Schema.Boolean,
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
"Space",
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
export type Direction = typeof Direction.Type
export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literals(["open", "snapshot", "screenshot", "back", "forward", "reload", "stop"]) }),
Schema.Struct({ type: Schema.Literal("navigate"), url: Schema.String.check(Schema.isMaxLength(16_384)) }),
Schema.Struct({ type: Schema.Literal("click"), ref: Ref }),
Schema.Struct({ type: Schema.Literal("fill"), ref: Ref, text: Schema.String.check(Schema.isMaxLength(10_000)) }),
Schema.Struct({ type: Schema.Literal("press"), key: Key }),
Schema.Struct({
type: Schema.Literal("evaluate"),
script: Schema.String.check(Schema.isMaxLength(100_000)).annotate({
description: "JavaScript to evaluate in the page. The result is JSON-serialized.",
}),
}),
Schema.Struct({
type: Schema.Literal("scroll"),
direction: Direction,
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
}),
]).annotate({ identifier: "Browser.Action" })
export type Action = typeof Action.Type
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
identifier: "Browser.Command",
})
export const Result = Schema.Union([
Schema.Struct({ type: Schema.Literal("state"), state: State }),
Schema.Struct({
type: Schema.Literal("snapshot"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("evaluate"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("screenshot"),
state: State,
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
}),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,
requestID: Schema.String,
command: Command,
}),
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Control" })
export type Control = typeof Control.Type
export const Definition = Rpc.define({
id: "experimental.browser",
methods: {
attach: { input: Schema.Struct(attachment), output: Schema.Void, errors },
state: { input: Schema.Struct({ ...attachment, state: Schema.NullOr(State) }), output: Schema.Void, errors },
result: {
input: Schema.Struct({ ...attachment, requestID: Schema.String, outcome: Outcome }),
output: Schema.Void,
errors,
},
},
events: { control: { schema: Control } },
})
+1
View File
@@ -39,6 +39,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
+3 -1
View File
@@ -15,6 +15,7 @@ const names = [
"protocol",
"client",
"plugin",
"plugin-browser",
"core",
"simulation",
"server",
@@ -163,12 +164,13 @@ export default {
Bun.write(
join(consumer, "boot.mjs"),
`import { Miniflare } from "miniflare"
import { fileURLToPath } from "node:url"
const miniflare = new Miniflare({
compatibilityDate: "2026-07-15",
compatibilityFlags: ["nodejs_compat"],
modules: true,
scriptPath: new URL("./dist/worker.js", import.meta.url).pathname,
scriptPath: fileURLToPath(new URL("./dist/worker.js", import.meta.url)),
durableObjects: { OPENCODE: { className: "OpenCodeDO", useSQLite: true } },
})
+334
View File
@@ -0,0 +1,334 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Tool } from "@opencode-ai/core/tool"
import plugin from "@opencode-ai/plugin-browser"
import { Browser } from "@opencode-ai/schema/browser"
import { Agent, Rpc } from "@opencode-ai/plugin/effect"
import type { Info } from "@opencode-ai/schema/tool"
import { AbsolutePath, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({
plugins: ["-opencode.browser"],
}),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Info>()
yield* opencode.plugin({ ...plugin, id: "browser-test" })
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
tool,
opencode,
location,
rpc,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test(
"attachment ownership, cancellation, and plugin unload release pending browser work",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const stale = yield* host.attach("stale")
// A newer attachment for the same session replaces the previous one.
const attached = yield* host.attach("first")
yield* Fiber.join(stale.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...stale.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"Code Mode discovers and executes the browser without a raw tool and preserves screenshot attachments",
() =>
Effect.gen(function* () {
const host = yield* fixture
yield* Effect.gen(function* () {
const tools = yield* Tool.Service
yield* tools.transform((editor) => editor.add(host.tool))
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools).toMatchObject([{ type: "tool", name: "browser" }])
const attached = yield* host.attach("codemode")
const execute = (code: string) =>
snapshot.execute({
sessionID: attached.input.sessionID,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
call: { type: "tool-call", id: crypto.randomUUID(), name: "execute", input: { code } },
})
const discovery = yield* execute('return search({ query: "browser" })')
expect(discovery.content).toMatchObject([{ type: "text", text: expect.stringContaining("tools.browser(") }])
yield* host.rpc.state({ ...attached.input, state }, { location: host.location })
const pending = yield* execute('return await tools.browser({ type: "screenshot" })').pipe(Effect.forkScoped)
const event = yield* host.next
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command).toEqual({ action: { type: "screenshot" }, generation: state.generation })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: event.data.requestID,
outcome: { type: "success", result: { type: "screenshot", state, data } },
},
{ location: host.location },
)
const result = yield* Fiber.join(pending)
expect(result.content).toEqual([
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
])
expect(result.metadata).toEqual({
toolCalls: [{ tool: "browser", status: "completed", input: { type: "screenshot" } }],
})
yield* Fiber.interrupt(attached.lifetime)
const disconnected = yield* execute('return await tools.browser({ type: "open" })')
expect(disconnected.metadata).toMatchObject({ error: true })
expect(disconnected.content).toMatchObject([
{ type: "text", text: expect.stringContaining("No desktop browser is connected.") },
])
}).pipe(
Effect.provide(AppNodeBuilder.build(Tool.node, [Location.node.replace(Location.boundNode(host.location))])),
)
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"commands use published state, and RPC results render text and screenshot bytes",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{
...attached.input,
requestID: open.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
@@ -1,20 +0,0 @@
const fs = require("node:fs")
const readline = require("node:readline")
fs.appendFileSync(process.argv[2], `${process.pid}\n`)
readline
.createInterface({ input: process.stdin })
.on("line", (line) => {
const request = JSON.parse(line)
if (request.id === undefined) return
const result =
request.method === "initialize"
? {
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "filesystem-test", version: "1" },
}
: { tools: [] }
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n")
})
.on("close", () => process.exit(0))
-79
View File
@@ -1,79 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"browsing parents and siblings reuses the current Location and its MCP process",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const current = path.join(tmp.path, "root", "current")
const starts = path.join(tmp.path, "starts")
yield* Effect.promise(async () => {
await fs.mkdir(current, { recursive: true })
await fs.mkdir(path.join(tmp.path, "root", ".git"))
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
await fs.writeFile(starts, "")
await fs.writeFile(
path.join(tmp.path, "root", "opencode.json"),
JSON.stringify({
mcp: {
servers: {
filesystem: {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
},
},
},
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const list = (directory: string) =>
Effect.promise(async () => {
const url = new URL("/api/fs/list", server.base)
url.searchParams.set("location[directory]", current)
url.searchParams.set("path", directory)
const response = await fetch(url, { headers: server.headers })
expect(response.status).toBe(200)
const result = await response.json()
expect(result.location.directory).toBe(current)
return result.data
})
const loaded = Effect.promise(async () => {
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
expect(response.status).toBe(200)
return response.json()
})
const count = Effect.promise(
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
)
yield* list(".")
expect(yield* loaded).toEqual([{ directory: current }])
expect(
yield* count.pipe(
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
Effect.timeout("5 seconds"),
),
).toBe(1)
yield* list("..")
const sibling = yield* list("../sibling")
expect(sibling).toEqual([
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
])
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
yield* list("../sibling/nested")
yield* list("../sibling")
expect(yield* loaded).toEqual([{ directory: current }])
expect(yield* count).toBe(1)
}),
15_000,
)
@@ -529,11 +529,13 @@ export function CurrentContextToolGroup(props: {
)
const label = createMemo(() => {
const notices = props.parts.filter((part) => part.type === "notice").length
const title =
[names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ") ||
i18n.plural("ui.messagePart.context.thought", props.parts.filter((part) => part.type === "reasoning").length)
if (!names() && !notices) {
const title = i18n.t("ui.messagePart.context.reasoning")
return { text: title, title, before: "", after: "" }
}
const title = [names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ")
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
const index = text.indexOf(title)
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
@@ -25,7 +25,13 @@ import { Skill } from "@opencode-ai/schema/skill"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, reconcileSelectionWindow, revealSelectionOffset } from "../../ui/select-controller"
import { directoryAutocomplete, slashArgumentAutocomplete } from "../../prompt/directory-completion"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
slashArgumentAutocomplete,
} from "../../prompt/directory-completion"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -337,38 +343,20 @@ export function Autocomplete(props: {
if (referenceMatch())
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
const { lineRange, base } = parseFileLineRange(input.query ?? "")
const directorySearch =
input.visible === "directory"
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
: undefined
const requestLocation = {
directory: input.location?.directory,
directory: directorySearch?.directory ?? input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
}
const width = props.anchor().width - 4
if (input.visible === "directory") {
const result = await directoryAutocomplete(
client.api.file,
{ ...requestLocation, directory: requestLocation.directory ?? paths.cwd },
base,
paths.home,
).catch(() => undefined)
if (!result)
return info.value?.mode === input.visible
? { ...info.value, failed: true }
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
return {
options: result.map((item) => ({
display: Locale.truncateMiddle(item.value, width),
value: item.value,
isDirectory: true,
path: item.value,
absolute: item.absolute,
onSelect: () => insertDirectory(item.value),
})),
failed: false,
mode: input.visible,
query: input.query,
resolved: true,
}
}
const result = await client.api.file.find({ query: base, limit: 20, location: requestLocation }).then(
const result = await (
input.visible === "directory"
? client.api.file.list({ location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
).then(
(result) => result,
() => undefined,
)
@@ -380,8 +368,38 @@ export function Autocomplete(props: {
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
if (exact) {
options.push({
display: Locale.truncateMiddle(exact, width),
value: exact,
isDirectory: true,
path: exact,
absolute: result.location.directory,
onSelect: () => insertDirectory(exact),
})
}
const entries =
input.visible === "directory"
? result.data.filter(
(item) =>
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
)
: result.data
options.push(
...result.data.map((item): AutocompleteOption => {
...entries.map((item): AutocompleteOption => {
if (input.visible === "directory") {
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
return {
display: Locale.truncateMiddle(directory, width),
value: directory,
isDirectory: true,
path: directory,
absolute: path.resolve(result.location.directory, item.path),
onSelect: () => insertDirectory(directory),
}
}
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
@@ -1,31 +1,8 @@
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import type { OpenCodeClient } from "@opencode-ai/client"
import path from "path"
import { displaySlice, promptOffsetWidth } from "./display"
import { parseSlashHead } from "./parse"
export async function directoryAutocomplete(
file: Pick<OpenCodeClient["file"], "list">,
location: { directory: string; workspace?: string },
query: string,
home: string,
) {
const search = directoryAutocompleteSearch(query, location.directory, home)
const result = await file.list({ location, path: search.directory })
const exact = directoryAutocompleteExactValue(query, search)
return [
...(exact ? [{ value: exact, absolute: search.directory }] : []),
...result.data
.filter((item) => item.type === "directory")
.map((item) => path.resolve(result.location.directory, item.path))
.filter((absolute) => directoryAutocompleteMatches(path.basename(absolute), search.query))
.map((absolute) => ({
value: directoryAutocompleteResultValue(path.basename(absolute) + "/", search),
absolute,
})),
]
}
export function slashArgumentAutocomplete(
value: string,
offset: number,
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import {
directoryAutocomplete,
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
@@ -11,64 +10,6 @@ import {
slashArgumentAutocomplete,
} from "../../src/prompt/directory-completion"
describe("directoryAutocomplete", () => {
test("lists parents and siblings through the current workspace without changing location", async () => {
const location = { directory: "/project/current", workspace: "workspace_1" }
const calls: unknown[] = []
const file = {
list: async (input: unknown) => {
calls.push(input)
return {
location: { directory: location.directory, workspaceID: location.workspace },
data: [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
{ path: "../.hidden/", type: "directory" },
{ path: "../README.md", type: "file" },
],
}
},
} as Parameters<typeof directoryAutocomplete>[0]
expect(await directoryAutocomplete(file, location, "..", "/home/user")).toEqual([
{ value: "..", absolute: path.resolve("/project") },
{ value: "../current/", absolute: path.resolve("/project/current") },
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
])
expect(await directoryAutocomplete(file, location, "../sib", "/home/user")).toEqual([
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
])
expect(calls).toEqual([
{ location, path: path.resolve("/project") },
{ location, path: path.resolve("/project") },
])
})
test("resolves home and nested sibling completions against the current location", async () => {
const location = { directory: "/project/current", workspace: "workspace_1" }
const calls: unknown[] = []
const file = {
list: async (input: unknown) => {
calls.push(input)
return {
location: { directory: location.directory },
data: [{ path: "../sibling/src/", type: "directory" }],
}
},
} as Parameters<typeof directoryAutocomplete>[0]
expect(await directoryAutocomplete(file, location, "../sibling/s", "/project/sibling")).toEqual([
{ value: "../sibling/src/", absolute: path.resolve("/project/sibling/src") },
])
expect(await directoryAutocomplete(file, location, "~", "/project/sibling")).toEqual([
{ value: "~", absolute: "/project/sibling" },
{ value: "~/src/", absolute: path.resolve("/project/sibling/src") },
])
expect(calls).toEqual([
{ location, path: path.resolve("/project/sibling") },
{ location, path: "/project/sibling" },
])
})
})
const commands = [
{
id: "session.cd",
+3 -4
View File
@@ -108,10 +108,9 @@ const source = {
"ui.messagePart.context.search.other": "{{count}} searches",
"ui.messagePart.context.list.one": "{{count}} list",
"ui.messagePart.context.list.other": "{{count}} lists",
"ui.messagePart.context.notice.one": "{{count}} Notice",
"ui.messagePart.context.notice.other": "{{count}} Notices",
"ui.messagePart.context.thought.one": "{{count}} Thought",
"ui.messagePart.context.thought.other": "{{count}} Thoughts",
"ui.messagePart.context.notice.one": "{{count}} notice",
"ui.messagePart.context.notice.other": "{{count}} notices",
"ui.messagePart.context.reasoning": "Reasoning",
"ui.messagePart.context.match.one": "({{count}} match)",
"ui.messagePart.context.match.other": "({{count}} matches)",
"ui.messagePart.tools.used": "Used {{tools}}",
+1 -1
View File
@@ -6,7 +6,7 @@ import "./icon.css"
const icons = {
edit: {
viewBox: "0 0 16 16",
body: `<path d="M13.5556 8.21529V13.5556H2.44446L2.44446 2.44445H7.78474M6.00002 8.16216V10H7.83786L14 3.83784L12.1622 2L6.00002 8.16216Z" stroke="currentColor"/>`,
body: `<path d="M13.5555 8.21534V13.5556H2.44434L2.44434 2.4445H7.78462M6.88878 9.11119C6.88878 9.11119 8.96327 9.0367 9.69678 8.3032L14.0301 3.96986C14.5824 3.4176 14.5824 2.52213 14.0301 1.96986C13.4778 1.4176 12.5824 1.4176 12.0301 1.96986L7.69678 6.3032C7.00513 6.99484 6.88878 9.11119 6.88878 9.11119Z" stroke="currentColor"/>`,
},
"folder-add-left": {
viewBox: "0 0 16 16",
@@ -11,7 +11,7 @@ export function Wordmark(props: Pick<ComponentProps<"svg">, "class">) {
fill="none"
classList={{ [props.class ?? ""]: !!props.class }}
>
<g opacity="0.6" class="[[data-color-scheme=dark]_&]:opacity-100">
<g opacity="0.6">
<g mask={`url(#${mask})`}>
<g opacity="0.16">
<path
+3
View File
@@ -62,6 +62,9 @@ await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin-browser ===\n")
await $`bun ./packages/plugin-browser/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`