summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-10 17:17:34 -0600
committerAdam <[email protected]>2025-12-10 17:17:37 -0600
commit85cfa226c34e41660ddfdcb04543af2e494ae168 (patch)
treef98a6631bf169470c37eeacc79129b826da59dd2
parentcbb591eb7dfe8e27298945f10e5d6cfff4405630 (diff)
downloadopencode-85cfa226c34e41660ddfdcb04543af2e494ae168.tar.gz
opencode-85cfa226c34e41660ddfdcb04543af2e494ae168.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/components/prompt-input.tsx221
-rw-r--r--packages/desktop/src/hooks/use-providers.ts31
-rw-r--r--packages/desktop/src/pages/layout.tsx19
-rw-r--r--packages/ui/src/components/input.tsx10
-rw-r--r--packages/ui/src/components/list.css8
-rw-r--r--packages/ui/src/components/list.tsx3
6 files changed, 222 insertions, 70 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 985dbae8e..0672dfc85 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -17,6 +17,13 @@ import { Select } from "@opencode-ai/ui/select"
import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useLayout } from "@/context/layout"
+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"
interface PromptInputProps {
class?: string
@@ -58,6 +65,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const session = useSession()
const layout = useLayout()
+ const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@@ -461,60 +469,167 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="chevron-down" size="small" />
</Button>
<Show when={layout.dialog.opened() === "model"}>
- <SelectDialog
- defaultOpen
- onOpenChange={(open) => {
- 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) => {
- const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
- 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 (order.includes(aProvider) && !order.includes(bProvider)) return -1
- if (!order.includes(aProvider) && order.includes(bProvider)) return 1
- return order.indexOf(aProvider) - order.indexOf(bProvider)
- }}
- onSelect={(x) =>
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
- }
- actions={
- <Button
- class="h-7 -my-1 text-14-medium"
- icon="plus-small"
- tabIndex={-1}
- onClick={() => layout.dialog.open("provider")}
+ <Switch>
+ <Match when={providers().connected().length > 0}>
+ <SelectDialog
+ defaultOpen
+ onOpenChange={(open) => {
+ 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={
+ <Button
+ class="h-7 -my-1 text-14-medium"
+ icon="plus-small"
+ tabIndex={-1}
+ onClick={() => layout.dialog.open("provider")}
+ >
+ Connect provider
+ </Button>
+ }
>
- Connect provider
- </Button>
- }
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-2.5">
- <span>{i.name}</span>
- <Show when={!i.cost || i.cost?.input === 0}>
- <Tag>Free</Tag>
- </Show>
- <Show when={i.latest}>
- <Tag>Latest</Tag>
- </Show>
- </div>
- )}
- </SelectDialog>
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2.5">
+ <span>{i.name}</span>
+ <Show when={!i.cost || i.cost?.input === 0}>
+ <Tag>Free</Tag>
+ </Show>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </SelectDialog>
+ </Match>
+ <Match when={true}>
+ {iife(() => {
+ let listRef: ListRef | undefined
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+ return (
+ <Dialog
+ modal
+ defaultOpen
+ onOpenChange={(open) => {
+ if (open) {
+ layout.dialog.open("model")
+ } else {
+ layout.dialog.close("model")
+ }
+ }}
+ >
+ <Dialog.Header>
+ <Dialog.Title>Select model</Dialog.Title>
+ <Dialog.CloseButton tabIndex={-1} />
+ </Dialog.Header>
+ <Dialog.Body>
+ <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
+ <div class="flex flex-col gap-3 px-2.5">
+ <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={local.model.list()}
+ current={local.model.current()}
+ key={(x) => `${x.provider.id}:${x.id}`}
+ onSelect={(x) => {
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ recent: true,
+ })
+ layout.dialog.close("model")
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2.5">
+ <span>{i.name}</span>
+ <Tag>Free</Tag>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </List>
+ <div />
+ <div />
+ </div>
+ <div class="px-1.5 pb-1.5">
+ <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
+ <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6">
+ <div class="px-2 text-14-medium text-text-base">
+ Add more models from popular providers
+ </div>
+ <List
+ class="w-full"
+ key={(x) => x?.id}
+ items={providers().popular()}
+ activeIcon="plus-small"
+ sortBy={(a, b) => {
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+ return a.name.localeCompare(b.name)
+ }}
+ onSelect={(x) => {
+ layout.dialog.close("model")
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-4">
+ <ProviderIcon
+ data-slot="list-item-extra-icon"
+ id={i.id as IconName}
+ // TODO: clean this up after we update icon in models.dev
+ classList={{
+ "text-icon-weak-base": true,
+ "size-4 mx-0.5": i.id === "opencode",
+ "size-5": i.id !== "opencode",
+ }}
+ />
+ <span>{i.name}</span>
+ <Show when={i.id === "opencode"}>
+ <Tag>Recommended</Tag>
+ </Show>
+ <Show when={i.id === "anthropic"}>
+ <div class="text-14-regular text-text-weak">
+ Connect with Claude Pro/Max or API key
+ </div>
+ </Show>
+ </div>
+ )}
+ </List>
+ </div>
+ </div>
+ </div>
+ </Dialog.Body>
+ </Dialog>
+ )
+ })}
+ </Match>
+ </Switch>
</Show>
</div>
<Tooltip
diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts
new file mode 100644
index 000000000..c3fcc7898
--- /dev/null
+++ b/packages/desktop/src/hooks/use-providers.ts
@@ -0,0 +1,31 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+
+export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+
+export function useProviders() {
+ const params = useParams()
+ const globalSync = useGlobalSync()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const providers = createMemo(() => {
+ if (currentDirectory()) {
+ const [projectStore] = globalSync.child(currentDirectory())
+ return projectStore.provider
+ }
+ return globalSync.data.provider
+ })
+ const connected = createMemo(() =>
+ providers().all.filter(
+ (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
+ ),
+ )
+ const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
+ return createMemo(() => ({
+ all: providers().all,
+ default: providers().default,
+ popular,
+ connected,
+ }))
+}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 2ea6c4ba0..10d4cbfda 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -33,8 +33,7 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Tag } from "@opencode-ai/ui/tag"
import { IconName } from "@opencode-ai/ui/icons/provider"
-
-const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+import { popularProviders, useProviders } from "@/hooks/use-providers"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -50,18 +49,7 @@ export default function Layout(props: ParentProps) {
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
- const providers = createMemo(() => {
- if (currentDirectory()) {
- const [projectStore] = globalSync.child(currentDirectory())
- return projectStore.provider
- }
- return globalSync.data.provider
- })
- const connectedProviders = createMemo(() =>
- providers().all.filter(
- (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
- ),
- )
+ const providers = useProviders()
function navigateToProject(directory: string | undefined) {
if (!directory) return
@@ -493,7 +481,7 @@ export default function Layout(props: ParentProps) {
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
- <Match when={!connectedProviders().length && layout.sidebar.opened()}>
+ <Match when={!providers().connected().length && layout.sidebar.opened()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
@@ -599,6 +587,7 @@ export default function Layout(props: ParentProps) {
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
+ data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx
index cbf23e5c7..82f704e8c 100644
--- a/packages/ui/src/components/input.tsx
+++ b/packages/ui/src/components/input.tsx
@@ -7,6 +7,7 @@ export interface InputProps
Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> {
label?: string
hideLabel?: boolean
+ hidden?: boolean
description?: string
}
@@ -14,6 +15,7 @@ export function Input(props: InputProps) {
const [local, others] = splitProps(props, [
"class",
"label",
+ "hidden",
"hideLabel",
"description",
"value",
@@ -21,7 +23,13 @@ export function Input(props: InputProps) {
"onKeyDown",
])
return (
- <Kobalte data-component="input" value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown}>
+ <Kobalte
+ data-component="input"
+ style={{ height: local.hidden ? 0 : undefined }}
+ value={local.value}
+ onChange={local.onChange}
+ onKeyDown={local.onKeyDown}
+ >
<Show when={local.label}>
<Kobalte.Label data-slot="input-label" classList={{ "sr-only": local.hideLabel }}>
{local.label}
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index 63d9a2fe1..38dcb773b 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -97,10 +97,18 @@
[data-slot="list-item-active-icon"] {
display: block;
}
+ [data-slot="list-item-extra-icon"] {
+ color: var(--icon-strong-base) !important;
+ }
}
&:active {
background: var(--surface-raised-base-active);
}
+ &:hover {
+ [data-slot="list-item-extra-icon"] {
+ color: var(--icon-strong-base) !important;
+ }
+ }
}
}
}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 3fbeb35f6..a7f2db9ef 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
import { Icon, IconProps } from "./icon"
export interface ListProps<T> extends FilteredListProps<T> {
+ class?: string
children: (item: T) => JSX.Element
emptyMessage?: string
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
@@ -90,7 +91,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
})
return (
- <div ref={setScrollRef} data-component="list">
+ <div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show
when={flat().length > 0}
fallback={