Browse Source

Merge branch 'dev' of https://github.com/sst/opencode into dev

David Hill 2 months ago
parent
commit
a468044c9f
57 changed files with 1616 additions and 1038 deletions
  1. 1 1
      README.md
  2. 2 2
      bun.lock
  3. 1 1
      nix/hashes.json
  4. 1 1
      package.json
  5. 1 1
      packages/console/app/src/app.tsx
  6. 4 4
      packages/console/app/src/config.ts
  7. 5 12
      packages/console/app/src/routes/index.tsx
  8. 7 1
      packages/desktop/src/app.tsx
  9. 185 52
      packages/desktop/src/components/prompt-input.tsx
  10. 27 58
      packages/desktop/src/context/global-sync.tsx
  11. 65 16
      packages/desktop/src/context/layout.tsx
  12. 23 8
      packages/desktop/src/context/local.tsx
  13. 1 1
      packages/desktop/src/context/session.tsx
  14. 3 3
      packages/desktop/src/context/sync.tsx
  15. 31 0
      packages/desktop/src/hooks/use-providers.ts
  16. 2 2
      packages/desktop/src/pages/home.tsx
  17. 105 3
      packages/desktop/src/pages/layout.tsx
  18. 1 1
      packages/enterprise/src/routes/share/[shareID].tsx
  19. 158 136
      packages/opencode/src/cli/cmd/auth.ts
  20. 1 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  21. 2 1
      packages/opencode/src/cli/cmd/tui/context/directory.ts
  22. 4 0
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  23. 9 5
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  24. 2 0
      packages/opencode/src/flag/flag.ts
  25. 1 1
      packages/opencode/src/project/project.ts
  26. 1 1
      packages/opencode/src/session/compaction.ts
  27. 7 2
      packages/opencode/src/session/prompt.ts
  28. 398 19
      packages/opencode/test/tool/bash.test.ts
  29. 1 0
      packages/tauri/src-tauri/capabilities/default.json
  30. 11 3
      packages/tauri/src-tauri/src/lib.rs
  31. 0 6
      packages/tauri/src/index.tsx
  32. 0 14
      packages/ui/index.html
  33. 0 1
      packages/ui/package.json
  34. 4 3
      packages/ui/src/components/avatar.tsx
  35. 13 5
      packages/ui/src/components/button.css
  36. 8 6
      packages/ui/src/components/dialog.css
  37. 1 1
      packages/ui/src/components/dialog.tsx
  38. 0 101
      packages/ui/src/components/icon.tsx
  39. 2 0
      packages/ui/src/components/input.css
  40. 9 1
      packages/ui/src/components/input.tsx
  41. 115 0
      packages/ui/src/components/list.css
  42. 142 0
      packages/ui/src/components/list.tsx
  43. 3 3
      packages/ui/src/components/provider-icon.tsx
  44. 19 84
      packages/ui/src/components/select-dialog.css
  45. 59 121
      packages/ui/src/components/select-dialog.tsx
  46. 37 0
      packages/ui/src/components/tag.css
  47. 22 0
      packages/ui/src/components/tag.tsx
  48. 0 291
      packages/ui/src/demo.tsx
  49. 0 40
      packages/ui/src/index.css
  50. 0 22
      packages/ui/src/index.tsx
  51. 2 0
      packages/ui/src/styles/index.css
  52. 1 0
      packages/ui/src/styles/tailwind/index.css
  53. 2 0
      packages/ui/src/styles/theme.css
  54. 1 1
      packages/web/astro.config.mjs
  55. 1 0
      packages/web/src/content/docs/ecosystem.mdx
  56. 2 2
      packages/web/src/content/docs/index.mdx
  57. 113 0
      packages/web/src/content/docs/providers.mdx

+ 1 - 1
README.md

@@ -7,7 +7,7 @@
     </picture>
   </a>
 </p>
-<p align="center">The AI coding agent built for the terminal.</p>
+<p align="center">The open source AI coding agent.</p>
 <p align="center">
   <a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
   <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>

+ 2 - 2
bun.lock

@@ -462,7 +462,7 @@
     "@hono/zod-validator": "0.4.2",
     "@kobalte/core": "0.13.11",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@pierre/precision-diffs": "0.6.0-beta.10",
+    "@pierre/precision-diffs": "0.6.1",
     "@solidjs/meta": "0.29.4",
     "@solidjs/router": "0.15.4",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
