feat(opencode): add mobile pairing dialogs

This commit is contained in:
Ryan Vogel
2026-04-08 21:01:32 +00:00
parent 381afd6e10
commit 7c18b95826
8 changed files with 270 additions and 5 deletions
@@ -8,14 +8,20 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsPair } from "./settings-pair"
export const DialogSettings: Component = () => {
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
defaultValue={props.defaultTab ?? "general"}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full pt-3">
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => {
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="pair">
<Icon name="link" />
{language.t("settings.pair.title")}
</Tabs.Trigger>
</div>
</div>
</div>
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="pair" class="no-scrollbar">
<SettingsPair />
</Tabs.Content>
</Tabs>
</Dialog>
)
@@ -0,0 +1,92 @@
import { type Component, createResource, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { SettingsList } from "./settings-list"
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
export const SettingsPair: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const [data] = createResource(async () => {
const f = platform.fetch ?? fetch
const res = await f(`${globalSDK.url}/experimental/push/pair`)
if (!res.ok) return { enabled: false as const }
return (await res.json()) as PairResult
})
return (
<div class="flex flex-col gap-6 py-4 px-5">
<div class="flex flex-col gap-1">
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
</div>
<Show when={data.loading}>
<SettingsList>
<div class="flex items-center justify-center py-12">
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
</div>
</SettingsList>
</Show>
<Show when={data.error}>
<SettingsList>
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Icon name="warning" size="large" />
<div class="flex flex-col gap-1">
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
<span class="text-13-regular text-text-weak max-w-md">
{language.t("settings.pair.error.description")}
</span>
</div>
</div>
</SettingsList>
</Show>
<Show when={!data.loading && !data.error && data()}>
{(result) => (
<Show
when={result().enabled && result()}
fallback={
<SettingsList>
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Icon name="link" size="large" />
<div class="flex flex-col gap-1">
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
<span class="text-13-regular text-text-weak max-w-md">
{language.t("settings.pair.disabled.description")}
</span>
</div>
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
</code>
</div>
</SettingsList>
}
>
{(pair) => (
<SettingsList>
<div class="flex flex-col items-center py-8 gap-4">
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
<div class="flex flex-col gap-1 text-center max-w-sm">
<span class="text-14-medium text-text-strong">
{language.t("settings.pair.instructions.title")}
</span>
<span class="text-13-regular text-text-weak">
{language.t("settings.pair.instructions.description")}
</span>
</div>
</div>
</SettingsList>
)}
</Show>
)}
</Show>
</div>
)
}
+12
View File
@@ -28,6 +28,7 @@ export const dict = {
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
"command.pair.show": "Pair mobile device",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
@@ -857,6 +858,17 @@ export const dict = {
"settings.providers.tag.config": "Config",
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.pair.title": "Pair",
"settings.pair.description": "Pair a mobile device for push notifications.",
"settings.pair.loading": "Loading pairing info...",
"settings.pair.error.title": "Could not load pairing info",
"settings.pair.error.description": "Check that the server is reachable and try again.",
"settings.pair.disabled.title": "Push relay is not enabled",
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
"settings.pair.instructions.description":
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents",
+9 -2
View File
@@ -1058,6 +1058,13 @@ export default function Layout(props: ParentProps) {
keybind: "mod+comma",
onSelect: () => openSettings(),
},
{
id: "pair.show",
title: language.t("command.pair.show"),
category: language.t("command.category.settings"),
slash: "pair",
onSelect: () => openSettings("pair"),
},
{
id: "session.previous",
title: language.t("command.session.previous"),
@@ -1210,11 +1217,11 @@ export default function Layout(props: ParentProps) {
})
}
function openSettings() {
function openSettings(defaultTab?: string) {
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
})
}
+12
View File
@@ -32,6 +32,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogPair } from "@tui/component/dialog-pair"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
@@ -691,6 +692,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
title: "Pair mobile device",
value: "pair.show",
slash: {
name: "pair",
},
onSelect: () => {
dialog.replace(() => <DialogPair />)
},
category: "System",
},
{
title: "Help",
value: "help.show",
@@ -0,0 +1,126 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { createResource, onMount, Show } from "solid-js"
import * as QRCode from "qrcode"
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
const BLOCK = {
WW: " ",
WB: "▄",
BB: "█",
BW: "▀",
}
function renderQR(link: string): string {
const qr = QRCode.create(link, { errorCorrectionLevel: "L" })
const size = qr.modules.size
const data = qr.modules.data
const margin = 2
const get = (r: number, c: number) => {
if (r < 0 || r >= size || c < 0 || c >= size) return false
return Boolean(data[r * size + c])
}
const totalW = size + margin * 2
const blank = BLOCK.WW.repeat(totalW)
const lines: string[] = []
// top margin
for (let i = 0; i < margin / 2; i++) lines.push(blank)
// QR rows, 2 at a time using half-block chars
for (let r = -margin; r < size + margin; r += 2) {
let row = ""
for (let c = -margin; c < size + margin; c++) {
const top = get(r, c)
const bottom = get(r + 1, c)
if (top && bottom) row += BLOCK.BB
else if (top) row += BLOCK.BW
else if (bottom) row += BLOCK.WB
else row += BLOCK.WW
}
lines.push(row)
}
return lines.join("\n")
}
export function DialogPair() {
const dialog = useDialog()
const { theme } = useTheme()
const sdk = useSDK()
onMount(() => {
dialog.setSize("large")
})
const [data] = createResource(async () => {
const res = await sdk.fetch(`${sdk.url}/experimental/push/pair`)
if (!res.ok) return { enabled: false as const }
const json = (await res.json()) as PairResult
if (!json.enabled) return json
const qrText = renderQR(json.link)
return { ...json, qrText }
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Pair Mobile Device
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={data.loading}>
<text fg={theme.textMuted}>Loading pairing info...</text>
</Show>
<Show when={data.error}>
<box gap={1}>
<text fg={theme.error}>Could not load pairing info.</text>
<text fg={theme.textMuted} wrapMode="word">
Check that the server is reachable and try again.
</text>
</box>
</Show>
<Show when={!data.loading && !data.error && data()}>
{(result) => (
<Show
when={result().enabled && result()}
fallback={
<box gap={1}>
<text fg={theme.warning}>Push relay is not enabled.</text>
<text fg={theme.textMuted} wrapMode="word">
Start the server with push relay options to enable mobile pairing:
</text>
<text fg={theme.text} wrapMode="word">
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
</text>
</box>
}
>
{(pair) => (
<box gap={1} alignItems="center">
<text fg={theme.text}>{(pair() as any).qrText}</text>
<box gap={0} alignItems="center">
<text fg={theme.textMuted} wrapMode="word">
Scan with the OpenCode Control app
</text>
<text fg={theme.textMuted} wrapMode="word">
to pair your device for push notifications.
</text>
</box>
</box>
)}
</Show>
)}
</Show>
</box>
)
}
@@ -39,9 +39,9 @@ export function Dialog(
width={dimensions().width}
height={dimensions().height}
alignItems="center"
justifyContent="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
@@ -37,6 +37,7 @@ const PushPairResult = z
z.object({
enabled: z.literal(true),
hosts: z.array(z.string()),
link: z.string(),
qr: z.string(),
}),
])
@@ -440,11 +441,13 @@ export const ExperimentalRoutes = lazy(() =>
})
}
const link = pushPairLink(pair)
const qr = await pushPairQRCode(pair)
return c.json({
enabled: true,
hosts: pair.hosts,
link,
qr,
})
},