From 7c18b95826ad1372a31632dd7d5e6054b2587b17 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Wed, 8 Apr 2026 21:01:32 +0000 Subject: [PATCH] feat(opencode): add mobile pairing dialogs --- .../app/src/components/dialog-settings.tsx | 17 ++- packages/app/src/components/settings-pair.tsx | 92 +++++++++++++ packages/app/src/i18n/en.ts | 12 ++ packages/app/src/pages/layout.tsx | 11 +- packages/opencode/src/cli/cmd/tui/app.tsx | 12 ++ .../src/cli/cmd/tui/component/dialog-pair.tsx | 126 ++++++++++++++++++ .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 2 +- .../src/server/routes/experimental.ts | 3 + 8 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/components/settings-pair.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5..ce2ef3538c 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -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 ( - +
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => { {language.t("settings.models.title")} + + + {language.t("settings.pair.title")} +
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => { + + +
) diff --git a/packages/app/src/components/settings-pair.tsx b/packages/app/src/components/settings-pair.tsx new file mode 100644 index 0000000000..7a7b69ce8b --- /dev/null +++ b/packages/app/src/components/settings-pair.tsx @@ -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 ( +
+
+

{language.t("settings.pair.title")}

+

{language.t("settings.pair.description")}

+
+ + + +
+ {language.t("settings.pair.loading")} +
+
+
+ + + +
+ +
+ {language.t("settings.pair.error.title")} + + {language.t("settings.pair.error.description")} + +
+
+
+
+ + + {(result) => ( + +
+ +
+ {language.t("settings.pair.disabled.title")} + + {language.t("settings.pair.disabled.description")} + +
+ + opencode serve --relay-url <url> --relay-secret <secret> + +
+ + } + > + {(pair) => ( + +
+ Pairing QR code +
+ + {language.t("settings.pair.instructions.title")} + + + {language.t("settings.pair.instructions.description")} + +
+
+
+ )} +
+ )} +
+
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c6bcc37b11..4144b1fc3a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f402f4bc04..b336def4b3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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(() => ) + dialog.show(() => ) }) } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4161c025c1..e50d464290 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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 }) { }, category: "System", }, + { + title: "Pair mobile device", + value: "pair.show", + slash: { + name: "pair", + }, + onSelect: () => { + dialog.replace(() => ) + }, + category: "System", + }, { title: "Help", value: "help.show", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx new file mode 100644 index 0000000000..90e5dd530d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx @@ -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 ( + + + + Pair Mobile Device + + dialog.clear()}> + esc + + + + Loading pairing info... + + + + Could not load pairing info. + + Check that the server is reachable and try again. + + + + + {(result) => ( + + Push relay is not enabled. + + Start the server with push relay options to enable mobile pairing: + + + opencode serve --relay-url <url> --relay-secret <secret> + + + } + > + {(pair) => ( + + {(pair() as any).qrText} + + + Scan with the OpenCode Control app + + + to pair your device for push notifications. + + + + )} + + )} + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 11c43fe24c..9d83d1c052 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -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)} diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c37f7a4975..7dcc4c3eae 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -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, }) },