From 0ca758e13516d96180cc11631e02e5f7929ba4b0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:32:53 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 137 ++++++++++++++--------- 1 file changed, 81 insertions(+), 56 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 41af8644b..7f8568291 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,17 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createSignal } from "solid-js" +import { + createEffect, + on, + Component, + Show, + For, + onMount, + onCleanup, + Switch, + Match, + createSignal, + createMemo, +} from "solid-js" import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" @@ -470,60 +482,73 @@ export const PromptInput: Component = (props) => { - 0}> - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - } - actions={ - + } > - Connect provider - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
+ {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} + + ) + })}
{iife(() => { @@ -554,7 +579,7 @@ export const PromptInput: Component = (props) => {
Free models provided by OpenCode
(listRef = ref)} - items={local.model.list()} + items={local.model.list} current={local.model.current()} key={(x) => `${x.provider.id}:${x.id}`} onSelect={(x) => { @@ -587,7 +612,7 @@ export const PromptInput: Component = (props) => { x?.id} - items={providers().popular()} + items={providers.popular} activeIcon="plus-small" sortBy={(a, b) => { if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) -- cgit v1.2.3 From 634fd62b25169619ffcbf87beec8777bf5a0d9bd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:01:37 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/link.tsx | 17 +++++ packages/desktop/src/pages/layout.tsx | 118 +++++++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 packages/desktop/src/components/link.tsx (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx new file mode 100644 index 000000000..e13c31330 --- /dev/null +++ b/packages/desktop/src/components/link.tsx @@ -0,0 +1,17 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends ComponentProps<"button"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children"]) + + return ( + + ) +} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 4a3fa766b..3086ff2fd 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { IconName } from "@opencode-ai/ui/icons/provider" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" import { List, ListRef } from "@opencode-ai/ui/list" import { Input } from "@opencode-ai/ui/input" import { showToast, Toast } from "@opencode-ai/ui/toast" @@ -637,6 +638,8 @@ export default function Layout(props: ParentProps) { error: undefined as string | undefined, }) + const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + async function selectMethod(index: number) { const method = methods()[index] setStore( @@ -652,10 +655,13 @@ export default function Layout(props: ParentProps) { setStore("state", "pending") const start = Date.now() await globalSDK.client.provider.oauth - .authorize({ - providerID: providerID(), - method: index, - }) + .authorize( + { + providerID: providerID(), + method: index, + }, + { throwOnError: true }, + ) .then((x) => { const elapsed = Date.now() - start const delay = 1000 - elapsed @@ -731,7 +737,16 @@ export default function Layout(props: ParentProps) {
-
Connect {provider().name}
+
+ + + Login with Claude Pro/Max + + Connect {provider().name} + +
@@ -756,7 +771,6 @@ export default function Layout(props: ParentProps) { data-slot="list-item-extra-icon" />
- {/* TODO: add checkmark thing */} {i.label}
)} @@ -833,13 +847,9 @@ export default function Layout(props: ParentProps) {
Visit{" "} - {" "} + {" "} to collect your API key.
@@ -873,8 +883,88 @@ export default function Layout(props: ParentProps) {
- Code {store.authorization?.url} - Auto {store.authorization?.url} + + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } + + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + code, + }) + if (!error) { + await globalSDK.client.global.dispose() + setTimeout(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) + layout.connect.complete() + }, 500) + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( +
+
+ Visit this link to collect your + authorization code to connect your account and use {provider().name} models in + OpenCode. +
+
+ + +
+
+ ) + })} +
+ +
+
+ Visit this link and enter the code below + to connect your account and use {provider().name} models in OpenCode. +
+
+
-- cgit v1.2.3 From 16b7370d8cac304eb2af800b9c6a584784a0c600 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:02:57 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 10 +- packages/desktop/src/pages/layout.tsx | 108 +++++++++++++------- packages/ui/src/components/dialog.css | 11 ++ packages/ui/src/components/icon.tsx | 2 + packages/ui/src/components/input.css | 99 ------------------ packages/ui/src/components/input.tsx | 75 -------------- packages/ui/src/components/list.css | 3 + packages/ui/src/components/select-dialog.tsx | 4 +- packages/ui/src/components/text-field.css | 125 +++++++++++++++++++++++ packages/ui/src/components/text-field.tsx | 103 +++++++++++++++++++ packages/ui/src/styles/index.css | 2 +- 11 files changed, 328 insertions(+), 214 deletions(-) delete mode 100644 packages/ui/src/components/input.css delete mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/text-field.css create mode 100644 packages/ui/src/components/text-field.tsx (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 7f8568291..2c153ecc3 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -33,7 +33,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { iife } from "@opencode-ai/util/iife" -import { Input } from "@opencode-ai/ui/input" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -557,6 +556,14 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + return ( = (props) => { -
Free models provided by OpenCode
{ if (methods().length === 1) { selectMethod(0) } + + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) }) - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") return - listRef?.onKeyDown(e) + async function complete() { + await globalSDK.client.global.dispose() + setTimeout(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) + layout.connect.complete() + }, 500) } return ( @@ -753,7 +774,6 @@ export default function Layout(props: ParentProps) {
Select login method for {provider().name}.
- (listRef = ref)} items={methods} @@ -820,16 +840,7 @@ export default function Layout(props: ParentProps) { key: apiKey, }, }) - await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) + await complete() } return ( @@ -862,7 +873,7 @@ export default function Layout(props: ParentProps) {
- { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) + await complete() return } setFormStore("error", "Invalid authorization code") @@ -938,7 +940,7 @@ export default function Layout(props: ParentProps) { OpenCode.
- -
-
- Visit this link and enter the code below - to connect your account and use {provider().name} models in OpenCode. -
-
+ {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) + + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + }) + if (result.error) { + // TODO: show error + layout.dialog.close("connect") + return + } + await complete() + }) + + return ( +
+
+ Visit this link and enter the code + below to connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })}
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1c7cd4f41..979906e26 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -88,8 +88,19 @@ flex-direction: column; flex: 1; overflow-y: auto; + + &:focus-visible { + outline: none; + } + } + &:focus-visible { + outline: none; } } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 56cef2d8c..ce4bf7556 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -47,6 +47,8 @@ const icons = { "layout-bottom-full": ``, "dot-grid": ``, "circle-check": ``, + copy: ``, + check: ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css deleted file mode 100644 index 276e8069b..000000000 --- a/packages/ui/src/components/input.css +++ /dev/null @@ -1,99 +0,0 @@ -[data-component="input"] { - width: 100%; - - [data-slot="input-input"] { - width: 100%; - color: var(--text-strong); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - - &:focus { - outline: none; - } - - &::placeholder { - color: var(--text-weak); - } - } - - &[data-variant="normal"] { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - - [data-slot="input-label"] { - color: var(--text-weak); - - /* text-12-medium */ - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: 18px; /* 150% */ - letter-spacing: var(--letter-spacing-normal); - } - - [data-slot="input-input"] { - color: var(--text-strong); - - display: flex; - height: 32px; - padding: 2px 12px; - align-items: center; - gap: 8px; - align-self: stretch; - - border-radius: var(--radius-md); - border: 1px solid var(--border-weak-base); - background: var(--input-base); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - - &:focus { - outline: none; - - /* border/shadow-xs/select */ - box-shadow: - 0 0 0 3px var(--border-weak-selected), - 0 0 0 1px var(--border-selected), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), - 0 1px 3px 0 rgba(19, 16, 16, 0.12); - } - - &[data-invalid] { - background: var(--surface-critical-weak); - border: 1px solid var(--border-critical-selected); - } - - &::placeholder { - color: var(--text-weak); - } - } - - [data-slot="input-error"] { - color: var(--text-on-critical-base); - - /* text-12-medium */ - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: 18px; /* 150% */ - letter-spacing: var(--letter-spacing-normal); - } - } -} diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx deleted file mode 100644 index 8e2a115c6..000000000 --- a/packages/ui/src/components/input.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { TextField as Kobalte } from "@kobalte/core/text-field" -import { Show, splitProps } from "solid-js" -import type { ComponentProps } from "solid-js" - -export interface InputProps - extends ComponentProps, - Partial< - Pick< - ComponentProps, - | "name" - | "defaultValue" - | "value" - | "onChange" - | "onKeyDown" - | "validationState" - | "required" - | "disabled" - | "readOnly" - > - > { - label?: string - hideLabel?: boolean - hidden?: boolean - description?: string - error?: string - variant?: "normal" | "ghost" -} - -export function Input(props: InputProps) { - const [local, others] = splitProps(props, [ - "name", - "defaultValue", - "value", - "onChange", - "onKeyDown", - "validationState", - "required", - "disabled", - "readOnly", - "class", - "label", - "hidden", - "hideLabel", - "description", - "error", - "variant", - ]) - return ( - - - - {local.label} - - - - - {local.description} - - {local.error} - - ) -} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 783b0ef4a..132824164 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -105,6 +105,9 @@ &:active { background: var(--surface-raised-base-active); } + &:focus-visible { + outline: none; + } } } } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index efa6c405b..06953168c 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" -import { Input } from "./input" +import { TextField } from "./text-field" interface SelectDialogProps extends Omit, "filter">, @@ -55,7 +55,7 @@ export function SelectDialog(props: SelectDialogProps) {
- , + Partial< + Pick< + ComponentProps, + | "name" + | "defaultValue" + | "value" + | "onChange" + | "onKeyDown" + | "validationState" + | "required" + | "disabled" + | "readOnly" + > + > { + label?: string + hideLabel?: boolean + description?: string + error?: string + variant?: "normal" | "ghost" + copyable?: boolean +} + +export function TextField(props: TextFieldProps) { + const [local, others] = splitProps(props, [ + "name", + "defaultValue", + "value", + "onChange", + "onKeyDown", + "validationState", + "required", + "disabled", + "readOnly", + "class", + "label", + "hideLabel", + "description", + "error", + "variant", + "copyable", + ]) + const [copied, setCopied] = createSignal(false) + + async function handleCopy() { + const value = local.value ?? local.defaultValue ?? "" + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + {local.label} + + +
+ + + + + + +
+ + {local.description} + + {local.error} +
+ ) +} + +/** @deprecated Use TextField instead */ +export const Input = TextField +/** @deprecated Use TextFieldProps instead */ +export type InputProps = TextFieldProps diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 918ba9e44..d60082d93 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -21,7 +21,7 @@ @import "../components/provider-icon.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); -@import "../components/input.css" layer(components); +@import "../components/text-field.css" layer(components); @import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); -- cgit v1.2.3 From a0472c0312d758c6477f00723c387645badd1272 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:24:26 -0600 Subject: fix: free model logic --- packages/desktop/src/components/prompt-input.tsx | 2 +- packages/desktop/src/hooks/use-providers.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 2c153ecc3..70ee0a739 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -537,7 +537,7 @@ export const PromptInput: Component = (props) => { {(i) => (
{i.name} - + Free diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts index cad810b7b..501ff9d0c 100644 --- a/packages/desktop/src/hooks/use-providers.ts +++ b/packages/desktop/src/hooks/use-providers.ts @@ -17,7 +17,9 @@ export function useProviders() { return globalSync.data.provider }) const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id))) - const paid = createMemo(() => connected().filter((p) => Object.values(p.models).find((m) => m.cost?.input))) + const paid = createMemo(() => + connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), + ) const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) return { all: createMemo(() => providers().all), -- cgit v1.2.3