Skip to content

Commit 7c18b95

Browse files
author
Ryan Vogel
committed
feat(opencode): add mobile pairing dialogs
1 parent 381afd6 commit 7c18b95

8 files changed

Lines changed: 270 additions & 5 deletions

File tree

packages/app/src/components/dialog-settings.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ import { SettingsGeneral } from "./settings-general"
88
import { SettingsKeybinds } from "./settings-keybinds"
99
import { SettingsProviders } from "./settings-providers"
1010
import { SettingsModels } from "./settings-models"
11+
import { SettingsPair } from "./settings-pair"
1112

12-
export const DialogSettings: Component = () => {
13+
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
1314
const language = useLanguage()
1415
const platform = usePlatform()
1516

1617
return (
1718
<Dialog size="x-large" transition>
18-
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
19+
<Tabs
20+
orientation="vertical"
21+
variant="settings"
22+
defaultValue={props.defaultTab ?? "general"}
23+
class="h-full settings-dialog"
24+
>
1925
<Tabs.List>
2026
<div class="flex flex-col justify-between h-full w-full">
2127
<div class="flex flex-col gap-3 w-full pt-3">
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => {
4551
<Icon name="models" />
4652
{language.t("settings.models.title")}
4753
</Tabs.Trigger>
54+
<Tabs.Trigger value="pair">
55+
<Icon name="link" />
56+
{language.t("settings.pair.title")}
57+
</Tabs.Trigger>
4858
</div>
4959
</div>
5060
</div>
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => {
6777
<Tabs.Content value="models" class="no-scrollbar">
6878
<SettingsModels />
6979
</Tabs.Content>
80+
<Tabs.Content value="pair" class="no-scrollbar">
81+
<SettingsPair />
82+
</Tabs.Content>
7083
</Tabs>
7184
</Dialog>
7285
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { type Component, createResource, Show } from "solid-js"
2+
import { Icon } from "@opencode-ai/ui/icon"
3+
import { useLanguage } from "@/context/language"
4+
import { useGlobalSDK } from "@/context/global-sdk"
5+
import { usePlatform } from "@/context/platform"
6+
import { SettingsList } from "./settings-list"
7+
8+
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
9+
10+
export const SettingsPair: Component = () => {
11+
const language = useLanguage()
12+
const globalSDK = useGlobalSDK()
13+
const platform = usePlatform()
14+
15+
const [data] = createResource(async () => {
16+
const f = platform.fetch ?? fetch
17+
const res = await f(`${globalSDK.url}/experimental/push/pair`)
18+
if (!res.ok) return { enabled: false as const }
19+
return (await res.json()) as PairResult
20+
})
21+
22+
return (
23+
<div class="flex flex-col gap-6 py-4 px-5">
24+
<div class="flex flex-col gap-1">
25+
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
26+
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
27+
</div>
28+
29+
<Show when={data.loading}>
30+
<SettingsList>
31+
<div class="flex items-center justify-center py-12">
32+
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
33+
</div>
34+
</SettingsList>
35+
</Show>
36+
37+
<Show when={data.error}>
38+
<SettingsList>
39+
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
40+
<Icon name="warning" size="large" />
41+
<div class="flex flex-col gap-1">
42+
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
43+
<span class="text-13-regular text-text-weak max-w-md">
44+
{language.t("settings.pair.error.description")}
45+
</span>
46+
</div>
47+
</div>
48+
</SettingsList>
49+
</Show>
50+
51+
<Show when={!data.loading && !data.error && data()}>
52+
{(result) => (
53+
<Show
54+
when={result().enabled && result()}
55+
fallback={
56+
<SettingsList>
57+
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
58+
<Icon name="link" size="large" />
59+
<div class="flex flex-col gap-1">
60+
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
61+
<span class="text-13-regular text-text-weak max-w-md">
62+
{language.t("settings.pair.disabled.description")}
63+
</span>
64+
</div>
65+
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
66+
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
67+
</code>
68+
</div>
69+
</SettingsList>
70+
}
71+
>
72+
{(pair) => (
73+
<SettingsList>
74+
<div class="flex flex-col items-center py-8 gap-4">
75+
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
76+
<div class="flex flex-col gap-1 text-center max-w-sm">
77+
<span class="text-14-medium text-text-strong">
78+
{language.t("settings.pair.instructions.title")}
79+
</span>
80+
<span class="text-13-regular text-text-weak">
81+
{language.t("settings.pair.instructions.description")}
82+
</span>
83+
</div>
84+
</div>
85+
</SettingsList>
86+
)}
87+
</Show>
88+
)}
89+
</Show>
90+
</div>
91+
)
92+
}

packages/app/src/i18n/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const dict = {
2828
"command.provider.connect": "Connect provider",
2929
"command.server.switch": "Switch server",
3030
"command.settings.open": "Open settings",
31+
"command.pair.show": "Pair mobile device",
3132
"command.session.previous": "Previous session",
3233
"command.session.next": "Next session",
3334
"command.session.previous.unseen": "Previous unread session",
@@ -857,6 +858,17 @@ export const dict = {
857858
"settings.providers.tag.config": "Config",
858859
"settings.providers.tag.custom": "Custom",
859860
"settings.providers.tag.other": "Other",
861+
"settings.pair.title": "Pair",
862+
"settings.pair.description": "Pair a mobile device for push notifications.",
863+
"settings.pair.loading": "Loading pairing info...",
864+
"settings.pair.error.title": "Could not load pairing info",
865+
"settings.pair.error.description": "Check that the server is reachable and try again.",
866+
"settings.pair.disabled.title": "Push relay is not enabled",
867+
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
868+
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
869+
"settings.pair.instructions.description":
870+
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
871+
860872
"settings.models.title": "Models",
861873
"settings.models.description": "Model settings will be configurable here.",
862874
"settings.agents.title": "Agents",

packages/app/src/pages/layout.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,13 @@ export default function Layout(props: ParentProps) {
10581058
keybind: "mod+comma",
10591059
onSelect: () => openSettings(),
10601060
},
1061+
{
1062+
id: "pair.show",
1063+
title: language.t("command.pair.show"),
1064+
category: language.t("command.category.settings"),
1065+
slash: "pair",
1066+
onSelect: () => openSettings("pair"),
1067+
},
10611068
{
10621069
id: "session.previous",
10631070
title: language.t("command.session.previous"),
@@ -1210,11 +1217,11 @@ export default function Layout(props: ParentProps) {
12101217
})
12111218
}
12121219

1213-
function openSettings() {
1220+
function openSettings(defaultTab?: string) {
12141221
const run = ++dialogRun
12151222
void import("@/components/dialog-settings").then((x) => {
12161223
if (dialogDead || dialogRun !== run) return
1217-
dialog.show(() => <x.DialogSettings />)
1224+
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
12181225
})
12191226
}
12201227

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
3232
import { DialogStatus } from "@tui/component/dialog-status"
3333
import { DialogThemeList } from "@tui/component/dialog-theme-list"
3434
import { DialogHelp } from "./ui/dialog-help"
35+
import { DialogPair } from "@tui/component/dialog-pair"
3536
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
3637
import { DialogAgent } from "@tui/component/dialog-agent"
3738
import { DialogSessionList } from "@tui/component/dialog-session-list"
@@ -691,6 +692,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
691692
},
692693
category: "System",
693694
},
695+
{
696+
title: "Pair mobile device",
697+
value: "pair.show",
698+
slash: {
699+
name: "pair",
700+
},
701+
onSelect: () => {
702+
dialog.replace(() => <DialogPair />)
703+
},
704+
category: "System",
705+
},
694706
{
695707
title: "Help",
696708
value: "help.show",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import { useTheme } from "../context/theme"
3+
import { useDialog } from "@tui/ui/dialog"
4+
import { useSDK } from "@tui/context/sdk"
5+
import { createResource, onMount, Show } from "solid-js"
6+
import * as QRCode from "qrcode"
7+
8+
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
9+
10+
const BLOCK = {
11+
WW: " ",
12+
WB: "▄",
13+
BB: "█",
14+
BW: "▀",
15+
}
16+
17+
function renderQR(link: string): string {
18+
const qr = QRCode.create(link, { errorCorrectionLevel: "L" })
19+
const size = qr.modules.size
20+
const data = qr.modules.data
21+
const margin = 2
22+
23+
const get = (r: number, c: number) => {
24+
if (r < 0 || r >= size || c < 0 || c >= size) return false
25+
return Boolean(data[r * size + c])
26+
}
27+
28+
const totalW = size + margin * 2
29+
const blank = BLOCK.WW.repeat(totalW)
30+
const lines: string[] = []
31+
32+
// top margin
33+
for (let i = 0; i < margin / 2; i++) lines.push(blank)
34+
35+
// QR rows, 2 at a time using half-block chars
36+
for (let r = -margin; r < size + margin; r += 2) {
37+
let row = ""
38+
for (let c = -margin; c < size + margin; c++) {
39+
const top = get(r, c)
40+
const bottom = get(r + 1, c)
41+
if (top && bottom) row += BLOCK.BB
42+
else if (top) row += BLOCK.BW
43+
else if (bottom) row += BLOCK.WB
44+
else row += BLOCK.WW
45+
}
46+
lines.push(row)
47+
}
48+
49+
return lines.join("\n")
50+
}
51+
52+
export function DialogPair() {
53+
const dialog = useDialog()
54+
const { theme } = useTheme()
55+
const sdk = useSDK()
56+
57+
onMount(() => {
58+
dialog.setSize("large")
59+
})
60+
61+
const [data] = createResource(async () => {
62+
const res = await sdk.fetch(`${sdk.url}/experimental/push/pair`)
63+
if (!res.ok) return { enabled: false as const }
64+
const json = (await res.json()) as PairResult
65+
if (!json.enabled) return json
66+
67+
const qrText = renderQR(json.link)
68+
return { ...json, qrText }
69+
})
70+
71+
return (
72+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
73+
<box flexDirection="row" justifyContent="space-between">
74+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
75+
Pair Mobile Device
76+
</text>
77+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
78+
esc
79+
</text>
80+
</box>
81+
<Show when={data.loading}>
82+
<text fg={theme.textMuted}>Loading pairing info...</text>
83+
</Show>
84+
<Show when={data.error}>
85+
<box gap={1}>
86+
<text fg={theme.error}>Could not load pairing info.</text>
87+
<text fg={theme.textMuted} wrapMode="word">
88+
Check that the server is reachable and try again.
89+
</text>
90+
</box>
91+
</Show>
92+
<Show when={!data.loading && !data.error && data()}>
93+
{(result) => (
94+
<Show
95+
when={result().enabled && result()}
96+
fallback={
97+
<box gap={1}>
98+
<text fg={theme.warning}>Push relay is not enabled.</text>
99+
<text fg={theme.textMuted} wrapMode="word">
100+
Start the server with push relay options to enable mobile pairing:
101+
</text>
102+
<text fg={theme.text} wrapMode="word">
103+
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
104+
</text>
105+
</box>
106+
}
107+
>
108+
{(pair) => (
109+
<box gap={1} alignItems="center">
110+
<text fg={theme.text}>{(pair() as any).qrText}</text>
111+
<box gap={0} alignItems="center">
112+
<text fg={theme.textMuted} wrapMode="word">
113+
Scan with the OpenCode Control app
114+
</text>
115+
<text fg={theme.textMuted} wrapMode="word">
116+
to pair your device for push notifications.
117+
</text>
118+
</box>
119+
</box>
120+
)}
121+
</Show>
122+
)}
123+
</Show>
124+
</box>
125+
)
126+
}

packages/opencode/src/cli/cmd/tui/ui/dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ export function Dialog(
3939
width={dimensions().width}
4040
height={dimensions().height}
4141
alignItems="center"
42+
justifyContent="center"
4243
position="absolute"
4344
zIndex={3000}
44-
paddingTop={dimensions().height / 4}
4545
left={0}
4646
top={0}
4747
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}

packages/opencode/src/server/routes/experimental.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const PushPairResult = z
3737
z.object({
3838
enabled: z.literal(true),
3939
hosts: z.array(z.string()),
40+
link: z.string(),
4041
qr: z.string(),
4142
}),
4243
])
@@ -440,11 +441,13 @@ export const ExperimentalRoutes = lazy(() =>
440441
})
441442
}
442443

444+
const link = pushPairLink(pair)
443445
const qr = await pushPairQRCode(pair)
444446

445447
return c.json({
446448
enabled: true,
447449
hosts: pair.hosts,
450+
link,
448451
qr,
449452
})
450453
},

0 commit comments

Comments
 (0)