diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 535003ed1a..6b2746c369 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -46,6 +46,7 @@ export default Runtime.handler(Commands, (input) => yield* run({ server: { endpoint: server.endpoint, + connect: (url, signal) => runServicePromise(ServerConnection.connect(url), { signal }), service: service ? { reconnect: (signal) => runServicePromise(service.reconnect(), { signal }), diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 4eccc95bc9..0dccfda182 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -21,23 +21,7 @@ export type Resolved = { export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) { if (args.server !== undefined && args.standalone) return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) - if (args.server !== undefined) { - const password = yield* Env.password - const endpoint = { - url: args.server, - auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, - } satisfies Endpoint - const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const health = yield* Effect.tryPromise({ - try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), - catch: (cause) => connectError(endpoint, cause), - }) - if (health.version !== InstallationVersion) - process.stderr.write( - `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, - ) - return { endpoint } satisfies Resolved - } + if (args.server !== undefined) return { endpoint: yield* connect(args.server) } satisfies Resolved if (args.standalone) { return { endpoint: yield* Standalone.start() } satisfies Resolved } @@ -49,6 +33,24 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg } satisfies Resolved }) +export const connect = Effect.fn("cli.server-connection.connect")(function* (url: string) { + const password = yield* Env.password + const endpoint = { + url, + auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, + } satisfies Endpoint + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const health = yield* Effect.tryPromise({ + try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), + catch: (cause) => connectError(endpoint, cause), + }) + if (health.version !== InstallationVersion) + process.stderr.write( + `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, + ) + return endpoint +}) + function managedService(options: EnsureOptions) { const reconnectOptions = { ...options, version: undefined } return { @@ -61,10 +63,7 @@ function managedService(options: EnsureOptions) { } } -const resolveManaged = Effect.fnUntraced(function* ( - options: EnsureOptions, - mismatch: NonNullable, -) { +const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable) { if (mismatch === "replace") return yield* Service.ensure(options) if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0690d3fba7..0cffdf84a2 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -81,6 +81,10 @@ import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./ import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap" +import { ServerProvider, decodeServerURLs, useServer, type ServerConnection } from "./context/server" +import { DialogServer } from "./component/dialog-server" +import { readJson, writeJsonAtomic } from "./util/persistence" +import path from "path" import { DialogVariant } from "./component/dialog-variant" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" @@ -94,6 +98,7 @@ registerOpencodeSpinner() const appGlobalBindingCommands = [ "session.list", "session.new", + "server.switch", "session.quick_switch.1", "session.quick_switch.2", "session.quick_switch.3", @@ -142,6 +147,7 @@ const appBindingCommands = [ export type TuiInput = { server: { endpoint: Endpoint + connect: (url: string, signal?: AbortSignal) => Promise service?: { reconnect: (signal: AbortSignal) => Promise restart: () => Promise @@ -182,24 +188,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), { terminalSuspend: process.platform !== "win32", }) - const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) } - const api = OpenCode.make(options) - const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( - Effect.map((response) => response.location.directory), - Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), + const initialAPI = OpenCode.make({ + baseUrl: input.server.endpoint.url, + headers: Service.headers(input.server.endpoint), + }) + yield* Effect.tryPromise(() => initialAPI.file.list({ location: { directory: process.cwd() } })).pipe( + Effect.catch(() => Effect.tryPromise(() => initialAPI.location.get())), ) + const serverFile = path.join(global.state, "tui-servers.json") + const serverURLs = decodeServerURLs(yield* Effect.promise(() => readJson(serverFile).catch(() => undefined))) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined - const managed = input.server.service - const service = managed - ? { - reconnect: async (signal: AbortSignal) => { - const endpoint = await managed.reconnect(signal) - const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } - return { api: OpenCode.make(next) } - }, - restart: managed.restart, - } - : undefined const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { @@ -315,68 +313,36 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }} > - - - - - + + + + { + const api = OpenCode.make({ + baseUrl: endpoint.url, + headers: Service.headers(endpoint), + }) + return api.file + .list({ location: { directory: process.cwd() } }) + .catch(() => api.location.get()) + .then(() => undefined) + }} + save={(servers) => writeJsonAtomic(serverFile, { servers })} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + @@ -405,6 +371,91 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) +type ServerScopeProps = { + input: TuiInput + pluginRuntime: ReturnType + started: number +} + +function ServerScope(props: ServerScopeProps) { + const server = useServer() + let startup = true + return ( + + {(connection) => { + const initial = startup + startup = false + return + }} + + ) +} + +function ServerApp(props: ServerScopeProps & { connection: ServerConnection; startup: boolean }) { + const api = OpenCode.make({ + baseUrl: props.connection.endpoint.url, + headers: Service.headers(props.connection.endpoint), + }) + const managed = props.connection.service + const service = managed + ? { + reconnect: async (signal: AbortSignal) => { + const endpoint = await managed.reconnect(signal) + return { + api: OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }), + } + }, + restart: managed.restart, + } + : undefined + const args = props.startup ? props.input.args : {} + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + function App(props: { pair?: DialogPairCredentials; started: number }) { const log = useLog({ component: "app" }) const startup = useTuiStartup() @@ -766,6 +817,15 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { }, category: "System", }, + { + name: "server.switch", + title: "Switch server", + slash: { name: "servers" }, + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, { name: "server.pair", title: "Pair device", diff --git a/packages/tui/src/component/dialog-server.tsx b/packages/tui/src/component/dialog-server.tsx new file mode 100644 index 0000000000..80de1a559e --- /dev/null +++ b/packages/tui/src/component/dialog-server.tsx @@ -0,0 +1,47 @@ +import { createMemo } from "solid-js" +import { useServer } from "../context/server" +import { DialogSelect } from "../ui/dialog-select" +import { DialogPrompt } from "../ui/dialog-prompt" +import { useDialog } from "../ui/dialog" +import { useToast } from "../ui/toast" + +export function DialogServer() { + const server = useServer() + const dialog = useDialog() + const toast = useToast() + const options = createMemo(() => + server.list().map((item) => ({ + title: item.name, + description: item.url, + value: item.id, + onSelect: () => { + dialog.clear() + void server + .select(item.id) + .then(() => toast.show({ variant: "success", message: `Switched to ${item.name}` }), toast.error) + }, + })), + ) + + function add() { + void DialogPrompt.show(dialog, "Add server", { + placeholder: "https://devbox.example", + description: () => Enter the URL of an OpenCode V2 server., + }).then((value) => { + if (!value) return + dialog.clear() + void server + .add(value) + .then(() => toast.show({ variant: "success", message: `Connected to ${server.current.name}` }), toast.error) + }) + } + + return ( + + ) +} diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index fb31f696be..6c57a06338 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -81,6 +81,8 @@ export const Definitions = { scrollbar_toggle: keybind("none", "Toggle session scrollbar"), status_view: keybind("s", "View status"), debug_view: keybind("none", "View debug info"), + server_switch: keybind("w", "Switch server"), + server_add: keybind("ctrl+a", "Add server"), session_export: keybind("x", "Export session to editor"), session_copy: keybind("none", "Copy session transcript"), @@ -283,6 +285,8 @@ export const CommandMap = { scrollbar_toggle: "session.toggle.scrollbar", status_view: "opencode.status", debug_view: "opencode.debug", + server_switch: "server.switch", + server_add: "server.add", session_export: "session.export", session_copy: "session.copy", session_move: "session.move", diff --git a/packages/tui/src/context/server.tsx b/packages/tui/src/context/server.tsx new file mode 100644 index 0000000000..3af49e8320 --- /dev/null +++ b/packages/tui/src/context/server.tsx @@ -0,0 +1,126 @@ +import type { Endpoint } from "@opencode-ai/client/effect/service" +import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js" + +export type ServerConnection = { + id: string + name: string + url: string + endpoint: Endpoint + service?: { + reconnect: (signal: AbortSignal) => Promise + restart: () => Promise + } +} + +export type ServerInfo = Pick + +type ServerContext = { + readonly current: ServerConnection + list: () => ServerInfo[] + select: (id: string) => Promise + add: (url: string) => Promise +} + +const context = createContext() + +export function ServerProvider( + props: ParentProps<{ + initial: Omit + urls: string[] + connect: (url: string, signal?: AbortSignal) => Promise + prepare: (endpoint: Endpoint) => Promise + save: (urls: string[]) => Promise + }>, +) { + const initialURL = normalizeServerURL(props.initial.endpoint.url) + const initial = { + ...props.initial, + id: initialURL, + name: serverName(initialURL), + url: initialURL, + } + const [current, setCurrent] = createSignal(initial) + const [urls, setURLs] = createSignal( + props.urls.map(normalizeServerURL).filter((url, index, all) => url !== initialURL && all.indexOf(url) === index), + ) + const list = createMemo(() => [ + { id: initial.id, name: initial.name, url: initial.url }, + ...urls().map((url) => ({ id: url, name: serverName(url), url })), + ]) + + async function select(id: string) { + if (id === current().id) return + if (id === initial.id) { + setCurrent(initial) + return + } + const info = list().find((item) => item.id === id) + if (!info) throw new Error(`Unknown server: ${id}`) + const endpoint = await props.connect(info.url) + await props.prepare(endpoint) + setCurrent({ ...info, endpoint }) + } + + async function add(value: string) { + const url = normalizeServerURL(value) + const existing = list().find((item) => item.url === url) + if (existing) return select(existing.id) + const endpoint = await props.connect(url) + await props.prepare(endpoint) + const next = [...urls(), url] + await props.save(next) + setURLs(next) + setCurrent({ id: url, name: serverName(url), url, endpoint }) + } + + return ( + + {props.children} + + ) +} + +export function useServer() { + const value = useContext(context) + if (!value) throw new Error("Server context must be used within a ServerProvider") + return value +} + +export function decodeServerURLs(input: unknown) { + if (!input || typeof input !== "object" || !("servers" in input) || !Array.isArray(input.servers)) return [] + return input.servers.flatMap((item) => { + if (typeof item !== "string") return [] + try { + return [normalizeServerURL(item)] + } catch { + return [] + } + }) +} + +export function normalizeServerURL(value: string) { + const trimmed = value.trim() + const input = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}` + if (!URL.canParse(input)) throw new Error(`Invalid server URL: ${trimmed || value}`) + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Server URL must use HTTP or HTTPS") + if (url.username || url.password) throw new Error("Server URL must not contain credentials") + if (url.search || url.hash) throw new Error("Server URL must not contain a query or fragment") + url.pathname = url.pathname.replace(/\/+$/, "") || "/" + return url.href.replace(/\/$/, "") +} + +export function serverName(value: string) { + const url = new URL(value) + if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return "Local" + return url.host +} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a6cd421ea1..d275c0c2e1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -28,7 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ - server: { endpoint: { url: server.url.toString() } }, + server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, args: {}, @@ -100,7 +100,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after const { run } = await import("../src/app") const task = Effect.runPromise( run({ - server: { endpoint: { url: server.url.toString() } }, + server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, diff --git a/packages/tui/test/context/server.test.tsx b/packages/tui/test/context/server.test.tsx new file mode 100644 index 0000000000..0b51b0b138 --- /dev/null +++ b/packages/tui/test/context/server.test.tsx @@ -0,0 +1,127 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { describe, expect, test } from "bun:test" +import { ServerProvider, decodeServerURLs, normalizeServerURL, serverName, useServer } from "../../src/context/server" + +describe("TUI servers", () => { + test("normalizes equivalent endpoint URLs", () => { + expect(normalizeServerURL(" https://devbox.example/ ")).toBe("https://devbox.example") + expect(normalizeServerURL("http://localhost:4096///")).toBe("http://localhost:4096") + expect(normalizeServerURL("devbox.example:4096")).toBe("http://devbox.example:4096") + expect(() => normalizeServerURL("https://user:secret@devbox.example")).toThrow("must not contain credentials") + expect(() => normalizeServerURL("https://devbox.example?token=secret")).toThrow("query or fragment") + }) + + test("labels loopback and remote servers", () => { + expect(serverName("http://127.0.0.1:49374")).toBe("Local") + expect(serverName("https://devbox.example:4096")).toBe("devbox.example:4096") + }) + + test("decodes only valid persisted URLs", () => { + expect(decodeServerURLs({ servers: ["https://devbox.example", 1, "ftp://nope"] })).toEqual([ + "https://devbox.example", + ]) + expect(decodeServerURLs(null)).toEqual([]) + }) +}) + +test("switches only after the next server is prepared", async () => { + let server!: ReturnType + const steps: string[] = [] + + function Harness() { + server = useServer() + return + } + + const app = await testRender(() => ( + { + steps.push(`connect:${url}`) + return { url } + }} + prepare={async (endpoint) => { + steps.push(`prepare:${endpoint.url}`) + }} + save={async () => {}} + > + + + )) + try { + expect(server.list().map((item) => item.url)).toEqual(["http://localhost:4096", "https://devbox.example"]) + await server.select("https://devbox.example") + expect(steps).toEqual(["connect:https://devbox.example", "prepare:https://devbox.example"]) + expect(server.current.url).toBe("https://devbox.example") + } finally { + app.renderer.destroy() + } +}) + +test("persists only the normalized URL after a successful add", async () => { + let server!: ReturnType + const writes: string[][] = [] + + function Harness() { + server = useServer() + return + } + + const app = await testRender(() => ( + ({ + url, + auth: { type: "basic", username: "opencode", password: "secret" }, + })} + prepare={async () => {}} + save={async (urls) => void writes.push(urls)} + > + + + )) + try { + await server.add("devbox.example:4096/") + expect(writes).toEqual([["http://devbox.example:4096"]]) + expect(JSON.stringify(writes)).not.toContain("secret") + expect(server.current.endpoint.auth?.password).toBe("secret") + } finally { + app.renderer.destroy() + } +}) + +test("keeps the current server when preparing a new endpoint fails", async () => { + let server!: ReturnType + let saved = false + + function Harness() { + server = useServer() + return + } + + const app = await testRender(() => ( + ({ url })} + prepare={async () => { + throw new Error("unreachable") + }} + save={async () => { + saved = true + }} + > + + + )) + try { + await expect(server.add("https://devbox.example")).rejects.toThrow("unreachable") + expect(server.current.url).toBe("http://localhost:4096") + expect(saved).toBeFalse() + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 3885914ebc..9f7c3c2e5d 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -115,3 +115,34 @@ test("global commands stay reachable when the mode changes", async () => { app.renderer.destroy() } }) + +test("server switch has a default global shortcut", async () => { + let shortcut = () => "" + + function Harness() { + const shortcuts = Keymap.useShortcuts() + Keymap.createLayer(() => ({ + mode: "global", + commands: [{ id: "server.switch", run() {} }], + })) + Keymap.createLayer(() => ({ + mode: "global", + bindings: ["server.switch"], + })) + shortcut = () => shortcuts.get("server.switch") ?? "" + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + expect(shortcut()).toBe("ctrl+x w") + } finally { + app.renderer.destroy() + } +})