Adam 9 месяцев назад
Родитель
Сommit
16b7370d8c

+ 8 - 2
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

+ 73 - 35
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>

+ 11 - 0
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;
+    }
   }
 }
 

+ 2 - 0
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"> {

+ 3 - 0
packages/ui/src/components/list.css

@@ -105,6 +105,9 @@
         &:active {
           background: var(--surface-raised-base-active);
         }
+        &:focus-visible {
+          outline: none;
+        }
       }
     }
   }

+ 2 - 2
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"

+ 44 - 18
packages/ui/src/components/input.css → 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);
 

+ 35 - 7
packages/ui/src/components/input.tsx → 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

+ 1 - 1
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);