Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Vogel ee66e11c27 feat(cli): add saved remote servers 2026-07-18 19:15:58 +00:00
20 changed files with 339 additions and 501 deletions
+26
View File
@@ -12,6 +12,11 @@ const ServerParams = {
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
remote: Flag.string("remote").pipe(
Flag.withAlias("r"),
Flag.withDescription("Connect to a saved remote server"),
Flag.optional,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
@@ -201,6 +206,27 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("server", {
description: "Manage saved server connections",
commands: [
Spec.make("list", { description: "List saved server connections" }),
Spec.make("add", {
description: "Save a server connection (and OPENCODE_PASSWORD when set)",
params: {
name: Argument.string("name").pipe(Argument.withDescription("Name for the saved server")),
url: Argument.string("url").pipe(Argument.withDescription("Server URL")),
username: Flag.string("username").pipe(
Flag.withDescription("Basic authentication username"),
Flag.optional,
),
},
}),
Spec.make("remove", {
description: "Remove a saved server connection",
params: { name: Argument.string("name").pipe(Argument.withDescription("Saved server name")) },
}),
],
}),
Spec.make("service", {
description: "Manage the background server",
commands: [
@@ -20,6 +20,7 @@ export default Runtime.handler(
Effect.fn("cli.api")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
remote: Option.getOrUndefined(input.remote),
standalone: input.standalone,
mismatch: "ignore",
})
@@ -20,6 +20,7 @@ export default Runtime.handler(Commands, (input) =>
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
remote: Option.getOrUndefined(input.remote),
standalone: input.standalone,
onStart: (reason, previousVersion) => {
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
@@ -46,7 +47,6 @@ 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 }),
+5 -1
View File
@@ -8,7 +8,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const server = yield* ServerConnection.resolve({
server: serverURL,
remote: Option.getOrUndefined(input.remote),
standalone: input.standalone,
})
yield* Effect.promise(() =>
runMini({
server,
@@ -9,6 +9,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
const separator = process.argv.indexOf("--", 2)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
remote: Option.getOrUndefined(input.remote),
standalone: input.standalone,
})
yield* Effect.promise(() =>
@@ -0,0 +1,23 @@
import { Effect, Option, Redacted } from "effect"
import { Commands } from "../../commands"
import { Config } from "../../../config"
import { Env } from "../../../env"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.server.commands.add,
Effect.fn("cli.server.add")(function* (input) {
if (!URL.canParse(input.url)) return yield* Effect.fail(new Error(`Invalid server URL: ${input.url}`))
const config = yield* Config.Service
const password = yield* Env.password
yield* config.update((draft) => {
draft.servers ??= {}
draft.servers[input.name] = {
url: input.url,
username: Option.getOrUndefined(input.username),
password: password ? Redacted.value(password) : undefined,
}
})
process.stdout.write(`Saved server ${input.name}\n`)
}),
)
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import { Commands } from "../../commands"
import { Config } from "../../../config"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.server.commands.list,
Effect.fn("cli.server.list")(function* () {
const config = yield* Config.Service
const servers = Object.entries((yield* config.get()).servers ?? {})
if (!servers.length) {
process.stdout.write("No saved servers\n")
return
}
process.stdout.write(
servers
.map(([name, server]) => `${name}\t${server.url}${server.username ? `\t${server.username}` : ""}`)
.join("\n") + "\n",
)
}),
)
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import { Commands } from "../../commands"
import { Config } from "../../../config"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.server.commands.remove,
Effect.fn("cli.server.remove")(function* (input) {
const config = yield* Config.Service
if ((yield* config.get()).servers?.[input.name] === undefined)
return yield* Effect.fail(new Error(`Saved server "${input.name}" not found`))
yield* config.update((draft) => {
if (!draft.servers) return
delete draft.servers[input.name]
if (!Object.keys(draft.servers).length) delete draft.servers
})
process.stdout.write(`Removed server ${input.name}\n`)
}),
)
+11 -1
View File
@@ -1,5 +1,15 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export const Server = Schema.Struct({
url: Schema.String,
username: Schema.optional(Schema.String),
password: Schema.optional(Schema.String),
})
export type Server = Schema.Schema.Type<typeof Server>
export const Info = Schema.Struct({
...Config.Info.fields,
servers: Schema.optional(Schema.Record(Schema.String, Server)),
})
export type Info = Schema.Schema.Type<typeof Info>
+5
View File
@@ -38,6 +38,11 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
server: {
list: () => import("./commands/handlers/server/list"),
add: () => import("./commands/handlers/server/add"),
remove: () => import("./commands/handlers/server/remove"),
},
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
+40 -22
View File
@@ -2,12 +2,14 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Redacted } from "effect"
import { Config } from "../config"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
import { Standalone } from "./standalone"
export type Args = {
readonly server?: string
readonly remote?: string
readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error"
readonly onStart?: EnsureOptions["onStart"]
@@ -19,9 +21,40 @@ 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) return { endpoint: yield* connect(args.server) } satisfies Resolved
if (args.server !== undefined && args.remote !== undefined)
return yield* Effect.fail(new Error("--server and --remote cannot be combined"))
if ((args.server !== undefined || args.remote !== undefined) && args.standalone)
return yield* Effect.fail(new Error("--server, --remote, and --standalone cannot be combined"))
if (args.server !== undefined || args.remote !== undefined) {
const config = yield* Config.Service
const profile = args.remote === undefined ? undefined : (yield* config.get()).servers?.[args.remote]
if (args.remote !== undefined && profile === undefined)
return yield* Effect.fail(new Error(`Saved server "${args.remote}" not found`))
const environmentPassword = yield* Env.password
const password = environmentPassword ? Redacted.value(environmentPassword) : profile?.password
const url = profile?.url ?? args.server
if (url === undefined) return yield* Effect.fail(new Error("Missing server URL"))
const endpoint = {
url,
auth: password
? {
type: "basic" as const,
username: profile?.username ?? "opencode",
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.standalone) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
@@ -33,24 +66,6 @@ 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 {
@@ -63,7 +78,10 @@ function managedService(options: EnsureOptions) {
}
}
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
const resolveManaged = Effect.fnUntraced(function* (
options: EnsureOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+48 -2
View File
@@ -6,6 +6,7 @@ import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Config } from "../src/config"
import { ServerConnection } from "../src/services/server-connection"
import { ServiceConfig } from "../src/services/service-config"
@@ -24,8 +25,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed
})
const registration = path.join(root, "state", ServiceConfig.filename())
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
const runPromise = <A, E>(
effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope | Config.Service>,
) =>
Effect.runPromise(
effect.pipe(
Effect.provide(Config.layer),
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
try {
await fs.mkdir(path.dirname(registration), { recursive: true })
@@ -50,8 +60,44 @@ test("resolution groups Effect-native lifecycle operations only for the managed
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
expect(explicit.endpoint.url).toBe(server.url.toString())
expect(explicit.service).toBeUndefined()
await runPromise(
Effect.gen(function* () {
const config = yield* Config.Service
yield* config.update((draft) => {
draft.servers = {
mac: { url: server.url.toString(), username: "ryan", password: "secret" },
}
})
}),
)
const saved = await runPromise(ServerConnection.resolve({ remote: "mac" }))
expect(saved.endpoint).toEqual({
url: server.url.toString(),
auth: { type: "basic", username: "ryan", password: "secret" },
})
expect(saved.service).toBeUndefined()
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
test("reports an unknown saved server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
try {
const result = await Effect.runPromise(
Effect.flip(ServerConnection.resolve({ remote: "missing" })).pipe(
Effect.provide(Config.layer),
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
expect(result.message).toBe('Saved server "missing" not found')
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const directories: string[] = []
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })))
})
async function cli(
args: string[],
options: { directory?: string; environment?: Record<string, string> } = {},
) {
const directory = options.directory ?? (await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-")))
if (!options.directory) directories.push(directory)
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, OPENCODE_CONFIG_DIR: directory, ...options.environment },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { directory, stdout, stderr, exitCode }
}
test("adds, lists, and removes a saved server", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-shared-"))
directories.push(directory)
const added = await cli(["server", "add", "mac", "http://127.0.0.1:4096", "--username", "ryan"], {
directory,
environment: { OPENCODE_PASSWORD: "secret" },
})
expect(added).toMatchObject({ exitCode: 0, stdout: "Saved server mac\n" })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toMatchObject({
servers: { mac: { url: "http://127.0.0.1:4096", username: "ryan", password: "secret" } },
})
const listed = await cli(["server", "list"], { directory })
expect(listed).toMatchObject({ exitCode: 0, stdout: "mac\thttp://127.0.0.1:4096\tryan\n" })
expect(listed.stdout).not.toContain("secret")
const removed = await cli(["server", "remove", "mac"], { directory })
expect(removed).toMatchObject({ exitCode: 0, stdout: "Removed server mac\n" })
expect(await Bun.file(path.join(directory, "cli.json")).json()).not.toHaveProperty("servers")
})
test("exposes a remote shorthand without replacing the session shorthand", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--remote, -r string")
expect(result.stdout).toContain("--session, -s string")
})
+77 -137
View File
@@ -81,10 +81,6 @@ 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"
@@ -98,7 +94,6 @@ registerOpencodeSpinner()
const appGlobalBindingCommands = [
"session.list",
"session.new",
"server.switch",
"session.quick_switch.1",
"session.quick_switch.2",
"session.quick_switch.3",
@@ -147,7 +142,6 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Endpoint
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
service?: {
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
@@ -188,16 +182,24 @@ 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 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 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 serverFile = path.join(global.state, "tui-servers.json")
const serverURLs = decodeServerURLs(yield* Effect.promise(() => readJson<unknown>(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* () {
@@ -313,36 +315,68 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}}
>
<ClipboardProvider>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode={mode}>
<ServerProvider
initial={{ endpoint: input.server.endpoint, service: input.server.service }}
urls={serverURLs}
connect={input.server.connect}
prepare={(endpoint) => {
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 })}
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ServerScope input={input} pluginRuntime={pluginRuntime} started={appStarted} />
</ServerProvider>
</ThemeProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
started={appStarted}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
@@ -371,91 +405,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
type ServerScopeProps = {
input: TuiInput
pluginRuntime: ReturnType<typeof createPluginRuntime>
started: number
}
function ServerScope(props: ServerScopeProps) {
const server = useServer()
let startup = true
return (
<Show when={server.current} keyed>
{(connection) => {
const initial = startup
startup = false
return <ServerApp {...props} connection={connection} startup={initial} />
}}
</Show>
)
}
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 (
<ArgsProvider {...args}>
<RouteProvider
initialRoute={
args.continue ? { type: "session", sessionID: "dummy" } : props.startup ? undefined : { type: "home" }
}
>
<PluginRuntimeProvider value={props.pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={props.input.packages}>
<App
started={props.started}
pair={
props.connection.endpoint.auth ?? {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ArgsProvider>
)
}
function App(props: { pair?: DialogPairCredentials; started: number }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
@@ -817,15 +766,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
},
category: "System",
},
{
name: "server.switch",
title: "Switch server",
slash: { name: "servers" },
run: () => {
dialog.replace(() => <DialogServer />)
},
category: "System",
},
{
name: "server.pair",
title: "Pair device",
@@ -1,47 +0,0 @@
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: () => <text>Enter the URL of an OpenCode V2 server.</text>,
}).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 (
<DialogSelect
title="Switch server"
options={options()}
current={server.current.id}
actions={[{ command: "server.add", title: "Add server", selection: "none", onTrigger: add }]}
/>
)
}
-4
View File
@@ -81,8 +81,6 @@ export const Definitions = {
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
debug_view: keybind("none", "View debug info"),
server_switch: keybind("<leader>w", "Switch server"),
server_add: keybind("ctrl+a", "Add server"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
@@ -285,8 +283,6 @@ 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",
-126
View File
@@ -1,126 +0,0 @@
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<Endpoint>
restart: () => Promise<void>
}
}
export type ServerInfo = Pick<ServerConnection, "id" | "name" | "url">
type ServerContext = {
readonly current: ServerConnection
list: () => ServerInfo[]
select: (id: string) => Promise<void>
add: (url: string) => Promise<void>
}
const context = createContext<ServerContext>()
export function ServerProvider(
props: ParentProps<{
initial: Omit<ServerConnection, "id" | "name" | "url">
urls: string[]
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
prepare: (endpoint: Endpoint) => Promise<void>
save: (urls: string[]) => Promise<void>
}>,
) {
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 (
<context.Provider
value={{
get current() {
return current()
},
list,
select,
add,
}}
>
{props.children}
</context.Provider>
)
}
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
}
+2 -2
View File
@@ -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() }, connect: async (url) => ({ url }) },
server: { endpoint: { url: server.url.toString() } },
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() }, connect: async (url) => ({ url }) },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" },
-127
View File
@@ -1,127 +0,0 @@
/** @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<typeof useServer>
const steps: string[] = []
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={["https://devbox.example", "http://localhost:4096/"]}
connect={async (url) => {
steps.push(`connect:${url}`)
return { url }
}}
prepare={async (endpoint) => {
steps.push(`prepare:${endpoint.url}`)
}}
save={async () => {}}
>
<Harness />
</ServerProvider>
))
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<typeof useServer>
const writes: string[][] = []
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={[]}
connect={async (url) => ({
url,
auth: { type: "basic", username: "opencode", password: "secret" },
})}
prepare={async () => {}}
save={async (urls) => void writes.push(urls)}
>
<Harness />
</ServerProvider>
))
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<typeof useServer>
let saved = false
function Harness() {
server = useServer()
return <box />
}
const app = await testRender(() => (
<ServerProvider
initial={{ endpoint: { url: "http://localhost:4096" } }}
urls={[]}
connect={async (url) => ({ url })}
prepare={async () => {
throw new Error("unreachable")
}}
save={async () => {
saved = true
}}
>
<Harness />
</ServerProvider>
))
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()
}
})
-31
View File
@@ -115,34 +115,3 @@ 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 <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
expect(shortcut()).toBe("ctrl+x w")
} finally {
app.renderer.destroy()
}
})