Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/commands/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
41 changes: 20 additions & 21 deletions packages/cli/src/services/server-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -61,10 +63,7 @@ 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 })

Expand Down
214 changes: 137 additions & 77 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -142,6 +147,7 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Endpoint
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
service?: {
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
Expand Down Expand Up @@ -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<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* () {
Expand Down Expand Up @@ -315,68 +313,36 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}}
>
<ClipboardProvider>
<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
}
<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 })}
>
<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>
<ServerScope input={input} pluginRuntime={pluginRuntime} started={appStarted} />
</ServerProvider>
</ThemeProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
Expand Down Expand Up @@ -405,6 +371,91 @@ 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()
Expand Down Expand Up @@ -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(() => <DialogServer />)
},
category: "System",
},
{
name: "server.pair",
title: "Pair device",
Expand Down
47 changes: 47 additions & 0 deletions packages/tui/src/component/dialog-server.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <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 }]}
/>
)
}
Loading
Loading