summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-11 13:02:57 -0600
committerAdam <[email protected]>2025-12-11 13:42:47 -0600
commit16b7370d8cac304eb2af800b9c6a584784a0c600 (patch)
tree8b181d31ce119d1f8cd8bb47d5268c08edde5ed7 /packages
parent634fd62b25169619ffcbf87beec8777bf5a0d9bd (diff)
downloadopencode-16b7370d8cac304eb2af800b9c6a584784a0c600.tar.gz
opencode-16b7370d8cac304eb2af800b9c6a584784a0c600.zip
wip(desktop): progress
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx10
-rw-r--r--packages/desktop/src/pages/layout.tsx108
-rw-r--r--packages/ui/src/components/dialog.css11
-rw-r--r--packages/ui/src/components/icon.tsx2
-rw-r--r--packages/ui/src/components/list.css3
-rw-r--r--packages/ui/src/components/select-dialog.tsx4
-rw-r--r--packages/ui/src/components/text-field.css (renamed from packages/ui/src/components/input.css)62
-rw-r--r--packages/ui/src/components/text-field.tsx (renamed from packages/ui/src/components/input.tsx)42
-rw-r--r--packages/ui/src/styles/index.css2
9 files changed, 179 insertions, 65 deletions
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<PromptInputProps> = (props) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
return (
<Dialog
modal
@@ -574,7 +581,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<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
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 3086ff2fd..3b9972c75 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
@@ -38,7 +38,7 @@ 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 { TextField } from "@opencode-ai/ui/text-field"
import { showToast, Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -683,16 +683,37 @@ export default function Layout(props: ParentProps) {
}
}
+ let listRef: ListRef | undefined
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
+ return
+ }
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
onMount(() => {
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) {
<Match when={store.method === undefined}>
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
<div class="">
- <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
<List
ref={(ref) => (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) {
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <Input
+ <TextField
autofocus
type="text"
label={`${provider().name} API key`}
@@ -915,16 +926,7 @@ export default function Layout(props: ParentProps) {
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)
+ await complete()
return
}
setFormStore("error", "Invalid authorization code")
@@ -938,7 +940,7 @@ export default function Layout(props: ParentProps) {
OpenCode.
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <Input
+ <TextField
autofocus
type="text"
label={`${store.method?.label} authorization code`}
@@ -958,12 +960,48 @@ export default function Layout(props: ParentProps) {
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
- <div class="flex flex-col gap-6">
- <div class="text-14-regular text-text-base">
- Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below
- to connect your account and use {provider().name} models in OpenCode.
- </div>
- </div>
+ {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 (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
+ below to connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <TextField
+ label="Confirmation code"
+ class="font-mono"
+ value={code()}
+ readOnly
+ copyable
+ />
+ <div class="text-14-regular text-text-base flex items-center gap-4">
+ <Spinner />
+ <span>Waiting for authorization...</span>
+ </div>
+ </div>
+ )
+ })}
</Match>
</Switch>
</Match>
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": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
+ copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
+ check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
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<T>
extends Omit<ListProps<T>, "filter">,
@@ -55,7 +55,7 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
<div data-component="select-dialog-input">
<div data-slot="select-dialog-input-container">
<Icon name="magnifying-glass" />
- <Input
+ <TextField
ref={inputRef}
autofocus
variant="ghost"
diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/text-field.css
index 276e8069b..897050a63 100644
--- a/packages/ui/src/components/input.css
+++ b/packages/ui/src/components/text-field.css
@@ -40,6 +40,37 @@
letter-spacing: var(--letter-spacing-normal);
}
+ [data-slot="input-wrapper"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding-right: 4px;
+
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-weak-base);
+ background: var(--input-base);
+
+ &:focus-within {
+ /* 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);
+ }
+
+ &:has([data-invalid]) {
+ background: var(--surface-critical-weak);
+ border: 1px solid var(--border-critical-selected);
+ }
+
+ &:not(:has([data-slot="input-copy-button"])) {
+ padding-right: 0;
+ }
+ }
+
[data-slot="input-input"] {
color: var(--text-strong);
@@ -47,12 +78,11 @@
height: 32px;
padding: 2px 12px;
align-items: center;
- gap: 8px;
- align-self: stretch;
+ flex: 1;
+ min-width: 0;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-weak-base);
- background: var(--input-base);
+ background: transparent;
+ border: none;
/* text-14-regular */
font-family: var(--font-family-sans);
@@ -64,19 +94,6 @@
&: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 {
@@ -84,6 +101,15 @@
}
}
+ [data-slot="input-copy-button"] {
+ flex-shrink: 0;
+ color: var(--icon-base);
+
+ &:hover {
+ color: var(--icon-strong-base);
+ }
+ }
+
[data-slot="input-error"] {
color: var(--text-on-critical-base);
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/text-field.tsx
index 8e2a115c6..63ffb2594 100644
--- a/packages/ui/src/components/input.tsx
+++ b/packages/ui/src/components/text-field.tsx
@@ -1,8 +1,10 @@
import { TextField as Kobalte } from "@kobalte/core/text-field"
-import { Show, splitProps } from "solid-js"
+import { createSignal, Show, splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
+import { IconButton } from "./icon-button"
+import { Tooltip } from "./tooltip"
-export interface InputProps
+export interface TextFieldProps
extends ComponentProps<typeof Kobalte.Input>,
Partial<
Pick<
@@ -20,13 +22,13 @@ export interface InputProps
> {
label?: string
hideLabel?: boolean
- hidden?: boolean
description?: string
error?: string
variant?: "normal" | "ghost"
+ copyable?: boolean
}
-export function Input(props: InputProps) {
+export function TextField(props: TextFieldProps) {
const [local, others] = splitProps(props, [
"name",
"defaultValue",
@@ -39,12 +41,21 @@ export function Input(props: InputProps) {
"readOnly",
"class",
"label",
- "hidden",
"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 (
<Kobalte
data-component="input"
@@ -57,7 +68,6 @@ export function Input(props: InputProps) {
required={local.required}
disabled={local.disabled}
readOnly={local.readOnly}
- style={{ height: local.hidden ? 0 : undefined }}
validationState={local.validationState}
>
<Show when={local.label}>
@@ -65,7 +75,20 @@ export function Input(props: InputProps) {
{local.label}
</Kobalte.Label>
</Show>
- <Kobalte.Input {...others} data-slot="input-input" class={local.class} />
+ <div data-slot="input-wrapper">
+ <Kobalte.Input {...others} data-slot="input-input" class={local.class} />
+ <Show when={local.copyable}>
+ <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}>
+ <IconButton
+ type="button"
+ icon={copied() ? "check" : "copy"}
+ variant="ghost"
+ onClick={handleCopy}
+ data-slot="input-copy-button"
+ />
+ </Tooltip>
+ </Show>
+ </div>
<Show when={local.description}>
<Kobalte.Description data-slot="input-description">{local.description}</Kobalte.Description>
</Show>
@@ -73,3 +96,8 @@ export function Input(props: InputProps) {
</Kobalte>
)
}
+
+/** @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);