feat: add server link sharing

This commit is contained in:
Dax Raad 2026-07-08 18:41:08 -04:00
commit 7698a5e6ac
25 changed files with 1586 additions and 1194 deletions

View file

@ -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
? [
{

View 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>
)
}

View file

@ -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>