mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:38:31 +00:00
feat: add server link sharing
This commit is contained in:
parent
ec8eea7cbe
commit
7698a5e6ac
25 changed files with 1533 additions and 1141 deletions
4
bun.lock
4
bun.lock
|
|
@ -119,6 +119,7 @@
|
|||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
|
@ -1016,6 +1017,7 @@
|
|||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
|
@ -5950,6 +5952,8 @@
|
|||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="],
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@
|
|||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2"
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("link", { description: "Show server connection information" }),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
|
|
|
|||
43
packages/cli/src/commands/handlers/link.ts
Normal file
43
packages/cli/src/commands/handlers/link.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.link,
|
||||
Effect.fn("cli.link")(function* () {
|
||||
const transport = yield* Service.start(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const server = yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: transport.url, headers: transport.headers }).server.get(),
|
||||
)
|
||||
const info = { urls: server.urls, username: "opencode", password }
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` URLs ${info.urls[0] ?? "(none)"}`,
|
||||
...info.urls.slice(1).map((url) => ` ${url}`),
|
||||
` Username ${info.username}`,
|
||||
` Password ${info.password}`,
|
||||
"",
|
||||
" Scan to connect",
|
||||
"",
|
||||
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
|
||||
const hostname = new URL(transport.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(
|
||||
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -36,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
link: () => import("./commands/handlers/link"),
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
restart: () => import("./commands/handlers/service/restart"),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function runTui(
|
|||
return yield* run({
|
||||
client: createOpencodeClient({ ...options, directory }),
|
||||
api,
|
||||
link: linkCredentials(transport),
|
||||
discover: discover
|
||||
? async () => {
|
||||
const next = await discover()
|
||||
|
|
@ -64,3 +65,12 @@ export function runTui(
|
|||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
}
|
||||
|
||||
function linkCredentials(transport: Service.Transport) {
|
||||
const authorization = new Headers(transport.headers).get("authorization")
|
||||
if (!authorization?.startsWith("Basic ")) return { username: "opencode", password: "" }
|
||||
const value = atob(authorization.slice("Basic ".length))
|
||||
const separator = value.indexOf(":")
|
||||
if (separator === -1) return { username: "opencode", password: "" }
|
||||
return { username: value.slice(0, separator), password: value.slice(separator + 1) }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
HealthGetOutput,
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
|
|
@ -328,6 +329,13 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
request<ServerGetOutput>(
|
||||
{ method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationGetOutput>(
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
|||
|
||||
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
|
||||
export type ServerGetOutput = { readonly urls: ReadonlyArray<string> }
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
|
||||
expect(Object.keys(client)).toEqual([
|
||||
"health",
|
||||
"server",
|
||||
"location",
|
||||
"agent",
|
||||
"plugin",
|
||||
|
|
@ -45,6 +46,21 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json({ urls: ["http://192.168.1.10:4096"] })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.server.get()).toEqual({ urls: ["http://192.168.1.10:4096"] })
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/server")
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event"
|
|||
import { AgentGroup } from "./groups/agent.js"
|
||||
import { PluginGroup } from "./groups/plugin.js"
|
||||
import { HealthGroup } from "./groups/health.js"
|
||||
import { ServerGroup } from "./groups/server.js"
|
||||
import { DebugGroup } from "./groups/debug.js"
|
||||
import { PtyGroup } from "./groups/pty.js"
|
||||
import { ShellGroup } from "./groups/shell.js"
|
||||
|
|
@ -82,6 +83,7 @@ type ApiGroups<
|
|||
Event extends HttpApiGroup.Any,
|
||||
> =
|
||||
| typeof HealthGroup
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
|
|
@ -143,6 +145,7 @@ const makeApiFromGroup = <
|
|||
> =>
|
||||
HttpApi.make("server")
|
||||
.add(HealthGroup)
|
||||
.add(ServerGroup)
|
||||
.add(LocationGroup.middleware(locationMiddleware))
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
.add(PluginGroup.middleware(locationMiddleware))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({
|
|||
|
||||
export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.server": "server",
|
||||
"server.debug": "debug",
|
||||
"server.location": "location",
|
||||
"server.agent": "agent",
|
||||
|
|
|
|||
16
packages/protocol/src/groups/server.ts
Normal file
16
packages/protocol/src/groups/server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.get", "/api/server", {
|
||||
success: Schema.Struct({ urls: Schema.Array(Schema.String) }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.server.get",
|
||||
summary: "Get server information",
|
||||
description: "Return the URLs that can be used to connect to this server.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "server" }))
|
||||
|
|
@ -362,6 +362,8 @@ import type {
|
|||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2ServerGetErrors,
|
||||
V2ServerGetResponses,
|
||||
V2SessionActiveErrors,
|
||||
V2SessionActiveResponses,
|
||||
V2SessionBackgroundErrors,
|
||||
|
|
@ -5113,6 +5115,20 @@ export class Health extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Server extends HeyApiClient {
|
||||
/**
|
||||
* Get server information
|
||||
*
|
||||
* Return the URLs that can be used to connect to this server.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<V2ServerGetResponses, V2ServerGetErrors, ThrowOnError>({
|
||||
url: "/api/server",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Location extends HeyApiClient {
|
||||
/**
|
||||
* Get location
|
||||
|
|
@ -8259,6 +8275,11 @@ export class V2 extends HeyApiClient {
|
|||
return (this._health ??= new Health({ client: this.client }))
|
||||
}
|
||||
|
||||
private _server?: Server
|
||||
get server(): Server {
|
||||
return (this._server ??= new Server({ client: this.client }))
|
||||
}
|
||||
|
||||
private _location?: Location
|
||||
get location(): Location {
|
||||
return (this._location ??= new Location({ client: this.client }))
|
||||
|
|
|
|||
|
|
@ -15154,6 +15154,37 @@ export type V2HealthGetResponses = {
|
|||
|
||||
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
||||
|
||||
export type V2ServerGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/api/server"
|
||||
}
|
||||
|
||||
export type V2ServerGetErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2ServerGetError = V2ServerGetErrors[keyof V2ServerGetErrors]
|
||||
|
||||
export type V2ServerGetResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
urls: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2ServerGetResponse = V2ServerGetResponses[keyof V2ServerGetResponses]
|
||||
|
||||
export type V2LocationGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event"
|
|||
import { AgentHandler } from "./handlers/agent"
|
||||
import { PluginHandler } from "./handlers/plugin"
|
||||
import { HealthHandler } from "./handlers/health"
|
||||
import { ServerHandler } from "./handlers/server"
|
||||
import { DebugHandler } from "./handlers/debug"
|
||||
import { PtyHandler } from "./handlers/pty"
|
||||
import { ShellHandler } from "./handlers/shell"
|
||||
|
|
@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
|
|||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
ServerHandler,
|
||||
DebugHandler,
|
||||
LocationHandler,
|
||||
AgentHandler,
|
||||
|
|
|
|||
13
packages/server/src/handlers/server.ts
Normal file
13
packages/server/src/handlers/server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
|
||||
handlers.handle("server.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return { urls: info.urls() }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
|||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { createRoutes } from "./routes"
|
||||
import { ServerInfo } from "./server-info"
|
||||
|
||||
export type Options = {
|
||||
readonly hostname: string
|
||||
|
|
@ -48,7 +49,12 @@ function listen(options: Options) {
|
|||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
createRoutes(password).pipe(
|
||||
createRoutes(password, () => {
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === "string") return []
|
||||
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
|
||||
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
|
||||
}).pipe(
|
||||
Layer.flatMap((context) =>
|
||||
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
|
|||
import { layer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
import { ServerInfo } from "./server-info"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
Database.node,
|
||||
|
|
@ -50,11 +51,12 @@ const applicationServices = LayerNode.group([
|
|||
LocationServiceMap.node,
|
||||
])
|
||||
|
||||
export function createRoutes(password?: string) {
|
||||
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
|
||||
return makeRoutes(
|
||||
password
|
||||
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
||||
: ServerAuth.Config.layer,
|
||||
serviceURLs,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +64,10 @@ export function createEmbeddedRoutes() {
|
|||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
serviceURLs: () => ReadonlyArray<string> = () => [],
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
[SessionExecution.node, SessionExecutionLocal.node],
|
||||
|
|
@ -87,7 +92,10 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
|
|||
return serviceLayer.pipe(
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context))
|
||||
const requestServices = Layer.merge(
|
||||
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)),
|
||||
ServerInfo.layer(serviceURLs),
|
||||
)
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||
Layer.provide(formLocationLayer),
|
||||
|
|
|
|||
32
packages/server/src/server-info.ts
Normal file
32
packages/server/src/server-info.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Context, Layer } from "effect"
|
||||
import { networkInterfaces } from "node:os"
|
||||
|
||||
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
|
||||
"@opencode-ai/server/ServerInfo",
|
||||
) {}
|
||||
|
||||
export function layer(urls: () => ReadonlyArray<string>) {
|
||||
return Layer.succeed(Service, Service.of({ urls }))
|
||||
}
|
||||
|
||||
export function connectionURLs(value: string, requestedHostname?: string) {
|
||||
const url = new URL(value)
|
||||
const hostname = requestedHostname ?? url.hostname
|
||||
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
|
||||
if (family === undefined) return [value]
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(networkInterfaces())
|
||||
.flatMap((entries) => entries ?? [])
|
||||
.filter((entry) => !entry.internal && entry.family === family)
|
||||
.map((entry) => {
|
||||
const result = new URL(value)
|
||||
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
|
||||
return result.toString().replace(/\/$/, "")
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export * as ServerInfo from "./server-info"
|
||||
|
|
@ -69,8 +69,9 @@
|
|||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"solid-js": "catalog:"
|
||||
"uqr": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { useConnected } from "./component/use-connected"
|
|||
import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
|
|
@ -121,6 +122,7 @@ const appBindingCommands = [
|
|||
"provider.connect",
|
||||
"console.org.switch",
|
||||
"opencode.status",
|
||||
"server.link",
|
||||
"opencode.debug",
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
|
|
@ -146,6 +148,7 @@ export type TuiInput = {
|
|||
api: OpenCodeClient
|
||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
reload?: () => Promise<void>
|
||||
link?: DialogLinkCredentials
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
|
|
@ -335,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
link={input.link}
|
||||
/>
|
||||
</LocationProvider>
|
||||
</EditorContextProvider>
|
||||
|
|
@ -380,7 +384,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
})
|
||||
})
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
|
||||
function App(props: {
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
pluginHost: TuiPluginHost
|
||||
link?: DialogLinkCredentials
|
||||
}) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
const tuiConfig = useTuiConfig()
|
||||
|
|
@ -813,6 +821,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "server.link",
|
||||
title: "Show server connection information",
|
||||
slashName: "link",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogLink credentials={props.link} />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(sdk.reload
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
126
packages/tui/src/component/dialog-link.tsx
Normal file
126
packages/tui/src/component/dialog-link.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export type DialogLinkCredentials = {
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export function DialogLink(props: { credentials?: DialogLinkCredentials }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
|
||||
dialog.setSize("large")
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
sdk.client.v2.server
|
||||
.get({ throwOnError: true })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
const info = createMemo(() => {
|
||||
const current = server()
|
||||
if (!current) return
|
||||
return {
|
||||
urls: current.urls,
|
||||
username: props.credentials?.username ?? "opencode",
|
||||
password: props.credentials?.password ?? "",
|
||||
}
|
||||
})
|
||||
const horizontal = createMemo(() => dimensions().width >= 96)
|
||||
const content = () => {
|
||||
const value = info()
|
||||
if (!value) return
|
||||
return (
|
||||
<box
|
||||
flexDirection={horizontal() ? "row" : "column"}
|
||||
alignItems={horizontal() ? "flex-start" : "center"}
|
||||
gap={2}
|
||||
>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={theme.text}>{url}</text>}</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>Username</text>
|
||||
<text fg={theme.text}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? theme.text : theme.textMuted}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
onMouseUp={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword() ? value.password : "************"}
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
|
||||
>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box
|
||||
width={horizontal() ? undefined : "100%"}
|
||||
flexGrow={horizontal() ? 1 : 0}
|
||||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={theme.text}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Link
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={loadError()}>
|
||||
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>}
|
||||
</Show>
|
||||
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
<scrollbox
|
||||
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
{content()}
|
||||
</scrollbox>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
|||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
centered?: boolean
|
||||
onClose: () => void
|
||||
}>,
|
||||
) {
|
||||
|
|
@ -40,9 +41,10 @@ export function Dialog(
|
|||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
justifyContent={props.centered ? "center" : undefined}
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
paddingTop={props.centered ? 0 : dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
|
|
@ -73,6 +75,7 @@ function init() {
|
|||
onClose?: () => void
|
||||
}[],
|
||||
size: "medium" as "medium" | "large" | "xlarge",
|
||||
centered: false,
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -143,6 +146,7 @@ function init() {
|
|||
}
|
||||
batch(() => {
|
||||
setStore("size", "medium")
|
||||
setStore("centered", false)
|
||||
setStore("stack", [])
|
||||
})
|
||||
refocus()
|
||||
|
|
@ -156,6 +160,7 @@ function init() {
|
|||
if (item.onClose) item.onClose()
|
||||
}
|
||||
setStore("size", "medium")
|
||||
setStore("centered", false)
|
||||
setStore("stack", [
|
||||
{
|
||||
element: input,
|
||||
|
|
@ -169,9 +174,15 @@ function init() {
|
|||
get size() {
|
||||
return store.size
|
||||
},
|
||||
get centered() {
|
||||
return store.centered
|
||||
},
|
||||
setSize(size: "medium" | "large" | "xlarge") {
|
||||
setStore("size", size)
|
||||
},
|
||||
setCentered(centered: boolean) {
|
||||
setStore("centered", centered)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +224,7 @@ export function DialogProvider(props: ParentProps) {
|
|||
onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
{value.stack.at(-1)!.element}
|
||||
</Dialog>
|
||||
</Show>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue