mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
feat(opencode): add mobile pairing dialogs
This commit is contained in:
@@ -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 <url> --relay-secret <secret>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <url> --relay-secret <secret>
|
||||
</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,
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user