diff options
| author | Adam <[email protected]> | 2025-12-11 13:02:57 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-11 13:42:47 -0600 |
| commit | 16b7370d8cac304eb2af800b9c6a584784a0c600 (patch) | |
| tree | 8b181d31ce119d1f8cd8bb47d5268c08edde5ed7 /packages | |
| parent | 634fd62b25169619ffcbf87beec8777bf5a0d9bd (diff) | |
| download | opencode-16b7370d8cac304eb2af800b9c6a584784a0c600.tar.gz opencode-16b7370d8cac304eb2af800b9c6a584784a0c600.zip | |
wip(desktop): progress
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 10 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 108 | ||||
| -rw-r--r-- | packages/ui/src/components/dialog.css | 11 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/list.css | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/select-dialog.tsx | 4 | ||||
| -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.css | 2 |
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); |
