From e9071b0a8088c1679c730c25fdb59c25db271ea5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 26 Apr 2026 19:33:18 -0400 Subject: [PATCH 01/17] tui: remove excessive debug logging from workspace creation flow to reduce terminal output noise --- .../tui/component/dialog-workspace-create.tsx | 101 ++---------------- packages/opencode/src/server/proxy.ts | 2 +- 2 files changed, 7 insertions(+), 96 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 899ab42ee1..009bb74d2c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -6,8 +6,7 @@ import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" import { setTimeout as sleep } from "node:timers/promises" -import { errorData, errorMessage } from "@/util/error" -import * as Log from "@opencode-ai/core/util/log" +import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" @@ -17,8 +16,6 @@ type Adaptor = { description: string } -const log = Log.Default.clone().tag("service", "tui-workspace") - function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { return createOpencodeClient({ baseUrl: sdk.url, @@ -37,18 +34,9 @@ export async function openWorkspaceSession(input: { workspaceID: string }) { const client = scoped(input.sdk, input.sync, input.workspaceID) - log.info("workspace session create requested", { - workspaceID: input.workspaceID, - }) while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => { - log.error("workspace session create request failed", { - workspaceID: input.workspaceID, - error: errorData(err), - }) - return undefined - }) + const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) if (!result) { input.toast.show({ message: "Failed to create workspace session", @@ -56,24 +44,11 @@ export async function openWorkspaceSession(input: { }) return } - log.info("workspace session create response", { - workspaceID: input.workspaceID, - status: result.response?.status, - sessionID: result.data?.id, - }) if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - log.warn("workspace session create retrying after server error", { - workspaceID: input.workspaceID, - status: result.response.status, - }) await sleep(1000) continue } if (!result.data) { - log.error("workspace session create returned no data", { - workspaceID: input.workspaceID, - status: result.response?.status, - }) input.toast.show({ message: "Failed to create workspace session", variant: "error", @@ -85,10 +60,6 @@ export async function openWorkspaceSession(input: { type: "session", sessionID: result.data.id, }) - log.info("workspace session create complete", { - workspaceID: input.workspaceID, - sessionID: result.data.id, - }) input.dialog.clear() return } @@ -104,27 +75,10 @@ export async function restoreWorkspaceSession(input: { sessionID: string done?: () => void }) { - log.info("session restore requested", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - }) const result = await input.sdk.client.experimental.workspace .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) - .catch((err) => { - log.error("session restore request failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - error: errorData(err), - }) - return undefined - }) + .catch(() => undefined) if (!result?.data) { - log.error("session restore failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: result?.response?.status, - error: result?.error ? errorData(result.error) : undefined, - }) input.toast.show({ message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", @@ -132,33 +86,11 @@ export async function restoreWorkspaceSession(input: { return } - log.info("session restore response", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: result.response?.status, - total: result.data.total, - }) - input.project.workspace.set(input.workspaceID) - try { - await input.sync.bootstrap({ fatal: false }) - } catch (e) {} + await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => { - log.error("session restore refresh failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - error: errorData(err), - }) - throw err - }) - - log.info("session restore complete", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total: result.data.total, - }) + await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) input.toast.show({ message: "Session restored into the new workspace", @@ -230,47 +162,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const create = async (type: string) => { if (creating()) return setCreating(type) - log.info("workspace create requested", { - type, - }) - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { toast.show({ message: "Creating workspace failed", variant: "error", }) - log.error("workspace create request failed", { - type, - error: errorData(err), - }) return undefined }) const workspace = result?.data if (!workspace) { setCreating(undefined) - log.error("workspace create failed", { - type, - status: result?.response.status, - error: result?.error ? errorData(result.error) : undefined, - }) toast.show({ message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) return } - log.info("workspace create response", { - type, - workspaceID: workspace.id, - status: result.response?.status, - }) await project.workspace.sync() - log.info("workspace create synced", { - type, - workspaceID: workspace.id, - }) await props.onSelect(workspace.id) setCreating(undefined) } diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 19a623cb0c..4d9bcd1174 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -101,7 +101,7 @@ const app = (upgrade: UpgradeWebSocket) => }), ) -const log = Log.Default.clone().tag("service", "server-proxy") +const log = Log.create({ service: "server-proxy" }) export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { if (!Workspace.isSyncing(workspaceID)) { From 58244eb6879abacfa31cd058d3dee32151012f74 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 26 Apr 2026 19:55:13 -0400 Subject: [PATCH 02/17] feat(httpapi): bridge event stream (#24518) --- packages/opencode/specs/effect/http-api.md | 4 +- .../server/routes/instance/httpapi/event.ts | 46 ++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/index.ts | 2 + .../test/server/httpapi-event.test.ts | 52 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/event.ts create mode 100644 packages/opencode/test/server/httpapi-event.test.ts diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 5f16ef197e..6536ac947c 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -184,7 +184,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | | `sync` | `bridged` | start/replay/history | -| `event` | `special` | SSE | +| `event` | `bridged` | SSE via raw Effect HTTP | | `pty` | `special` | websocket | | `tui` | `special` | UI bridge | @@ -316,7 +316,7 @@ This checklist tracks bridge parity only. Checked routes are available through t ### Event Routes -- [ ] `GET /event` - SSE event stream; replace with raw Effect HTTP, not `HttpApi`. +- [x] `GET /event` - SSE event stream via raw Effect HTTP. ### PTY Routes diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts new file mode 100644 index 0000000000..dc5fa9c530 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -0,0 +1,46 @@ +import { Bus } from "@/bus" +import { Log } from "@/util" +import { Effect } from "effect" +import * as Stream from "effect/Stream" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +export const EventPaths = { + event: "/event", +} as const + +function eventData(data: unknown) { + return `data: ${JSON.stringify(data)}\n\n` +} + +export const eventRoute = HttpRouter.add( + "GET", + EventPaths.event, + Effect.gen(function* () { + const bus = yield* Bus.Service + const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + ) + + log.info("event connected") + return HttpServerResponse.stream( + Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) + }).pipe(Effect.provide(Bus.layer)), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index adc70a43b3..1e8b23f55e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -10,6 +10,7 @@ import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" import { authorizationLayer } from "./auth" import { ConfigApi, configHandlers } from "./config" +import { eventRoute } from "./event" import { FileApi, fileHandlers } from "./file" import { ExperimentalApi, experimentalHandlers } from "./experimental" import { InstanceApi, instanceHandlers } from "./instance" @@ -66,6 +67,7 @@ const instance = HttpRouter.middleware()( ).layer export const routes = Layer.mergeAll( + eventRoute, HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)), HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 4c0503af5a..c2e89c14e9 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" import { ExperimentalPaths } from "./httpapi/experimental" import { FilePaths } from "./httpapi/file" import { InstancePaths } from "./httpapi/instance" @@ -41,6 +42,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler const context = Context.empty() as Context.Context + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) app.get("/question", (c) => handler(c.req.raw, context)) app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts new file mode 100644 index 0000000000..42d2f80364 --- /dev/null +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { UpgradeWebSocket } from "hono/ws" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Instance } from "../../src/project/instance" +import { InstanceRoutes } from "../../src/server/routes/instance" +import { EventPaths } from "../../src/server/routes/instance/httpapi/event" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return InstanceRoutes(websocket) +} + +async function readFirstChunk(response: Response) { + if (!response.body) throw new Error("missing response body") + const reader = response.body.getReader() + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)), + ]) + await reader.cancel() + return new TextDecoder().decode(result.value) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("event HttpApi bridge", () => { + test("serves event stream through experimental Effect route", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/event-stream") + expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") + expect(response.headers.get("x-accel-buffering")).toBe("no") + expect(response.headers.get("x-content-type-options")).toBe("nosniff") + expect(await readFirstChunk(response)).toContain( + 'data: {"type":"server.connected","properties":{}}\n\n', + ) + }) +}) From c4d8a8183e6c2d15831767f1b898a8d0ed0297b9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 26 Apr 2026 23:56:15 +0000 Subject: [PATCH 03/17] chore: generate --- packages/opencode/test/server/httpapi-event.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 42d2f80364..53bb1edb27 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -45,8 +45,6 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain( - 'data: {"type":"server.connected","properties":{}}\n\n', - ) + expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') }) }) From 141f33d24bdc059aa26bd1e32c9416ac3aed36e1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:54:55 +1000 Subject: [PATCH 04/17] feat: configurable shell selection + desktop settings UI (#20602) --- .../app/src/components/settings-general.tsx | 187 ++++++++++++------ .../app/src/context/global-sync/bootstrap.ts | 7 +- packages/app/src/i18n/en.ts | 5 + packages/opencode/src/config/config.ts | 20 +- packages/opencode/src/pty/index.ts | 21 +- .../src/server/routes/instance/pty.ts | 27 +++ packages/opencode/src/session/prompt.ts | 53 +---- packages/opencode/src/shell/shell.ts | 145 ++++++++++++-- packages/opencode/src/tool/bash.ts | 17 +- packages/opencode/test/config/config.test.ts | 102 ++++++++++ packages/opencode/test/pty/pty-shell.test.ts | 35 ++++ packages/opencode/test/session/prompt.test.ts | 67 +++++++ packages/opencode/test/shell/shell.test.ts | 28 ++- packages/opencode/test/tool/bash.test.ts | 29 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 27 +++ packages/sdk/openapi.json | 60 ++++++ packages/web/src/content/docs/config.mdx | 15 ++ 18 files changed, 720 insertions(+), 156 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index f38442379d..8060ae94d9 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" -import { usePlatform } from "@/context/platform" +import { usePlatform, type DisplayBackend } from "@/context/platform" +import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" import { monoDefault, monoFontFamily, @@ -40,6 +42,18 @@ type ThemeOption = { name: string } +type ShellOption = { + path: string + name: string + acceptable: boolean +} + +type ShellSelectOption = { + id: string + value: string + label: string +} + // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { @@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => { const params = useParams() const settings = useSettings() - onMount(() => { - void theme.loadThemes() - }) - const [store, setStore] = createStore({ checking: false, }) @@ -165,6 +175,70 @@ export const SettingsGeneral: Component = () => { const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + const globalSync = useGlobalSync() + const globalSdk = useGlobalSDK() + + const [shells] = createResource( + () => + globalSdk.client.pty + .shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), + { initialValue: [] as ShellOption[] }, + ) + + const [displayBackend, { refetch: refetchDisplayBackend }] = createResource( + () => (linux() && platform.getDisplayBackend ? true : false), + () => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null), + { initialValue: null as DisplayBackend | null }, + ) + + onMount(() => { + void theme.loadThemes() + }) + + const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } + const currentShell = createMemo(() => globalSync.data.config.shell ?? "") + + const shellOptions = createMemo(() => { + const list = shells.latest + const current = globalSync.data.config.shell + + const nameCounts = new Map() + for (const s of list) { + nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) + } + + const options = [ + autoOption, + ...list.map((s) => { + const ambiguousName = (nameCounts.get(s.name) || 0) > 1 + const text = ambiguousName ? s.path : s.name + const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` + return { + id: s.path, + // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. + value: ambiguousName ? s.path : s.name, + label, + } + }), + ] + + if (current && !options.some((o) => o.value === current)) { + options.push({ id: current, value: current, label: current }) + } + + return options + }) + + const onDisplayBackendChange = (checked: boolean) => { + const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto") + if (!update) return + void update.finally(() => { + void refetchDisplayBackend() + }) + } + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, { value: "light", label: language.t("theme.scheme.light") }, @@ -243,6 +317,27 @@ export const SettingsGeneral: Component = () => { + +