@@ -1277,7 +1277,7 @@
 
     "@petamoriken/float16": ["@petamoriken/[email protected]", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
 
-    "@pierre/precision-diffs": ["@pierre/[email protected].0-beta.10", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-2rdd1Q1xJbB0Z4oUbm0Ybrr2gLFEdvNetZLadJboZSFL7Q4gFujdQZfXfV3vB9X+esjt++v0nzb3mioW25BOTA=="],
+    "@pierre/precision-diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="],
 
     "@pkgjs/parseargs": ["@pkgjs/[email protected]", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
 

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-JT8J+Nd2kk0x46BcyotmBbM39tuKOW7VzXfOV3R3sqQ="
+  "nodeModules": "sha256-WQMQmqKojxdRtwv6KL9HBaDfwYa4qPn2pvXKqgNM73A="
 }

+ 1 - 1
package.json

@@ -30,7 +30,7 @@
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/precision-diffs": "0.6.0-beta.10",
+      "@pierre/precision-diffs": "0.6.1",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "ai": "5.0.97",

+ 1 - 1
packages/console/app/src/app.tsx

@@ -13,7 +13,7 @@ export default function App() {
       root={(props) => (
         <MetaProvider>
           <Title>opencode</Title>
-          <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
+          <Meta name="description" content="OpenCode - The open source coding agent." />
           <Favicon />
           <Suspense>{props.children}</Suspense>
         </MetaProvider>

+ 4 - 4
packages/console/app/src/config.ts

@@ -9,8 +9,8 @@ export const config = {
   github: {
     repoUrl: "https://github.com/sst/opencode",
     starsFormatted: {
-      compact: "35K",
-      full: "35,000",
+      compact: "38K",
+      full: "38,000",
     },
   },
 
@@ -22,8 +22,8 @@ export const config = {
 
   // Static stats (used on landing page)
   stats: {
-    contributors: "350",
-    commits: "5,000",
+    contributors: "375",
+    commits: "5,250",
     monthlyUsers: "400,000",
   },
 } as const

+ 5 - 12
packages/console/app/src/routes/index.tsx

@@ -188,15 +188,9 @@ export default function Home() {
           <section data-component="what">
             <div data-slot="section-title">
               <h3>What is OpenCode?</h3>
-              <p>OpenCode is an open source agent that helps you write and run code directly from the terminal.</p>
+              <p>OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.</p>
             </div>
             <ul>
-              <li>
-                <span>[*]</span>
-                <div>
-                  <strong>Native TUI</strong> A responsive, native, themeable terminal UI
-                </div>
-              </li>
               <li>
                 <span>[*]</span>
                 <div>
@@ -230,7 +224,7 @@ export default function Home() {
               <li>
                 <span>[*]</span>
                 <div>
-                  <strong>Any editor</strong> OpenCode runs in your terminal, pair it with any IDE
+                  <strong>Any editor</strong> Available as a terminal interface, desktop app, and IDE extension
                 </div>
               </li>
             </ul>
@@ -682,9 +676,8 @@ export default function Home() {
             <ul>
               <li>
                 <Faq question="What is OpenCode?">
-                  OpenCode is an open source agent that helps you write and run code directly from the terminal. You can
-                  pair OpenCode with any AI model, and because it’s terminal-based you can pair it with your preferred
-                  code editor.
+                  OpenCode is an open source agent that helps you write and run code with any AI model. It's available
+                  as a terminal-based interface, desktop app, or IDE extension.
                 </Faq>
               </li>
               <li>
@@ -705,7 +698,7 @@ export default function Home() {
               </li>
               <li>
                 <Faq question="Can I only use OpenCode in the terminal?">
-                  Yes, for now. We are actively working on a desktop app. Join the waitlist for early access.
+                  Not anymore! OpenCode is now available as an app for your desktop.
                 </Faq>
               </li>
               <li>

+ 7 - 1
packages/desktop/src/app.tsx

@@ -15,8 +15,14 @@ import { GlobalSDKProvider } from "./context/global-sdk"
 import { SessionProvider } from "./context/session"
 import { Show } from "solid-js"
 
+declare global {
+  interface Window {
+    __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
+  }
+}
+
 const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
-const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
+const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
 
 const url =
   new URLSearchParams(document.location.search).get("url") ||

+ 185 - 52
packages/desktop/src/components/prompt-input.tsx

@@ -3,7 +3,6 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
 import { createStore } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { DateTime } from "luxon"
 import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
 import { useSDK } from "@/context/sdk"
 import { useNavigate } from "@solidjs/router"
@@ -14,10 +13,17 @@ import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Select } from "@opencode-ai/ui/select"
+import { Tag } from "@opencode-ai/ui/tag"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { type IconName } from "@opencode-ai/ui/icons/provider"
+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 +64,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const sync = useSync()
   const local = useLocal()
   const session = useSession()
+  const layout = useLayout()
+  const providers = useProviders()
   let editorRef!: HTMLDivElement
 
   const [store, setStore] = createStore<{
@@ -455,55 +463,180 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="capitalize"
               variant="ghost"
             />
-            <SelectDialog
-              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)}
-              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 })
-              }
-              trigger={
-                <Button as="div" variant="ghost">
-                  {local.model.current()?.name ?? "Select model"}
-                  <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
-                  <Icon name="chevron-down" size="small" />
-                </Button>
-              }
-            >
-              {(i) => (
-                <div class="w-full flex items-center justify-between gap-x-3">
-                  <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
-                    <ProviderIcon name={i.provider.id as IconName} class="size-6 p-0.5 shrink-0" />
-                    <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
-                      <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
-                      <Show when={false}>
-                        <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
-                          {DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
-                        </span>
-                      </Show>
-                    </div>
-                  </div>
-                  <Show when={!i.cost || i.cost?.input === 0}>
-                    <div class="overflow-hidden text-12-medium text-text-strong">Free</div>
-                  </Show>
-                </div>
-              )}
-            </SelectDialog>
+            <Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
+              {local.model.current()?.name ?? "Select model"}
+              <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
+              <Icon name="chevron-down" size="small" />
+            </Button>
+            <Show when={layout.dialog.opened() === "model"}>
+              <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>
+                    }
+                  >
+                    {(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>
+                                <Button variant="ghost" class="w-full justify-start">
+                                  <div class="flex items-center gap-2">
+                                    <Icon name="plus-small" />
+                                    <div class="text-text-strong">View all providers</div>
+                                  </div>
+                                </Button>
+                              </div>
+                            </div>
+                          </div>
+                        </Dialog.Body>
+                      </Dialog>
+                    )
+                  })}
+                </Match>
+              </Switch>
+            </Show>
           </div>
           <Tooltip
             placement="top"

+ 27 - 58
packages/desktop/src/context/global-sync.tsx

@@ -1,7 +1,6 @@
 import type {
   Message,
   Agent,
-  Provider,
   Session,
   Part,
   Config,
@@ -12,49 +11,18 @@ import type {
   FileDiff,
   Todo,
   SessionStatus,
+  ProviderListResponse,
 } from "@opencode-ai/sdk/v2"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { Binary } from "@opencode-ai/util/binary"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 
-const PASTEL_COLORS = [
-  "#FCEAFD", // pastel pink
-  "#FFDFBA", // pastel peach
-  "#FFFFBA", // pastel yellow
-  "#BAFFC9", // pastel green
-  "#EAF6FD", // pastel blue
-  "#EFEAFD", // pastel lavender
-  "#FEC8D8", // pastel rose
-  "#D4F0F0", // pastel cyan
-  "#FDF0EA", // pastel coral
-  "#C1E1C1", // pastel mint
-]
-
-function pickAvailableColor(usedColors: Set<string>) {
-  const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
-  if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
-  return available[Math.floor(Math.random() * available.length)]
-}
-
-async function ensureProjectColor(
-  project: Project,
-  sdk: ReturnType<typeof useGlobalSDK>,
-  usedColors: Set<string>,
-): Promise<Project> {
-  if (project.icon?.color) return project
-  const color = pickAvailableColor(usedColors)
-  usedColors.add(color)
-  const updated = { ...project, icon: { ...project.icon, color } }
-  sdk.client.project.update({ projectID: project.id, icon: { color } })
-  return updated
-}
-
 type State = {
   ready: boolean
-  provider: Provider[]
   agent: Agent[]
   project: string
+  provider: ProviderListResponse
   config: Config
   path: Path
   session: Session[]
@@ -81,13 +49,16 @@ type State = {
 export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
   name: "GlobalSync",
   init: () => {
+    const sdk = useGlobalSDK()
     const [globalStore, setGlobalStore] = createStore<{
       ready: boolean
-      projects: Project[]
+      project: Project[]
+      provider: ProviderListResponse
       children: Record<string, State>
     }>({
       ready: false,
-      projects: [],
+      project: [],
+      provider: { all: [], connected: [], default: {} },
       children: {},
     })
 
@@ -96,11 +67,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
       if (!children[directory]) {
         setGlobalStore("children", directory, {
           project: "",
+          provider: { all: [], connected: [], default: {} },
           config: {},
-          path: { state: "", config: "", worktree: "", directory: "" },
+          path: { state: "", config: "", worktree: "", directory: "", home: "" },
           ready: false,
           agent: [],
-          provider: [],
           session: [],
           session_status: {},
           session_diff: {},
@@ -116,7 +87,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
       return children[directory]
     }
 
-    const sdk = useGlobalSDK()
     sdk.event.listen((e) => {
       const directory = e.name
       const event = e.details
@@ -124,20 +94,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
       if (directory === "global") {
         switch (event.type) {
           case "project.updated": {
-            const usedColors = new Set(globalStore.projects.map((p) => p.icon?.color).filter(Boolean) as string[])
-            ensureProjectColor(event.properties, sdk, usedColors).then((project) => {
-              const result = Binary.search(globalStore.projects, project.id, (s) => s.id)
-              if (result.found) {
-                setGlobalStore("projects", result.index, reconcile(project))
-                return
-              }
-              setGlobalStore(
-                "projects",
-                produce((draft) => {
-                  draft.splice(result.index, 0, project)
-                }),
-              )
-            })
+            const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
+            if (result.found) {
+              setGlobalStore("project", result.index, reconcile(event.properties))
+              return
+            }
+            setGlobalStore(
+              "project",
+              produce((draft) => {
+                draft.splice(result.index, 0, event.properties)
+              }),
+            )
             break
           }
         }
@@ -216,14 +183,16 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
 
     Promise.all([
       sdk.client.project.list().then(async (x) => {
-        const filtered = x.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
-        const usedColors = new Set(filtered.map((p) => p.icon?.color).filter(Boolean) as string[])
-        const projects = await Promise.all(filtered.map((p) => ensureProjectColor(p, sdk, usedColors)))
         setGlobalStore(
-          "projects",
-          projects.sort((a, b) => a.id.localeCompare(b.id)),
+          "project",
+          x
+            .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
+            .sort((a, b) => a.id.localeCompare(b.id)),
         )
       }),
+      sdk.client.provider.list().then((x) => {
+        setGlobalStore("provider", x.data ?? {})
+      }),
     ]).then(() => setGlobalStore("ready", true))
 
     return {

+ 65 - 16
packages/desktop/src/context/layout.tsx

@@ -4,6 +4,20 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
+import { Project } from "@opencode-ai/sdk/v2"
+
+const PASTEL_COLORS = [
+  "#FCEAFD", // pastel pink
+  "#FFDFBA", // pastel peach
+  "#FFFFBA", // pastel yellow
+  "#BAFFC9", // pastel green
+  "#EAF6FD", // pastel blue
+  "#EFEAFD", // pastel lavender
+  "#FEC8D8", // pastel rose
+  "#D4F0F0", // pastel cyan
+  "#FDF0EA", // pastel coral
+  "#C1E1C1", // pastel mint
+]
 
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
@@ -26,9 +40,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       }),
       {
-        name: "default-layout.v6",
+        name: "default-layout.v7",
       },
     )
+    const [ephemeral, setEphemeral] = createStore({
+      dialog: {
+        open: undefined as undefined | "provider" | "model",
+      },
+    })
+    const usedColors = new Set<string>()
+
+    function pickAvailableColor() {
+      const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
+      if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
+      return available[Math.floor(Math.random() * available.length)]
+    }
+
+    function enrich(project: { worktree: string; expanded: boolean }) {
+      const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
+      if (!metadata) return []
+      return [
+        {
+          ...project,
+          ...metadata,
+        },
+      ]
+    }
+
+    function colorize(project: Project & { expanded: boolean }) {
+      if (project.icon?.color) return project
+      const color = pickAvailableColor()
+      usedColors.add(color)
+      project.icon = { ...project.icon, color }
+      globalSdk.client.project.update({ projectID: project.id, icon: { color } })
+      return project
+    }
+
+    const enriched = createMemo(() => store.projects.flatMap(enrich))
+    const list = createMemo(() => enriched().flatMap(colorize))
 
     async function loadProjectSessions(directory: string) {
       const [, setStore] = globalSync.child(directory)
@@ -43,30 +92,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
     onMount(() => {
       Promise.all(
-        store.projects.map(({ worktree }) => {
-          return loadProjectSessions(worktree)
+        store.projects.map((project) => {
+          return loadProjectSessions(project.worktree)
         }),
       )
     })
 
-    function enrich(project: { worktree: string; expanded: boolean }) {
-      const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree)
-      if (!metadata) return []
-      return [
-        {
-          ...project,
-          ...metadata,
-        },
-      ]
-    }
-
     return {
       projects: {
-        list: createMemo(() => store.projects.flatMap(enrich)),
+        list,
         open(directory: string) {
           if (store.projects.find((x) => x.worktree === directory)) return
           loadProjectSessions(directory)
-          setStore("projects", (x) => [...x, { worktree: directory, expanded: true }])
+          setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
         },
         close(directory: string) {
           setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
@@ -129,6 +167,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("review", "state", "tab")
         },
       },
+      dialog: {
+        opened: createMemo(() => ephemeral.dialog?.open),
+        open(dialog: "provider" | "model") {
+          setEphemeral("dialog", "open", dialog)
+        },
+        close(dialog: "provider" | "model") {
+          if (ephemeral.dialog?.open === dialog) {
+            setEphemeral("dialog", "open", undefined)
+          }
+        },
+      },
     }
   },
 })

+ 23 - 8
packages/desktop/src/context/local.tsx

@@ -25,6 +25,7 @@ export type View = LocalFile["view"]
 
 export type LocalModel = Omit<Model, "provider"> & {
   provider: Provider
+  latest?: boolean
 }
 export type ModelKey = { providerID: string; modelID: string }
 
@@ -38,8 +39,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const sync = useSync()
 
     function isModelValid(model: ModelKey) {
-      const provider = sync.data.provider.find((x) => x.id === model.providerID)
-      return !!provider?.models[model.modelID]
+      const provider = sync.data.provider?.all.find((x) => x.id === model.providerID)
+      return !!provider?.models[model.modelID] && sync.data.provider?.connected.includes(model.providerID)
     }
 
     function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -114,7 +115,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       })
 
       const list = createMemo(() =>
-        sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
+        sync.data.provider.all
+          .filter((p) => sync.data.provider.connected.includes(p.id))
+          .flatMap((p) =>
+            Object.values(p.models).map((m) => ({
+              ...m,
+              name: m.name.replace("(latest)", "").trim(),
+              provider: p,
+              latest: m.name.includes("(latest)"),
+            })),
+          ),
       )
       const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
 
@@ -134,12 +144,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             return item
           }
         }
-        const provider = sync.data.provider[0]
-        const model = Object.values(provider.models)[0]
-        return {
-          providerID: provider.id,
-          modelID: model.id,
+
+        for (const p of sync.data.provider.connected) {
+          if (p in sync.data.provider.default) {
+            return {
+              providerID: p,
+              modelID: sync.data.provider.default[p],
+            }
+          }
         }
+
+        throw new Error("No default model found")
       })
 
       const currentModel = createMemo(() => {

+ 1 - 1
packages/desktop/src/context/session.tsx

@@ -94,7 +94,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
       () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
     )
     const model = createMemo(() =>
-      last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+      last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
     )
     const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 

+ 3 - 3
packages/desktop/src/context/sync.tsx

@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     const load = {
       project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)),
-      provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
+      provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)),
       path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
       agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
       session: () =>
@@ -42,8 +42,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         return store.ready
       },
       get project() {
-        const match = Binary.search(globalSync.data.projects, store.project, (p) => p.id)
-        if (match.found) return globalSync.data.projects[match.index]
+        const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
+        if (match.found) return globalSync.data.project[match.index]
         return undefined
       },
       session: {

+ 31 - 0
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,
+  }))
+}

+ 2 - 2
packages/desktop/src/pages/home.tsx

@@ -38,7 +38,7 @@ export default function Home() {
     <div class="mx-auto mt-55">
       <Logo class="w-xl opacity-12" />
       <Switch>
-        <Match when={sync.data.projects.length > 0}>
+        <Match when={sync.data.project.length > 0}>
           <div class="mt-20 w-full flex flex-col gap-4">
             <div class="flex gap-2 items-center justify-between pl-3">
               <div class="text-14-medium text-text-strong">Recent projects</div>
@@ -50,7 +50,7 @@ export default function Home() {
             </div>
             <ul class="flex flex-col gap-2">
               <For
-                each={sync.data.projects
+                each={sync.data.project
                   .toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
                   .slice(0, 5)}
               >

+ 105 - 3
packages/desktop/src/pages/layout.tsx

@@ -9,6 +9,7 @@ import { Avatar } from "@opencode-ai/ui/avatar"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
@@ -29,6 +30,10 @@ import {
   useDragDropContext,
 } from "@thisbeyond/solid-dnd"
 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"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -44,6 +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 = useProviders()
 
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
@@ -82,12 +88,21 @@ export default function Layout(props: ParentProps) {
     }
   }
 
+  async function connectProvider() {
+    layout.dialog.open("provider")
+  }
+
   createEffect(() => {
     if (!params.dir || !params.id) return
     const directory = base64Decode(params.dir)
     setStore("lastSession", directory, params.id)
   })
 
+  createEffect(() => {
+    const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
+    document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+  })
+
   function getDraggableId(event: unknown): string | undefined {
     if (typeof event !== "object" || event === null) return undefined
     if (!("draggable" in event)) return undefined
@@ -465,10 +480,44 @@ export default function Layout(props: ParentProps) {
             </DragDropProvider>
           </div>
           <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+            <Switch>
+              <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>
+                    <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+                    <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
+                  </div>
+                  <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+                    <Button
+                      class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
+                      size="large"
+                      icon="plus-small"
+                      onClick={connectProvider}
+                    >
+                      <Show when={layout.sidebar.opened()}>Connect provider</Show>
+                    </Button>
+                  </Tooltip>
+                </div>
+              </Match>
+              <Match when={true}>
+                <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+                  <Button
+                    class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
+                    variant="ghost"
+                    size="large"
+                    icon="plus-small"
+                    onClick={connectProvider}
+                  >
+                    <Show when={layout.sidebar.opened()}>Connect provider</Show>
+                  </Button>
+                </Tooltip>
+              </Match>
+            </Switch>
             <Show when={platform.openDirectoryPickerDialog}>
               <Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
                 <Button
-                  class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+                  class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
                   variant="ghost"
                   size="large"
                   icon="folder-add-left"
@@ -481,7 +530,7 @@ export default function Layout(props: ParentProps) {
             <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
               <Button
                 disabled
-                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
                 variant="ghost"
                 size="large"
                 icon="settings-gear"
@@ -494,7 +543,7 @@ export default function Layout(props: ParentProps) {
                 as={"a"}
                 href="https://opencode.ai/desktop-feedback"
                 target="_blank"
-                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
                 variant="ghost"
                 size="large"
                 icon="bubble-5"
@@ -505,6 +554,59 @@ export default function Layout(props: ParentProps) {
           </div>
         </div>
         <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
+        <Show when={layout.dialog.opened() === "provider"}>
+          <SelectDialog
+            defaultOpen
+            title="Connect provider"
+            placeholder="Search providers"
+            activeIcon="plus-small"
+            key={(x) => x?.id}
+            items={providers().all}
+            // current={local.model.current()}
+            filterKeys={["id", "name"]}
+            groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
+            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)
+            }}
+            sortGroupsBy={(a, b) => {
+              if (a.category === "Popular" && b.category !== "Popular") return -1
+              if (b.category === "Popular" && a.category !== "Popular") return 1
+              return 0
+            }}
+            // onSelect={(x) => }
+            onOpenChange={(open) => {
+              if (open) {
+                layout.dialog.open("provider")
+              } else {
+                layout.dialog.close("provider")
+              }
+            }}
+          >
+            {(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={{
+                    "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>
+            )}
+          </SelectDialog>
+        </Show>
       </div>
     </div>
   )

+ 1 - 1
packages/enterprise/src/routes/share/[shareID].tsx

@@ -212,7 +212,7 @@ export default function () {
                           <div class="text-12-mono text-text-base">v{info().version}</div>
                         </div>
                         <div class="flex gap-2 items-center">
-                          <ProviderIcon name={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
+                          <ProviderIcon id={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
                           <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
                         </div>
                         <div class="text-12-regular text-text-weaker">

+ 158 - 136
packages/opencode/src/cli/cmd/auth.ts

@@ -10,6 +10,154 @@ import { Config } from "../../config/config"
 import { Global } from "../../global"
 import { Plugin } from "../../plugin"
 import { Instance } from "../../project/instance"
+import type { Hooks } from "@opencode-ai/plugin"
+
+type PluginAuth = NonNullable<Hooks["auth"]>
+
+/**
+ * Handle plugin-based authentication flow.
+ * Returns true if auth was handled, false if it should fall through to default handling.
+ */
+async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
+  let index = 0
+  if (plugin.auth.methods.length > 1) {
+    const method = await prompts.select({
+      message: "Login method",
+      options: [
+        ...plugin.auth.methods.map((x, index) => ({
+          label: x.label,
+          value: index.toString(),
+        })),
+      ],
+    })
+    if (prompts.isCancel(method)) throw new UI.CancelledError()
+    index = parseInt(method)
+  }
+  const method = plugin.auth.methods[index]
+
+  // Handle prompts for all auth types
+  await new Promise((resolve) => setTimeout(resolve, 10))
+  const inputs: Record<string, string> = {}
+  if (method.prompts) {
+    for (const prompt of method.prompts) {
+      if (prompt.condition && !prompt.condition(inputs)) {
+        continue
+      }
+      if (prompt.type === "select") {
+        const value = await prompts.select({
+          message: prompt.message,
+          options: prompt.options,
+        })
+        if (prompts.isCancel(value)) throw new UI.CancelledError()
+        inputs[prompt.key] = value
+      } else {
+        const value = await prompts.text({
+          message: prompt.message,
+          placeholder: prompt.placeholder,
+          validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
+        })
+        if (prompts.isCancel(value)) throw new UI.CancelledError()
+        inputs[prompt.key] = value
+      }
+    }
+  }
+
+  if (method.type === "oauth") {
+    const authorize = await method.authorize(inputs)
+
+    if (authorize.url) {
+      prompts.log.info("Go to: " + authorize.url)
+    }
+
+    if (authorize.method === "auto") {
+      if (authorize.instructions) {
+        prompts.log.info(authorize.instructions)
+      }
+      const spinner = prompts.spinner()
+      spinner.start("Waiting for authorization...")
+      const result = await authorize.callback()
+      if (result.type === "failed") {
+        spinner.stop("Failed to authorize", 1)
+      }
+      if (result.type === "success") {
+        const saveProvider = result.provider ?? provider
+        if ("refresh" in result) {
+          const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
+          await Auth.set(saveProvider, {
+            type: "oauth",
+            refresh,
+            access,
+            expires,
+            ...extraFields,
+          })
+        }
+        if ("key" in result) {
+          await Auth.set(saveProvider, {
+            type: "api",
+            key: result.key,
+          })
+        }
+        spinner.stop("Login successful")
+      }
+    }
+
+    if (authorize.method === "code") {
+      const code = await prompts.text({
+        message: "Paste the authorization code here: ",
+        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+      })
+      if (prompts.isCancel(code)) throw new UI.CancelledError()
+      const result = await authorize.callback(code)
+      if (result.type === "failed") {
+        prompts.log.error("Failed to authorize")
+      }
+      if (result.type === "success") {
+        const saveProvider = result.provider ?? provider
+        if ("refresh" in result) {
+          const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
+          await Auth.set(saveProvider, {
+            type: "oauth",
+            refresh,
+            access,
+            expires,
+            ...extraFields,
+          })
+        }
+        if ("key" in result) {
+          await Auth.set(saveProvider, {
+            type: "api",
+            key: result.key,
+          })
+        }
+        prompts.log.success("Login successful")
+      }
+    }
+
+    prompts.outro("Done")
+    return true
+  }
+
+  if (method.type === "api") {
+    if (method.authorize) {
+      const result = await method.authorize(inputs)
+      if (result.type === "failed") {
+        prompts.log.error("Failed to authorize")
+      }
+      if (result.type === "success") {
+        const saveProvider = result.provider ?? provider
+        await Auth.set(saveProvider, {
+          type: "api",
+          key: result.key,
+        })
+        prompts.log.success("Login successful")
+      }
+      prompts.outro("Done")
+      return true
+    }
+  }
+
+  return false
+}
 
 export const AuthCommand = cmd({
   command: "auth",
@@ -160,142 +308,8 @@ export const AuthLoginCommand = cmd({
 
         const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
         if (plugin && plugin.auth) {
-          let index = 0
-          if (plugin.auth.methods.length > 1) {
-            const method = await prompts.select({
-              message: "Login method",
-              options: [
-                ...plugin.auth.methods.map((x, index) => ({
-                  label: x.label,
-                  value: index.toString(),
-                })),
-              ],
-            })
-            if (prompts.isCancel(method)) throw new UI.CancelledError()
-            index = parseInt(method)
-          }
-          const method = plugin.auth.methods[index]
-
-          // Handle prompts for all auth types
-          await new Promise((resolve) => setTimeout(resolve, 10))
-          const inputs: Record<string, string> = {}
-          if (method.prompts) {
-            for (const prompt of method.prompts) {
-              if (prompt.condition && !prompt.condition(inputs)) {
-                continue
-              }
-              if (prompt.type === "select") {
-                const value = await prompts.select({
-                  message: prompt.message,
-                  options: prompt.options,
-                })
-                if (prompts.isCancel(value)) throw new UI.CancelledError()
-                inputs[prompt.key] = value
-              } else {
-                const value = await prompts.text({
-                  message: prompt.message,
-                  placeholder: prompt.placeholder,
-                  validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
-                })
-                if (prompts.isCancel(value)) throw new UI.CancelledError()
-                inputs[prompt.key] = value
-              }
-            }
-          }
-
-          if (method.type === "oauth") {
-            const authorize = await method.authorize(inputs)
-
-            if (authorize.url) {
-              prompts.log.info("Go to: " + authorize.url)
-            }
-
-            if (authorize.method === "auto") {
-              if (authorize.instructions) {
-                prompts.log.info(authorize.instructions)
-              }
-              const spinner = prompts.spinner()
-              spinner.start("Waiting for authorization...")
-              const result = await authorize.callback()
-              if (result.type === "failed") {
-                spinner.stop("Failed to authorize", 1)
-              }
-              if (result.type === "success") {
-                const saveProvider = result.provider ?? provider
-                if ("refresh" in result) {
-                  const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
-                  await Auth.set(saveProvider, {
-                    type: "oauth",
-                    refresh,
-                    access,
-                    expires,
-                    ...extraFields,
-                  })
-                }
-                if ("key" in result) {
-                  await Auth.set(saveProvider, {
-                    type: "api",
-                    key: result.key,
-                  })
-                }
-                spinner.stop("Login successful")
-              }
-            }
-
-            if (authorize.method === "code") {
-              const code = await prompts.text({
-                message: "Paste the authorization code here: ",
-                validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-              })
-              if (prompts.isCancel(code)) throw new UI.CancelledError()
-              const result = await authorize.callback(code)
-              if (result.type === "failed") {
-                prompts.log.error("Failed to authorize")
-              }
-              if (result.type === "success") {
-                const saveProvider = result.provider ?? provider
-                if ("refresh" in result) {
-                  const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
-                  await Auth.set(saveProvider, {
-                    type: "oauth",
-                    refresh,
-                    access,
-                    expires,
-                    ...extraFields,
-                  })
-                }
-                if ("key" in result) {
-                  await Auth.set(saveProvider, {
-                    type: "api",
-                    key: result.key,
-                  })
-                }
-                prompts.log.success("Login successful")
-              }
-            }
-
-            prompts.outro("Done")
-            return
-          }
-
-          if (method.type === "api") {
-            if (method.authorize) {
-              const result = await method.authorize(inputs)
-              if (result.type === "failed") {
-                prompts.log.error("Failed to authorize")
-              }
-              if (result.type === "success") {
-                const saveProvider = result.provider ?? provider
-                await Auth.set(saveProvider, {
-                  type: "api",
-                  key: result.key,
-                })
-                prompts.log.success("Login successful")
-              }
-              prompts.outro("Done")
-              return
-            }
-          }
+          const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
+          if (handled) return
         }
 
         if (provider === "other") {
@@ -306,6 +320,14 @@ export const AuthLoginCommand = cmd({
           if (prompts.isCancel(provider)) throw new UI.CancelledError()
           provider = provider.replace(/^@ai-sdk\//, "")
           if (prompts.isCancel(provider)) throw new UI.CancelledError()
+
+          // Check if a plugin provides auth for this custom provider
+          const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+          if (customPlugin && customPlugin.auth) {
+            const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
+            if (handled) return
+          }
+
           prompts.log.warn(
             `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
           )

+ 1 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -186,7 +186,7 @@ function App() {
 
       // Truncate title to 40 chars max
       const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
-      renderer.setTerminalTitle(`oc | ${title}`)
+      renderer.setTerminalTitle(`OC | ${title}`)
     }
   })
 

+ 2 - 1
packages/opencode/src/cli/cmd/tui/context/directory.ts

@@ -5,7 +5,8 @@ import { Global } from "@/global"
 export function useDirectory() {
   const sync = useSync()
   return createMemo(() => {
-    const result = process.cwd().replace(Global.Path.home, "~")
+    const directory = sync.data.path.directory || process.cwd()
+    const result = directory.replace(Global.Path.home, "~")
     if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
     return result
   })

+ 4 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -24,6 +24,7 @@ import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
 import { batch, onMount } from "solid-js"
 import { Log } from "@/util/log"
+import type { Path } from "@opencode-ai/sdk"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -62,6 +63,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       }
       formatter: FormatterStatus[]
       vcs: VcsInfo | undefined
+      path: Path
     }>({
       provider_next: {
         all: [],
@@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       mcp: {},
       formatter: [],
       vcs: undefined,
+      path: { state: "", config: "", worktree: "", directory: "" },
     })
 
     const sdk = useSDK()
@@ -286,6 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
             sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
             sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
+            sdk.client.path.get().then((x) => setStore("path", x.data!)),
           ]).then(() => {
             setStore("status", "complete")
           })

+ 9 - 5
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1503,11 +1503,15 @@ ToolRegistry.register<typeof TaskTool>({
         <Show when={props.metadata.summary?.length}>
           <box>
             <For each={props.metadata.summary ?? []}>
-              {(task) => (
-                <text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
-                  ∟ {Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""}
-                </text>
-              )}
+              {(task, index) => {
+                const summary = props.metadata.summary ?? []
+                return (
+                  <text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
+                    {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
+                    {task.state.status === "completed" ? task.state.title : ""}
+                  </text>
+                )
+              }}
             </For>
           </box>
         </Show>

+ 2 - 0
packages/opencode/src/flag/flag.ts

@@ -14,6 +14,8 @@ export namespace Flag {
 
   // Experimental
   export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+  export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
+    OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
   export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
   export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
   export const OPENCODE_ENABLE_EXA =

+ 1 - 1
packages/opencode/src/project/project.ts

@@ -107,7 +107,7 @@ export namespace Project {
         await migrateFromGlobal(id, worktree)
       }
     }
-    if (Flag.OPENCODE_EXPERIMENTAL) discover(existing)
+    if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
     const result: Info = {
       ...existing,
       worktree,

+ 1 - 1
packages/opencode/src/session/compaction.ts

@@ -174,7 +174,7 @@ export namespace SessionCompaction {
           content: [
             {
               type: "text",
-              text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
+              text: "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.",
             },
           ],
         },

+ 7 - 2
packages/opencode/src/session/prompt.ts

@@ -338,6 +338,7 @@ export namespace SessionPrompt {
             },
           },
         })) as MessageV2.ToolPart
+        let executionError: Error | undefined
         const result = await taskTool
           .execute(
             {
@@ -362,7 +363,11 @@ export namespace SessionPrompt {
               },
             },
           )
-          .catch(() => {})
+          .catch((error) => {
+            executionError = error
+            log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
+            return undefined
+          })
         assistantMessage.finish = "tool-calls"
         assistantMessage.time.completed = Date.now()
         await Session.updateMessage(assistantMessage)
@@ -388,7 +393,7 @@ export namespace SessionPrompt {
             ...part,
             state: {
               status: "error",
-              error: "Tool execution failed",
+              error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
               time: {
                 start: part.state.status === "running" ? part.state.time.start : Date.now(),
                 end: Date.now(),

+ 398 - 19
packages/opencode/test/tool/bash.test.ts

@@ -3,11 +3,12 @@ import path from "path"
 import { BashTool } from "../../src/tool/bash"
 import { Instance } from "../../src/project/instance"
 import { Permission } from "../../src/permission"
+import { tmpdir } from "../fixture/fixture"
 
 const ctx = {
   sessionID: "test",
   messageID: "",
-  toolCallID: "",
+  callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
@@ -33,23 +34,401 @@ describe("tool.bash", () => {
       },
     })
   })
+})
+
+describe("tool.bash permissions", () => {
+  test("allows command matching allow pattern", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "echo *": "allow",
+                "*": "deny",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const result = await bash.execute(
+          {
+            command: "echo hello",
+            description: "Echo hello",
+          },
+          ctx,
+        )
+        expect(result.metadata.exit).toBe(0)
+        expect(result.metadata.output).toContain("hello")
+      },
+    })
+  })
 
-  // TODO: better test
-  // test("cd ../ should ask for permission for external directory", async () => {
-  //   await Instance.provide({
-  //     directory: projectRoot,
-  //     fn: async () => {
-  //       bash.execute(
-  //         {
-  //           command: "cd ../",
-  //           description: "Try to cd to parent directory",
-  //         },
-  //         ctx,
-  //       )
-  //       // Give time for permission to be asked
-  //       await new Promise((resolve) => setTimeout(resolve, 1000))
-  //       expect(Permission.pending()[ctx.sessionID]).toBeDefined()
-  //     },
-  //   })
-  // })
+  test("denies command matching deny pattern", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "curl *": "deny",
+                "*": "allow",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        await expect(
+          bash.execute(
+            {
+              command: "curl https://example.com",
+              description: "Fetch URL",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
+
+  test("denies all commands with wildcard deny", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "*": "deny",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        await expect(
+          bash.execute(
+            {
+              command: "ls",
+              description: "List files",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
+
+  test("more specific pattern overrides general pattern", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "*": "deny",
+                "ls *": "allow",
+                "pwd*": "allow",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        // ls should be allowed
+        const result = await bash.execute(
+          {
+            command: "ls -la",
+            description: "List files",
+          },
+          ctx,
+        )
+        expect(result.metadata.exit).toBe(0)
+
+        // pwd should be allowed
+        const pwd = await bash.execute(
+          {
+            command: "pwd",
+            description: "Print working directory",
+          },
+          ctx,
+        )
+        expect(pwd.metadata.exit).toBe(0)
+
+        // cat should be denied
+        await expect(
+          bash.execute(
+            {
+              command: "cat /etc/passwd",
+              description: "Read file",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
+
+  test("denies dangerous subcommands while allowing safe ones", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "find *": "allow",
+                "find * -delete*": "deny",
+                "find * -exec*": "deny",
+                "*": "deny",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        // Basic find should work
+        const result = await bash.execute(
+          {
+            command: "find . -name '*.ts'",
+            description: "Find typescript files",
+          },
+          ctx,
+        )
+        expect(result.metadata.exit).toBe(0)
+
+        // find -delete should be denied
+        await expect(
+          bash.execute(
+            {
+              command: "find . -name '*.tmp' -delete",
+              description: "Delete temp files",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+
+        // find -exec should be denied
+        await expect(
+          bash.execute(
+            {
+              command: "find . -name '*.ts' -exec cat {} \\;",
+              description: "Find and cat files",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
+
+  test("allows git read commands while denying writes", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "git status*": "allow",
+                "git log*": "allow",
+                "git diff*": "allow",
+                "git branch": "allow",
+                "git commit *": "deny",
+                "git push *": "deny",
+                "*": "deny",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        // git status should work
+        const status = await bash.execute(
+          {
+            command: "git status",
+            description: "Git status",
+          },
+          ctx,
+        )
+        expect(status.metadata.exit).toBe(0)
+
+        // git log should work
+        const log = await bash.execute(
+          {
+            command: "git log --oneline -5",
+            description: "Git log",
+          },
+          ctx,
+        )
+        expect(log.metadata.exit).toBe(0)
+
+        // git commit should be denied
+        await expect(
+          bash.execute(
+            {
+              command: "git commit -m 'test'",
+              description: "Git commit",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+
+        // git push should be denied
+        await expect(
+          bash.execute(
+            {
+              command: "git push origin main",
+              description: "Git push",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
+
+  test("denies external directory access when permission is deny", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              external_directory: "deny",
+              bash: {
+                "*": "allow",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        // Should deny cd to parent directory (cd is checked for external paths)
+        await expect(
+          bash.execute(
+            {
+              command: "cd ../",
+              description: "Change to parent directory",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow()
+      },
+    })
+  })
+
+  test("denies workdir outside project when external_directory is deny", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              external_directory: "deny",
+              bash: {
+                "*": "allow",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        await expect(
+          bash.execute(
+            {
+              command: "ls",
+              workdir: "/tmp",
+              description: "List /tmp",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow()
+      },
+    })
+  })
+
+  test("handles multiple commands in sequence", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            permission: {
+              bash: {
+                "echo *": "allow",
+                "curl *": "deny",
+                "*": "deny",
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        // echo && echo should work
+        const result = await bash.execute(
+          {
+            command: "echo foo && echo bar",
+            description: "Echo twice",
+          },
+          ctx,
+        )
+        expect(result.metadata.output).toContain("foo")
+        expect(result.metadata.output).toContain("bar")
+
+        // echo && curl should fail (curl is denied)
+        await expect(
+          bash.execute(
+            {
+              command: "echo hi && curl https://example.com",
+              description: "Echo then curl",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("restricted")
+      },
+    })
+  })
 })

+ 1 - 0
packages/tauri/src-tauri/capabilities/default.json

@@ -7,6 +7,7 @@
     "core:default",
     "opener:default",
     "core:window:allow-start-dragging",
+    "core:webview:allow-set-webview-zoom",
     "shell:default",
     "updater:default",
     "dialog:default",

+ 11 - 3
packages/tauri/src-tauri/src/lib.rs

@@ -1,5 +1,5 @@
 use std::{
-    net::SocketAddr,
+    net::{SocketAddr, TcpListener},
     process::Command,
     sync::{Arc, Mutex},
     time::{Duration, Instant},
@@ -18,7 +18,13 @@ fn get_sidecar_port() -> u16 {
         .map(|s| s.to_string())
         .or_else(|| std::env::var("OPENCODE_PORT").ok())
         .and_then(|port_str| port_str.parse().ok())
-        .unwrap_or(4096)
+        .unwrap_or_else(|| {
+            TcpListener::bind("127.0.0.1:0")
+                .expect("Failed to bind to find free port")
+                .local_addr()
+                .expect("Failed to get local address")
+                .port()
+        })
 }
 
 fn find_and_kill_process_on_port(port: u16) -> Result<(), Box<dyn std::error::Error>> {
@@ -60,6 +66,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) -> CommandChild {
         .shell()
         .sidecar("opencode")
         .unwrap()
+        .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
         .args(["serve", &format!("--port={port}")])
         .spawn()
         .expect("Failed to spawn opencode");
@@ -168,7 +175,8 @@ pub fn run() {
                         .initialization_script(format!(
                             r#"
                           window.__OPENCODE__ ??= {{}};
-                          window.__OPENCODE__.updaterEnabled = {updater_enabled}
+                          window.__OPENCODE__.updaterEnabled = {updater_enabled};
+                          window.__OPENCODE__.port = {port};
                         "#
                         ));
 

+ 0 - 6
packages/tauri/src/index.tsx

@@ -47,12 +47,6 @@ const platform: Platform = {
   },
 }
 
-declare global {
-  interface Window {
-    __OPENCODE__?: { updaterEnabled?: boolean }
-  }
-}
-
 render(() => {
   onMount(() => {
     if (window.__OPENCODE__?.updaterEnabled) runUpdater()

+ 0 - 14
packages/ui/index.html

@@ -1,14 +0,0 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#000000" />
-    <title>OpenCode UI</title>
-  </head>
-  <body>
-    <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
-    <script src="/src/index.tsx" type="module"></script>
-  </body>
-</html>

+ 0 - 1
packages/ui/package.json

@@ -17,7 +17,6 @@
   "scripts": {
     "typecheck": "tsgo --noEmit",
     "dev": "vite",
-    "build": "vite build",
     "generate:tailwind": "bun run script/tailwind.ts"
   },
   "devDependencies": {

+ 4 - 3
packages/ui/src/components/avatar.tsx

@@ -9,22 +9,23 @@ export interface AvatarProps extends ComponentProps<"div"> {
 
 export function Avatar(props: AvatarProps) {
   const [split, rest] = splitProps(props, ["fallback", "src", "background", "size", "class", "classList", "style"])
+  const src = split.src // did this so i can zero it out to test fallback
   return (
     <div
       {...rest}
       data-component="avatar"
       data-size={split.size || "normal"}
-      data-has-image={split.src ? "" : undefined}
+      data-has-image={src ? "" : undefined}
       classList={{
         ...(split.classList ?? {}),
         [split.class ?? ""]: !!split.class,
       }}
       style={{
         ...(typeof split.style === "object" ? split.style : {}),
-        ...(!split.src && split.background ? { "--avatar-bg": split.background } : {}),
+        ...(!src && split.background ? { "--avatar-bg": split.background } : {}),
       }}
     >
-      <Show when={split.src} fallback={split.fallback?.[0]}>
+      <Show when={src} fallback={split.fallback?.[0]}>
         {(src) => <img src={src()} draggable={false} class="size-full object-cover" />}
       </Show>
     </div>

+ 13 - 5
packages/ui/src/components/button.css

@@ -102,12 +102,20 @@
     height: 24px;
     padding: 0 6px;
     &[data-icon] {
-      padding: 0 8px 0 6px;
+      padding: 0 12px 0 4px;
     }
 
     font-size: var(--font-size-small);
     line-height: var(--line-height-large);
     gap: 6px;
+
+    /* 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: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
   }
 
   &[data-size="large"] {
@@ -115,17 +123,17 @@
     padding: 0 8px;
 
     &[data-icon] {
-      padding: 0 8px 0 6px;
+      padding: 0 12px 0 8px;
     }
 
     gap: 8px;
 
-    /* text-12-medium */
+    /* text-14-medium */
     font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
+    font-size: 14px;
     font-style: normal;
     font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large); /* 166.667% */
+    line-height: var(--line-height-large); /* 142.857% */
     letter-spacing: var(--letter-spacing-normal);
   }
 

+ 8 - 6
packages/ui/src/components/dialog.css

@@ -16,6 +16,7 @@
 [data-component="dialog"] {
   position: fixed;
   inset: 0;
+  margin-left: var(--dialog-left-margin);
   z-index: 50;
   display: flex;
   align-items: center;
@@ -24,7 +25,7 @@
   [data-slot="dialog-container"] {
     position: relative;
     z-index: 50;
-    width: min(calc(100vw - 16px), 624px);
+    width: min(calc(100vw - 16px), 480px);
     height: min(calc(100vh - 16px), 512px);
     display: flex;
     flex-direction: column;
@@ -36,14 +37,14 @@
       flex-direction: column;
       align-items: flex-start;
       align-self: stretch;
-      gap: 8px;
       width: 100%;
       max-height: 100%;
+      min-height: 280px;
 
       /* padding: 8px; */
-      padding: 8px 8px 0 8px;
+      /* padding: 8px 8px 0 8px; */
       border: 1px solid var(--border-base);
-      border-radius: var(--radius-md);
+      border-radius: var(--radius-xl);
       background: var(--surface-raised-stronger-non-alpha);
       box-shadow:
         0 15px 45px 0 rgba(19, 16, 16, 0.22),
@@ -58,8 +59,9 @@
 
       [data-slot="dialog-header"] {
         display: flex;
-        height: 40px;
-        padding: 4px 4px 4px 8px;
+        /* height: 40px; */
+        /* padding: 4px 4px 4px 8px; */
+        padding: 20px;
         justify-content: space-between;
         align-items: center;
         flex-shrink: 0;

+ 1 - 1
packages/ui/src/components/dialog.tsx

@@ -79,7 +79,7 @@ function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">)
 }
 
 function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
-  return <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" {...props} />
+  return <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" {...props} />
 }
 
 export const Dialog = Object.assign(DialogRoot, {

File diff suppressed because it is too large
+ 0 - 101
packages/ui/src/components/icon.tsx


+ 2 - 0
packages/ui/src/components/input.css

@@ -1,7 +1,9 @@
 [data-component="input"] {
+  width: 100%;
   /* [data-slot="input-label"] {} */
 
   [data-slot="input-input"] {
+    width: 100%;
     color: var(--text-strong);
 
     /* text-14-regular */

+ 9 - 1
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}

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

@@ -0,0 +1,115 @@
+[data-component="list"] {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+
+  [data-slot="list-empty-state"] {
+    display: flex;
+    padding: 32px 0px;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    gap: 8px;
+    align-self: stretch;
+
+    [data-slot="list-message"] {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      gap: 2px;
+      color: var(--text-weak);
+      text-align: center;
+
+      /* 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);
+    }
+
+    [data-slot="list-filter"] {
+      color: var(--text-strong);
+    }
+  }
+
+  [data-slot="list-group"] {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+
+    [data-slot="list-header"] {
+      display: flex;
+      height: 28px;
+      padding: 0 10px;
+      justify-content: space-between;
+      align-items: center;
+      align-self: stretch;
+      background: var(--surface-raised-stronger-non-alpha);
+      position: sticky;
+      top: 0;
+
+      color: var(--text-base);
+
+      /* text-14-medium */
+      font-family: var(--font-family-sans);
+      font-size: 14px;
+      font-style: normal;
+      font-weight: var(--font-weight-medium);
+      line-height: var(--line-height-large); /* 142.857% */
+      letter-spacing: var(--letter-spacing-normal);
+    }
+
+    [data-slot="list-items"] {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      align-self: stretch;
+
+      [data-slot="list-item"] {
+        display: flex;
+        width: 100%;
+        height: 28px;
+        padding: 4px 10px;
+        align-items: center;
+        color: var(--text-strong);
+
+        /* text-14-medium */
+        font-family: var(--font-family-sans);
+        font-size: 14px;
+        font-style: normal;
+        font-weight: var(--font-weight-medium);
+        line-height: var(--line-height-large); /* 142.857% */
+        letter-spacing: var(--letter-spacing-normal);
+
+        [data-slot="list-item-selected-icon"] {
+          color: var(--icon-strong-base);
+        }
+        [data-slot="list-item-active-icon"] {
+          display: none;
+          color: var(--icon-strong-base);
+        }
+
+        &[data-active="true"] {
+          border-radius: var(--radius-md);
+          background: var(--surface-raised-base-hover);
+          [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;
+          }
+        }
+      }
+    }
+  }
+}

+ 142 - 0
packages/ui/src/components/list.tsx

@@ -0,0 +1,142 @@
+import { createEffect, Show, For, type JSX, createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
+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
+  activeIcon?: IconProps["name"]
+  filter?: string
+}
+
+export interface ListRef {
+  onKeyDown: (e: KeyboardEvent) => void
+  setScrollRef: (el: HTMLDivElement | undefined) => void
+}
+
+export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
+  const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
+  const [store, setStore] = createStore({
+    mouseActive: false,
+  })
+
+  const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
+    items: props.items,
+    key: props.key,
+    filterKeys: props.filterKeys,
+    current: props.current,
+    groupBy: props.groupBy,
+    sortBy: props.sortBy,
+    sortGroupsBy: props.sortGroupsBy,
+  })
+
+  createEffect(() => {
+    if (props.filter === undefined) return
+    onInput(props.filter)
+  })
+
+  createEffect(() => {
+    filter()
+    scrollRef()?.scrollTo(0, 0)
+    reset()
+  })
+
+  createEffect(() => {
+    if (!scrollRef()) return
+    if (!props.current) return
+    const key = props.key(props.current)
+    requestAnimationFrame(() => {
+      const element = scrollRef()!.querySelector(`[data-key="${key}"]`)
+      element?.scrollIntoView({ block: "center" })
+    })
+  })
+
+  createEffect(() => {
+    const all = flat()
+    if (store.mouseActive || all.length === 0) return
+    if (active() === props.key(all[0])) {
+      scrollRef()?.scrollTo(0, 0)
+      return
+    }
+    const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
+    element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+  })
+
+  const handleSelect = (item: T | undefined) => {
+    props.onSelect?.(item)
+  }
+
+  const handleKey = (e: KeyboardEvent) => {
+    setStore("mouseActive", false)
+    if (e.key === "Escape") return
+
+    const all = flat()
+    const selected = all.find((x) => props.key(x) === active())
+    props.onKeyEvent?.(e, selected)
+
+    if (e.key === "Enter") {
+      e.preventDefault()
+      if (selected) handleSelect(selected)
+    } else {
+      onKeyDown(e)
+    }
+  }
+
+  props.ref?.({
+    onKeyDown: handleKey,
+    setScrollRef,
+  })
+
+  return (
+    <div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
+      <Show
+        when={flat().length > 0}
+        fallback={
+          <div data-slot="list-empty-state">
+            <div data-slot="list-message">
+              {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
+            </div>
+          </div>
+        }
+      >
+        <For each={grouped()}>
+          {(group) => (
+            <div data-slot="list-group">
+              <Show when={group.category}>
+                <div data-slot="list-header">{group.category}</div>
+              </Show>
+              <div data-slot="list-items">
+                <For each={group.items}>
+                  {(item) => (
+                    <button
+                      data-slot="list-item"
+                      data-key={props.key(item)}
+                      data-active={props.key(item) === active()}
+                      data-selected={item === props.current}
+                      onClick={() => handleSelect(item)}
+                      onMouseMove={() => {
+                        setStore("mouseActive", true)
+                        setActive(props.key(item))
+                      }}
+                    >
+                      {props.children(item)}
+                      <Show when={item === props.current}>
+                        <Icon data-slot="list-item-selected-icon" name="check-small" />
+                      </Show>
+                      <Show when={props.activeIcon}>
+                        {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+                      </Show>
+                    </button>
+                  )}
+                </For>
+              </div>
+            </div>
+          )}
+        </For>
+      </Show>
+    </div>
+  )
+}

+ 3 - 3
packages/ui/src/components/provider-icon.tsx

@@ -4,11 +4,11 @@ import sprite from "./provider-icons/sprite.svg"
 import type { IconName } from "./provider-icons/types"
 
 export type ProviderIconProps = JSX.SVGElementTags["svg"] & {
-  name: IconName
+  id: IconName
 }
 
 export const ProviderIcon: Component<ProviderIconProps> = (props) => {
-  const [local, rest] = splitProps(props, ["name", "class", "classList"])
+  const [local, rest] = splitProps(props, ["id", "class", "classList"])
   return (
     <svg
       data-component="provider-icon"
@@ -18,7 +18,7 @@ export const ProviderIcon: Component<ProviderIconProps> = (props) => {
         [local.class ?? ""]: !!local.class,
       }}
     >
-      <use href={`${sprite}#${local.name}`} />
+      <use href={`${sprite}#${local.id}`} />
     </svg>
   )
 }

+ 19 - 84
packages/ui/src/components/select-dialog.css

@@ -1,8 +1,25 @@
+[data-slot="select-dialog-content"] {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  gap: 20px;
+  padding: 0 10px;
+
+  [data-slot="dialog-body"] {
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
+}
+
 [data-component="select-dialog-input"] {
   display: flex;
   height: 40px;
   flex-shrink: 0;
-  padding: 4px 10px 4px 6px;
+  padding: 4px 10px 4px 16px;
   align-items: center;
   gap: 12px;
   align-self: stretch;
@@ -13,7 +30,7 @@
   [data-slot="select-dialog-input-container"] {
     display: flex;
     align-items: center;
-    gap: 12px;
+    gap: 16px;
     flex: 1 0 0;
 
     /* [data-slot="select-dialog-icon"] {} */
@@ -25,85 +42,3 @@
 
   /* [data-slot="select-dialog-clear-button"] {} */
 }
-
-[data-component="select-dialog"] {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-
-  [data-slot="select-dialog-empty-state"] {
-    display: flex;
-    padding: 32px 160px;
-    flex-direction: column;
-    justify-content: center;
-    align-items: center;
-    gap: 8px;
-    align-self: stretch;
-
-    [data-slot="select-dialog-message"] {
-      display: flex;
-      justify-content: center;
-      align-items: center;
-      gap: 2px;
-      color: var(--text-weak);
-      text-align: center;
-
-      /* 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);
-    }
-
-    [data-slot="select-dialog-filter"] {
-      color: var(--text-strong);
-    }
-  }
-
-  [data-slot="select-dialog-group"] {
-    display: flex;
-    flex-direction: column;
-    gap: 4px;
-
-    [data-slot="select-dialog-header"] {
-      display: flex;
-      padding: 4px 8px;
-      justify-content: space-between;
-      align-items: center;
-      align-self: stretch;
-
-      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: var(--line-height-large); /* 166.667% */
-      letter-spacing: var(--letter-spacing-normal);
-    }
-
-    [data-slot="select-dialog-list"] {
-      display: flex;
-      flex-direction: column;
-      align-items: flex-start;
-      gap: 4px;
-      align-self: stretch;
-
-      [data-slot="select-dialog-item"] {
-        display: flex;
-        width: 100%;
-        height: 32px;
-        padding: 4px 8px 4px 4px;
-        align-items: center;
-
-        &[data-active="true"] {
-          border-radius: var(--radius-md);
-          background: var(--surface-raised-base-hover);
-        }
-      }
-    }
-  }
-}

+ 59 - 121
packages/ui/src/components/select-dialog.tsx

@@ -1,85 +1,46 @@
-import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
-import { createStore } from "solid-js/store"
-import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js"
 import { Dialog, DialogProps } from "./dialog"
 import { Icon } from "./icon"
 import { Input } from "./input"
 import { IconButton } from "./icon-button"
+import { List, ListRef, ListProps } from "./list"
 
 interface SelectDialogProps<T>
-  extends FilteredListProps<T>,
+  extends Omit<ListProps<T>, "filter">,
     Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
   title: string
   placeholder?: string
-  emptyMessage?: string
-  children: (item: T) => JSX.Element
-  onSelect?: (value: T | undefined) => void
-  onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
+  actions?: JSX.Element
 }
 
 export function SelectDialog<T>(props: SelectDialogProps<T>) {
   const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
   let closeButton!: HTMLButtonElement
-  let scrollRef: HTMLDivElement | undefined
-  const [store, setStore] = createStore({
-    mouseActive: false,
-  })
-
-  const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
-    items: others.items,
-    key: others.key,
-    filterKeys: others.filterKeys,
-    current: others.current,
-    groupBy: others.groupBy,
-    sortBy: others.sortBy,
-    sortGroupsBy: others.sortGroupsBy,
-  })
-
-  createEffect(() => {
-    filter()
-    scrollRef?.scrollTo(0, 0)
-    reset()
-  })
+  let inputRef: HTMLInputElement | undefined
+  const [filter, setFilter] = createSignal("")
+  let listRef: ListRef | undefined
 
   createEffect(() => {
-    const all = flat()
-    if (store.mouseActive || all.length === 0) return
-    if (active() === others.key(all[0])) {
-      scrollRef?.scrollTo(0, 0)
-      return
-    }
-    const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
-    element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+    if (!props.current) return
+    const key = props.key(props.current)
+    requestAnimationFrame(() => {
+      const element = document.querySelector(`[data-key="${key}"]`)
+      element?.scrollIntoView({ block: "center" })
+    })
   })
 
-  const handleInput = (value: string) => {
-    onInput(value)
-    reset()
-  }
-
   const handleSelect = (item: T | undefined) => {
     others.onSelect?.(item)
     closeButton.click()
   }
 
   const handleKey = (e: KeyboardEvent) => {
-    setStore("mouseActive", false)
     if (e.key === "Escape") return
-
-    const all = flat()
-    const selected = all.find((x) => others.key(x) === active())
-    props.onKeyEvent?.(e, selected)
-
-    if (e.key === "Enter") {
-      e.preventDefault()
-      if (selected) handleSelect(selected)
-    } else {
-      onKeyDown(e)
-    }
+    listRef?.onKeyDown(e)
   }
 
   const handleOpenChange = (open: boolean) => {
-    if (!open) clear()
+    if (!open) setFilter("")
     props.onOpenChange?.(open)
   }
 
@@ -87,77 +48,54 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
     <Dialog modal {...dialog} onOpenChange={handleOpenChange}>
       <Dialog.Header>
         <Dialog.Title>{others.title}</Dialog.Title>
-        <Dialog.CloseButton ref={closeButton} style={{ display: "none" }} />
+        <Show when={others.actions}>{others.actions}</Show>
+        <Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: others.actions ? "none" : undefined }} />
       </Dialog.Header>
-      <div data-component="select-dialog-input">
-        <div data-slot="select-dialog-input-container">
-          <Icon name="magnifying-glass" />
-          <Input
-            data-slot="select-dialog-input"
-            type="text"
-            value={filter()}
-            onChange={(value) => handleInput(value)}
-            onKeyDown={handleKey}
-            placeholder={others.placeholder}
-            autofocus
-            spellcheck={false}
-            autocorrect="off"
-            autocomplete="off"
-            autocapitalize="off"
-          />
+      <div data-slot="select-dialog-content">
+        <div data-component="select-dialog-input">
+          <div data-slot="select-dialog-input-container">
+            <Icon name="magnifying-glass" />
+            <Input
+              ref={inputRef}
+              autofocus
+              data-slot="select-dialog-input"
+              type="text"
+              value={filter()}
+              onChange={setFilter}
+              onKeyDown={handleKey}
+              placeholder={others.placeholder}
+              spellcheck={false}
+              autocorrect="off"
+              autocomplete="off"
+              autocapitalize="off"
+            />
+          </div>
+          <Show when={filter()}>
+            <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
+          </Show>
         </div>
-        <Show when={filter()}>
-          <IconButton
-            icon="circle-x"
-            variant="ghost"
-            onClick={() => {
-              onInput("")
-              reset()
+        <Dialog.Body>
+          <List
+            ref={(ref) => {
+              listRef = ref
             }}
-          />
-        </Show>
+            items={others.items}
+            key={others.key}
+            filterKeys={others.filterKeys}
+            current={others.current}
+            groupBy={others.groupBy}
+            sortBy={others.sortBy}
+            sortGroupsBy={others.sortGroupsBy}
+            emptyMessage={others.emptyMessage}
+            activeIcon={others.activeIcon}
+            filter={filter()}
+            onSelect={handleSelect}
+            onKeyEvent={others.onKeyEvent}
+          >
+            {others.children}
+          </List>
+        </Dialog.Body>
       </div>
-      <Dialog.Body ref={scrollRef} data-component="select-dialog" class="no-scrollbar">
-        <Show
-          when={flat().length > 0}
-          fallback={
-            <div data-slot="select-dialog-empty-state">
-              <div data-slot="select-dialog-message">
-                {props.emptyMessage ?? "No search results"} for{" "}
-                <span data-slot="select-dialog-filter">&quot;{filter()}&quot;</span>
-              </div>
-            </div>
-          }
-        >
-          <For each={grouped()}>
-            {(group) => (
-              <div data-slot="select-dialog-group">
-                <Show when={group.category}>
-                  <div data-slot="select-dialog-header">{group.category}</div>
-                </Show>
-                <div data-slot="select-dialog-list">
-                  <For each={group.items}>
-                    {(item) => (
-                      <button
-                        data-slot="select-dialog-item"
-                        data-key={others.key(item)}
-                        data-active={others.key(item) === active()}
-                        onClick={() => handleSelect(item)}
-                        onMouseMove={() => {
-                          setStore("mouseActive", true)
-                          setActive(others.key(item))
-                        }}
-                      >
-                        {others.children(item)}
-                      </button>
-                    )}
-                  </For>
-                </div>
-              </div>
-            )}
-          </For>
-        </Show>
-      </Dialog.Body>
     </Dialog>
   )
 }

+ 37 - 0
packages/ui/src/components/tag.css

@@ -0,0 +1,37 @@
+[data-component="tag"] {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  user-select: none;
+
+  border-radius: var(--radius-xs);
+  border: 0.5px solid var(--border-weak-base);
+  background: var(--surface-raised-base);
+  color: var(--text-base);
+
+  &[data-size="normal"] {
+    height: 18px;
+    padding: 0 6px;
+
+    /* 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: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
+  &[data-size="large"] {
+    height: 22px;
+    padding: 0 8px;
+
+    /* text-14-medium */
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 142.857% */
+    letter-spacing: var(--letter-spacing-normal);
+  }
+}

+ 22 - 0
packages/ui/src/components/tag.tsx

@@ -0,0 +1,22 @@
+import { type ComponentProps, splitProps } from "solid-js"
+
+export interface TagProps extends ComponentProps<"span"> {
+  size?: "normal" | "large"
+}
+
+export function Tag(props: TagProps) {
+  const [split, rest] = splitProps(props, ["size", "class", "classList", "children"])
+  return (
+    <span
+      {...rest}
+      data-component="tag"
+      data-size={split.size || "normal"}
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </span>
+  )
+}

+ 0 - 291
packages/ui/src/demo.tsx

@@ -1,291 +0,0 @@
-import type { Component } from "solid-js"
-import { createSignal } from "solid-js"
-import "./index.css"
-import { Button } from "./components/button"
-import { Select } from "./components/select"
-import { Font } from "./components/font"
-import { Accordion } from "./components/accordion"
-import { Tabs } from "./components/tabs"
-import { Tooltip } from "./components/tooltip"
-import { Input } from "./components/input"
-import { Checkbox } from "./components/checkbox"
-import { Icon } from "./components/icon"
-import { IconButton } from "./components/icon-button"
-import { Dialog } from "./components/dialog"
-import { SelectDialog } from "./components/select-dialog"
-import { Collapsible } from "./components/collapsible"
-
-const Demo: Component = () => {
-  const [dialogOpen, setDialogOpen] = createSignal(false)
-  const [selectDialogOpen, setSelectDialogOpen] = createSignal(false)
-  const [inputValue, setInputValue] = createSignal("")
-  const [checked, setChecked] = createSignal(false)
-  const [termsAccepted, setTermsAccepted] = createSignal(false)
-
-  const Content = (props: { dark?: boolean }) => (
-    <div class={`${props.dark ? "dark" : ""}`}>
-      <h3>Buttons</h3>
-      <section>
-        <Button variant="primary" size="normal">
-          Normal Primary
-        </Button>
-        <Button variant="secondary" size="normal">
-          Normal Secondary
-        </Button>
-        <Button variant="ghost" size="normal">
-          Normal Ghost
-        </Button>
-        <Button variant="secondary" size="normal" disabled>
-          Normal Disabled
-        </Button>
-        <Button variant="primary" size="large">
-          Large Primary
-        </Button>
-        <Button variant="secondary" size="large">
-          Large Secondary
-        </Button>
-        <Button variant="ghost" size="large">
-          Large Ghost
-        </Button>
-        <Button variant="secondary" size="large" disabled>
-          Large Disabled
-        </Button>
-      </section>
-      <h3>Select</h3>
-      <section>
-        <Select
-          class={props.dark ? "dark" : ""}
-          variant="primary"
-          options={["Option 1", "Option 2", "Option 3"]}
-          placeholder="Select Primary"
-        />
-        <Select
-          variant="secondary"
-          class={props.dark ? "dark" : ""}
-          options={["Option 1", "Option 2", "Option 3"]}
-          placeholder="Select Secondary"
-        />
-        <Select
-          variant="ghost"
-          class={props.dark ? "dark" : ""}
-          options={["Option 1", "Option 2", "Option 3"]}
-          placeholder="Select Ghost"
-        />
-      </section>
-      <h3>Tabs</h3>
-      <section>
-        <Tabs defaultValue="tab1" style={{ width: "100%" }}>
-          <Tabs.List>
-            <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
-            <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
-            <Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
-            <Tabs.Trigger value="tab4" disabled>
-              Disabled Tab
-            </Tabs.Trigger>
-          </Tabs.List>
-          <Tabs.Content value="tab1">
-            <div style={{ padding: "16px" }}>
-              <h4>Tab 1 Content</h4>
-              <p>This is the content for the first tab.</p>
-            </div>
-          </Tabs.Content>
-          <Tabs.Content value="tab2">
-            <div style={{ padding: "16px" }}>
-              <h4>Tab 2 Content</h4>
-              <p>This is the content for the second tab.</p>
-            </div>
-          </Tabs.Content>
-          <Tabs.Content value="tab3">
-            <div style={{ padding: "16px" }}>
-              <h4>Tab 3 Content</h4>
-              <p>This is the content for the third tab.</p>
-            </div>
-          </Tabs.Content>
-          <Tabs.Content value="tab4">
-            <div style={{ padding: "16px" }}>
-              <h4>Tab 4 Content</h4>
-              <p>This tab should be disabled.</p>
-            </div>
-          </Tabs.Content>
-        </Tabs>
-      </section>
-      <h3>Tooltips</h3>
-      <section>
-        <Tooltip value="This is a top tooltip" placement="top">
-          <Button variant="secondary">Top Tooltip</Button>
-        </Tooltip>
-        <Tooltip value="This is a bottom tooltip" placement="bottom">
-          <Button variant="secondary">Bottom Tooltip</Button>
-        </Tooltip>
-        <Tooltip value="This is a left tooltip" placement="left">
-          <Button variant="secondary">Left Tooltip</Button>
-        </Tooltip>
-        <Tooltip value="This is a right tooltip" placement="right">
-          <Button variant="secondary">Right Tooltip</Button>
-        </Tooltip>
-        <Tooltip value={`Dynamic tooltip: ${new Date().toLocaleTimeString()}`} placement="top">
-          <Button variant="primary">Dynamic Tooltip</Button>
-        </Tooltip>
-      </section>
-      <h3>List</h3>
-      <h3>Input</h3>
-      <section>
-        <Input
-          placeholder="Enter text..."
-          value={inputValue()}
-          onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setInputValue(e.currentTarget.value)}
-        />
-        <Input placeholder="Disabled input" disabled />
-        <Input type="password" placeholder="Password input" />
-      </section>
-      <h3>Checkbox</h3>
-      <section style={{ "flex-direction": "column", "align-items": "flex-start", gap: "12px" }}>
-        <Checkbox label="Simple checkbox" />
-        <Checkbox label="Checked by default" defaultChecked />
-        <Checkbox label="Disabled checkbox" disabled />
-        <Checkbox label="Disabled & checked" disabled checked />
-        <Checkbox
-          label="Controlled checkbox"
-          description="This checkbox is controlled by state"
-          checked={checked()}
-          onChange={setChecked}
-        />
-        <Checkbox label="With description" description="This is a helpful description for the checkbox" />
-        <Checkbox label="Indeterminate state" description="Useful for nested checkbox lists" indeterminate />
-        <Checkbox
-          label="I agree to the Terms and Conditions"
-          description="You must agree to continue"
-          checked={termsAccepted()}
-          onChange={setTermsAccepted}
-          validationState={!termsAccepted() ? "invalid" : "valid"}
-        />
-      </section>
-      <h3>Icons</h3>
-      <section>
-        <Icon name="close" />
-        <Icon name="checkmark" />
-        <Icon name="chevron-down" />
-        <Icon name="chevron-up" />
-        <Icon name="chevron-left" />
-        <Icon name="chevron-right" />
-        <Icon name="search" />
-        <Icon name="loading" />
-      </section>
-      <h3>Icon Buttons</h3>
-      <section>
-        <IconButton icon="close" onClick={() => console.log("Close clicked")} />
-        <IconButton icon="checkmark" onClick={() => console.log("Check clicked")} />
-        <IconButton icon="search" onClick={() => console.log("Search clicked")} disabled />
-      </section>
-      <h3>Dialog</h3>
-      <section>
-        <Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
-        <Dialog open={dialogOpen()} onOpenChange={setDialogOpen}>
-          <Dialog.Title>Example Dialog</Dialog.Title>
-          <Dialog.Description>This is an example dialog with a title and description.</Dialog.Description>
-          <div
-            style={{
-              "margin-top": "16px",
-              display: "flex",
-              gap: "8px",
-              "justify-content": "flex-end",
-            }}
-          >
-            <Button variant="ghost" onClick={() => setDialogOpen(false)}>
-              Cancel
-            </Button>
-            <Button variant="primary" onClick={() => setDialogOpen(false)}>
-              Confirm
-            </Button>
-          </div>
-        </Dialog>
-      </section>
-      <h3>Select Dialog</h3>
-      <section>
-        <Button onClick={() => setSelectDialogOpen(true)}>Open Select Dialog</Button>
-        <SelectDialog
-          title="Select an Option"
-          defaultOpen={selectDialogOpen()}
-          onOpenChange={setSelectDialogOpen}
-          items={["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]}
-          key={(x) => x}
-          onSelect={(option) => {
-            console.log("Selected:", option)
-            setSelectDialogOpen(false)
-          }}
-          placeholder="Search options..."
-        >
-          {(item) => <div>{item}</div>}
-        </SelectDialog>
-      </section>
-      <h3>Collapsible</h3>
-      <section>
-        <Collapsible>
-          <Collapsible.Trigger>
-            <Button variant="secondary">Toggle Content</Button>
-          </Collapsible.Trigger>
-          <Collapsible.Content>
-            <div
-              style={{
-                padding: "16px",
-                "background-color": "var(--surface-base)",
-                "border-radius": "8px",
-                "margin-top": "8px",
-              }}
-            >
-              <p>This is collapsible content that can be toggled open and closed.</p>
-              <p>It animates smoothly using CSS animations.</p>
-            </div>
-          </Collapsible.Content>
-        </Collapsible>
-      </section>
-      <h3>Accordion</h3>
-      <section>
-        <Accordion collapsible>
-          <Accordion.Item value="item-1">
-            <Accordion.Header>
-              <Accordion.Trigger>What is Kobalte?</Accordion.Trigger>
-            </Accordion.Header>
-            <Accordion.Content>
-              <div style={{ padding: "16px" }}>
-                <p>Kobalte is a UI toolkit for building accessible web apps and design systems with SolidJS.</p>
-              </div>
-            </Accordion.Content>
-          </Accordion.Item>
-          <Accordion.Item value="item-2">
-            <Accordion.Header>
-              <Accordion.Trigger>Is it accessible?</Accordion.Trigger>
-            </Accordion.Header>
-            <Accordion.Content>
-              <div style={{ padding: "16px" }}>
-                <p>Yes. It adheres to the WAI-ARIA design patterns.</p>
-              </div>
-            </Accordion.Content>
-          </Accordion.Item>
-          <Accordion.Item value="item-3">
-            <Accordion.Header>
-              <Accordion.Trigger>Can it be animated?</Accordion.Trigger>
-            </Accordion.Header>
-            <Accordion.Content>
-              <div style={{ padding: "16px" }}>
-                <p>Yes! You can animate the content height using CSS animations.</p>
-              </div>
-            </Accordion.Content>
-          </Accordion.Item>
-        </Accordion>
-      </section>
-    </div>
-  )
-
-  return (
-    <>
-      <Font />
-      <main>
-        <Content />
-        <Content dark />
-      </main>
-    </>
-  )
-}
-
-export default Demo

+ 0 - 40
packages/ui/src/index.css

@@ -1,40 +0,0 @@
-@import "./styles/index.css";
-
-:root {
-  body {
-    margin: 0;
-    background-color: var(--background-base);
-    color: var(--text-base);
-  }
-  main {
-    display: flex;
-    flex-direction: row;
-    overflow-x: hidden;
-  }
-  main > div {
-    flex: 1;
-    padding: 2rem;
-    min-width: 0;
-    overflow-x: hidden;
-    display: flex;
-    flex-direction: column;
-    gap: 2rem;
-  }
-  h3 {
-    font-size: 1.25rem;
-    font-weight: 600;
-    margin: 0 0 1rem 0;
-    margin-bottom: -1rem;
-  }
-  section {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 0.75rem;
-    align-items: flex-start;
-  }
-}
-
-.dark {
-  background-color: var(--background-base);
-  color: var(--text-base);
-}

+ 0 - 22
packages/ui/src/index.tsx

@@ -1,22 +0,0 @@
-/* @refresh reload */
-import { render } from "solid-js/web"
-import { MetaProvider } from "@solidjs/meta"
-
-import Demo from "./demo"
-
-const root = document.getElementById("root")
-
-if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
-  throw new Error(
-    "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
-  )
-}
-
-render(
-  () => (
-    <MetaProvider>
-      <Demo />
-    </MetaProvider>
-  ),
-  root!,
-)

+ 2 - 0
packages/ui/src/styles/index.css

@@ -22,6 +22,7 @@
 @import "../components/icon.css" layer(components);
 @import "../components/icon-button.css" layer(components);
 @import "../components/input.css" layer(components);
+@import "../components/list.css" layer(components);
 @import "../components/logo.css" layer(components);
 @import "../components/markdown.css" layer(components);
 @import "../components/message-part.css" layer(components);
@@ -36,6 +37,7 @@
 @import "../components/session-turn.css" layer(components);
 @import "../components/sticky-accordion-header.css" layer(components);
 @import "../components/tabs.css" layer(components);
+@import "../components/tag.css" layer(components);
 @import "../components/tooltip.css" layer(components);
 @import "../components/typewriter.css" layer(components);
 

+ 1 - 0
packages/ui/src/styles/tailwind/index.css

@@ -57,6 +57,7 @@
   --radius-sm: 0.25rem;
   --radius-md: 0.375rem;
   --radius-lg: 0.5rem;
+  --radius-xl: 0.625rem;
 
   --shadow-xs: var(--shadow-xs);
   --shadow-md: var(--shadow-md);

+ 2 - 0
packages/ui/src/styles/theme.css

@@ -40,9 +40,11 @@
   --container-6xl: 72rem;
   --container-7xl: 80rem;
 
+  --radius-xs: 0.125rem;
   --radius-sm: 0.25rem;
   --radius-md: 0.375rem;
   --radius-lg: 0.5rem;
+  --radius-xl: 0.625rem;
 
   --shadow-xs:
     0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);

+ 1 - 1
packages/web/astro.config.mjs

@@ -31,7 +31,7 @@ export default defineConfig({
     configSchema(),
     solidJs(),
     starlight({
-      title: "opencode",
+      title: "OpenCode",
       lastUpdated: true,
       expressiveCode: { themes: ["github-light", "github-dark"] },
       social: [

+ 1 - 0
packages/web/src/content/docs/ecosystem.mdx

@@ -17,6 +17,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
 
 | Name                                                                                              | Description                                                           |
 | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session)                 | Automatically inject Helicone session headers for request grouping    |
 | [opencode-skills](https://github.com/malhashemi/opencode-skills)                                  | Manage and organize OpenCode skills and capabilities                  |
 | [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject)                           | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
 | [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth)            | Use your ChatGPT Plus/Pro subscription instead of API credits         |

+ 2 - 2
packages/web/src/content/docs/index.mdx

@@ -7,7 +7,7 @@ import { Tabs, TabItem } from "@astrojs/starlight/components"
 import config from "../../../config.mjs"
 export const console = config.console
 
-[**OpenCode**](/) is an AI coding agent built for the terminal.
+[**OpenCode**](/) is an open source AI coding agent. It's available as a terminal-based interface, desktop app, or IDE extension.
 
 ![OpenCode TUI with the opencode theme](../../assets/lander/screenshot.png)
 
@@ -17,7 +17,7 @@ Let's get started.
 
 #### Prerequisites
 
-To use OpenCode, you'll need:
+To use OpenCode in your terminal, you'll need:
 
 1. A modern terminal emulator like:
    - [WezTerm](https://wezterm.org), cross-platform

+ 113 - 0
packages/web/src/content/docs/providers.mdx

@@ -568,6 +568,119 @@ The `global` region improves availability and reduces errors at no extra cost. U
 
 ---
 
+### Helicone
+
+[Helicone](https://helicone.ai) is an LLM observability platform that provides logging, monitoring, and analytics for your AI applications. The Helicone AI Gateway routes your requests to the appropriate provider automatically based on the model.
+
+1. Head over to [Helicone](https://helicone.ai), create an account, and generate an API key from your dashboard.
+
+2. Run the `/connect` command and search for **Helicone**.
+
+   ```txt
+   /connect
+   ```
+
+3. Enter your Helicone API key.
+
+   ```txt
+   ┌ API key
+   │
+   │
+   └ enter
+   ```
+
+4. Run the `/models` command to select a model.
+
+   ```txt
+   /models
+   ```
+
+For more providers and advanced features like caching and rate limiting, check the [Helicone documentation](https://docs.helicone.ai).
+
+#### Optional Configs
+
+In the event you see a feature or model from Helicone that isn't configured automatically through opencode, you can always configure it yourself.
+
+Here's [Helicone's Model Directory](https://helicone.ai/models), you'll need this to grab the IDs of the models you want to add.
+
+```jsonc title="~/.config/opencode/opencode.jsonc"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "provider": {
+    "helicone": {
+      "npm": "@ai-sdk/openai-compatible",
+      "name": "Helicone",
+      "options": {
+        "baseURL": "https://ai-gateway.helicone.ai",
+      },
+      "models": {
+        "gpt-4o": {
+          // Model ID (from Helicone's model directory page)
+          "name": "GPT-4o", // Your own custom name for the model
+        },
+        "claude-sonnet-4-20250514": {
+          "name": "Claude Sonnet 4",
+        },
+      },
+    },
+  },
+}
+```
+
+#### Custom Headers
+
+Helicone supports custom headers for features like caching, user tracking, and session management. Add them to your provider config using `options.headers`:
+
+```jsonc title="~/.config/opencode/opencode.jsonc"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "provider": {
+    "helicone": {
+      "npm": "@ai-sdk/openai-compatible",
+      "name": "Helicone",
+      "options": {
+        "baseURL": "https://ai-gateway.helicone.ai",
+        "headers": {
+          "Helicone-Cache-Enabled": "true",
+          "Helicone-User-Id": "opencode",
+        },
+      },
+    },
+  },
+}
+```
+
+##### Session tracking
+
+Helicone's [Sessions](https://docs.helicone.ai/features/sessions) feature lets you group related LLM requests together. Use the [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) plugin to automatically log each OpenCode conversation as a session in Helicone.
+
+```bash
+npm install -g opencode-helicone-session
+```
+
+Add it to your config.
+
+```json title="opencode.json"
+{
+  "plugin": ["opencode-helicone-session"]
+}
+```
+
+The plugin injects `Helicone-Session-Id` and `Helicone-Session-Name` headers into your requests. In Helicone's Sessions page, you'll see each OpenCode conversation listed as a separate session.
+
+##### Common Helicone headers
+
+| Header                     | Description                                                   |
+| -------------------------- | ------------------------------------------------------------- |
+| `Helicone-Cache-Enabled`   | Enable response caching (`true`/`false`)                      |
+| `Helicone-User-Id`         | Track metrics by user                                         |
+| `Helicone-Property-[Name]` | Add custom properties (e.g., `Helicone-Property-Environment`) |
+| `Helicone-Prompt-Id`       | Associate requests with prompt versions                       |
+
+See the [Helicone Header Directory](https://docs.helicone.ai/helicone-headers/header-directory) for all available headers.
+
+---
+
 ### llama.cpp
 
 You can configure opencode to use local models through [llama.cpp's](https://github.com/ggml-org/llama.cpp) llama-server utility

Some files were not shown because too many files changed in this diff