Просмотр исходного кода

Merge branch 'dev' into fix/interrupt-double-sound

Kit Langton 1 месяц назад
Родитель
Сommit
4ed3336b23
38 измененных файлов с 842 добавлено и 460 удалено
  1. 4 0
      .github/actions/setup-bun/action.yml
  2. 8 15
      .github/workflows/test.yml
  3. 1 0
      .opencode/.gitignore
  4. 4 4
      nix/hashes.json
  5. 2 4
      packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
  6. 2 1
      packages/app/e2e/selectors.ts
  7. 44 31
      packages/app/src/components/session/session-header.tsx
  8. 3 2
      packages/app/src/components/terminal.tsx
  9. 8 3
      packages/app/src/pages/session.tsx
  10. 0 2
      packages/app/src/pages/session/composer/session-todo-dock.tsx
  11. 183 141
      packages/app/src/pages/session/terminal-panel.tsx
  12. 7 1
      packages/console/app/src/routes/zen/util/handler.ts
  13. 40 16
      packages/console/app/src/routes/zen/util/rateLimiter.ts
  14. 1 0
      packages/console/core/src/model.ts
  15. 29 88
      packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json
  16. 9 1
      packages/opencode/src/account/repo.ts
  17. 3 1
      packages/opencode/src/account/service.ts
  18. 1 3
      packages/opencode/src/cli/cmd/account.ts
  19. 0 12
      packages/opencode/src/provider/error.ts
  20. 1 3
      packages/opencode/test/cli/import.test.ts
  21. 0 29
      packages/opencode/test/session/message-v2.test.ts
  22. 7 5
      packages/opencode/tsconfig.json
  23. 2 2
      packages/plugin/src/example.ts
  24. 3 3
      packages/plugin/src/index.ts
  25. 2 2
      packages/plugin/tsconfig.json
  26. 87 22
      packages/ui/src/components/card.css
  27. 6 8
      packages/ui/src/components/card.stories.tsx
  28. 104 3
      packages/ui/src/components/card.tsx
  29. 6 0
      packages/ui/src/components/markdown.css
  30. 0 36
      packages/ui/src/components/message-part.css
  31. 2 19
      packages/ui/src/components/message-part.tsx
  32. 54 0
      packages/ui/src/components/tool-error-card.css
  33. 96 0
      packages/ui/src/components/tool-error-card.stories.tsx
  34. 112 0
      packages/ui/src/components/tool-error-card.tsx
  35. 1 0
      packages/ui/src/styles/index.css
  36. 2 2
      packages/ui/src/styles/theme.css
  37. 6 0
      packages/ui/src/theme/themes/oc-2.json
  38. 2 1
      script/beta.ts

+ 4 - 0
.github/actions/setup-bun/action.yml

@@ -31,6 +31,10 @@ runs:
         bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
         bun-download-url: ${{ steps.bun-url.outputs.url }}
 
+    - name: Install setuptools for distutils compatibility
+      run: python3 -m pip install setuptools || pip install setuptools || true
+      shell: bash
+
     - name: Install dependencies
       run: bun install
       shell: bash

+ 8 - 15
.github/workflows/test.yml

@@ -6,6 +6,14 @@ on:
       - dev
   pull_request:
   workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
 jobs:
   unit:
     name: unit (${{ matrix.settings.name }})
@@ -86,18 +94,3 @@ jobs:
           path: |
             packages/app/e2e/test-results
             packages/app/e2e/playwright-report
-
-  required:
-    name: test (linux)
-    runs-on: blacksmith-4vcpu-ubuntu-2404
-    needs:
-      - unit
-      - e2e
-    if: always()
-    steps:
-      - name: Verify upstream test jobs passed
-        run: |
-          echo "unit=${{ needs.unit.result }}"
-          echo "e2e=${{ needs.e2e.result }}"
-          test "${{ needs.unit.result }}" = "success"
-          test "${{ needs.e2e.result }}" = "success"

+ 1 - 0
.opencode/.gitignore

@@ -1,3 +1,4 @@
 plans/
 bun.lock
 package.json
+package-lock.json

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-duBedS4ZTc1as03OM0KB9mKKU21Cywv4o9GHwQZv6Ts=",
-    "aarch64-linux": "sha256-juvQfuNBqqzeB/TIY9PuUDqgpsdyI54ImowjQLrNhns=",
-    "aarch64-darwin": "sha256-kKgcuEN1oJqHJc+sGjcZ4INWvbZczSTDJ8VHIWAquD4=",
-    "x86_64-darwin": "sha256-hXkFWOL4wi9s8HSrChpqtH4PKSNzbzVgU+0GbAxEUT4="
+    "x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
+    "aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
+    "aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
+    "x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
   }
 }

+ 2 - 4
packages/app/e2e/prompt/prompt-slash-terminal.spec.ts

@@ -9,14 +9,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
 
   await expect(terminal).not.toBeVisible()
 
-  await prompt.click()
-  await page.keyboard.type("/terminal")
+  await prompt.fill("/terminal")
   await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
   await page.keyboard.press("Enter")
   await expect(terminal).toBeVisible()
 
-  await prompt.click()
-  await page.keyboard.type("/terminal")
+  await prompt.fill("/terminal")
   await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
   await page.keyboard.press("Enter")
   await expect(terminal).not.toBeVisible()

+ 2 - 1
packages/app/e2e/selectors.ts

@@ -1,5 +1,6 @@
 export const promptSelector = '[data-component="prompt-input"]'
-export const terminalSelector = '[data-component="terminal"]'
+export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
+export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
 export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
 export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
 export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'

+ 44 - 31
packages/app/src/components/session/session-header.tsx

@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { focusTerminalById } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover } from "../status-popover"
@@ -229,6 +231,7 @@ export function SessionHeader() {
   const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
+  const terminal = useTerminal()
 
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const project = createMemo(() => {
@@ -296,6 +299,16 @@ export function SessionHeader() {
     ] as const
   })
 
+  const toggleTerminal = () => {
+    const next = !view().terminal.opened()
+    view().terminal.toggle()
+    if (!next) return
+
+    const id = terminal.active()
+    if (!id) return
+    focusTerminalById(id)
+  }
+
   const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
   const [menu, setMenu] = createStore({ open: false })
   const [openRequest, setOpenRequest] = createStore({
@@ -617,39 +630,39 @@ export function SessionHeader() {
                 </div>
               </Show>
               <div class="flex items-center gap-1">
-                <div class="hidden md:flex items-center gap-1 shrink-0">
-                  <TooltipKeybind
-                    title={language.t("command.terminal.toggle")}
-                    keybind={command.keybind("terminal.toggle")}
+                <TooltipKeybind
+                  title={language.t("command.terminal.toggle")}
+                  keybind={command.keybind("terminal.toggle")}
+                >
+                  <Button
+                    variant="ghost"
+                    class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
+                    onClick={toggleTerminal}
+                    aria-label={language.t("command.terminal.toggle")}
+                    aria-expanded={view().terminal.opened()}
+                    aria-controls="terminal-panel"
                   >
-                    <Button
-                      variant="ghost"
-                      class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
-                      onClick={() => view().terminal.toggle()}
-                      aria-label={language.t("command.terminal.toggle")}
-                      aria-expanded={view().terminal.opened()}
-                      aria-controls="terminal-panel"
-                    >
-                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                        <Icon
-                          size="small"
-                          name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
-                          class="group-hover/terminal-toggle:hidden"
-                        />
-                        <Icon
-                          size="small"
-                          name="layout-bottom-partial"
-                          class="hidden group-hover/terminal-toggle:inline-block"
-                        />
-                        <Icon
-                          size="small"
-                          name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
-                          class="hidden group-active/terminal-toggle:inline-block"
-                        />
-                      </div>
-                    </Button>
-                  </TooltipKeybind>
+                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
+                        class="group-hover/terminal-toggle:hidden"
+                      />
+                      <Icon
+                        size="small"
+                        name="layout-bottom-partial"
+                        class="hidden group-hover/terminal-toggle:inline-block"
+                      />
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
+                        class="hidden group-active/terminal-toggle:inline-block"
+                      />
+                    </div>
+                  </Button>
+                </TooltipKeybind>
 
+                <div class="hidden md:flex items-center gap-1 shrink-0">
                   <TooltipKeybind
                     title={language.t("command.review.toggle")}
                     keybind={command.keybind("review.toggle")}

+ 3 - 2
packages/app/src/components/terminal.tsx

@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
 const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
+  autoFocus?: boolean
   onSubmit?: () => void
   onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
   onConnect?: () => void
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
   const language = useLanguage()
   const server = useServer()
   let container!: HTMLDivElement
-  const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
+  const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
   const id = local.pty.id
   const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
   const restoreSize =
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
         handleLinkClick,
       })
 
-      focusTerminal()
+      if (local.autoFocus !== false) focusTerminal()
 
       if (typeof document !== "undefined" && document.fonts) {
         document.fonts.ready.then(scheduleFit)

+ 8 - 3
packages/app/src/pages/session.tsx

@@ -32,8 +32,9 @@ import { useLayout } from "@/context/layout"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -267,6 +268,7 @@ export default function Page() {
   const sdk = useSDK()
   const prompt = usePrompt()
   const comments = useComments()
+  const terminal = useTerminal()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
 
   createEffect(() => {
@@ -759,8 +761,11 @@ export default function Page() {
       return
     }
 
-    // Don't autofocus chat if desktop terminal panel is open
-    if (isDesktop() && view().terminal.opened()) return
+    // Prefer the open terminal over the composer when it can take focus
+    if (view().terminal.opened()) {
+      const id = terminal.active()
+      if (id && focusTerminalById(id)) return
+    }
 
     // Only treat explicit scroll keys as potential "user scroll" gestures.
     if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {

+ 0 - 2
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -138,7 +138,6 @@ export function SessionTodoDock(props: {
               "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
               "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
               opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
-              filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
             }}
           >
             <AnimatedNumber value={done()} />
@@ -196,7 +195,6 @@ export function SessionTodoDock(props: {
           style={{
             visibility: off() ? "hidden" : "visible",
             opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
-            filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
           }}
         >
           <TodoList todos={props.todos} open={!store.collapsed} />

+ 183 - 141
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,6 +1,5 @@
-import { For, Show, createEffect, createMemo, on } from "solid-js"
+import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { createMediaQuery } from "@solid-primitives/media"
 import { useParams } from "@solidjs/router"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -17,7 +16,7 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
-import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
+import { createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
 
 export function TerminalPanel() {
@@ -27,13 +26,10 @@ export function TerminalPanel() {
   const language = useLanguage()
   const command = useCommand()
 
-  const isDesktop = createMediaQuery("(min-width: 768px)")
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
 
   const opened = createMemo(() => view().terminal.opened())
-  const open = createMemo(() => isDesktop() && opened())
-  const panel = createPresence(open)
   const size = createSizing()
   const height = createMemo(() => layout.terminal.height())
   const close = () => view().terminal.close()
@@ -42,6 +38,25 @@ export function TerminalPanel() {
   const [store, setStore] = createStore({
     autoCreated: false,
     activeDraggable: undefined as string | undefined,
+    view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
+  })
+
+  const max = () => store.view * 0.6
+  const pane = () => Math.min(height(), max())
+
+  createEffect(() => {
+    if (typeof window === "undefined") return
+
+    const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
+    const port = window.visualViewport
+
+    sync()
+    window.addEventListener("resize", sync)
+    port?.addEventListener("resize", sync)
+    onCleanup(() => {
+      window.removeEventListener("resize", sync)
+      port?.removeEventListener("resize", sync)
+    })
   })
 
   createEffect(() => {
@@ -66,21 +81,42 @@ export function TerminalPanel() {
     ),
   )
 
+  const focus = (id: string) => {
+    focusTerminalById(id)
+
+    const frame = requestAnimationFrame(() => {
+      if (!opened()) return
+      if (terminal.active() !== id) return
+      focusTerminalById(id)
+    })
+
+    const timers = [120, 240].map((ms) =>
+      window.setTimeout(() => {
+        if (!opened()) return
+        if (terminal.active() !== id) return
+        focusTerminalById(id)
+      }, ms),
+    )
+
+    return () => {
+      cancelAnimationFrame(frame)
+      for (const timer of timers) clearTimeout(timer)
+    }
+  }
+
   createEffect(
     on(
-      () => terminal.active(),
-      (activeId) => {
-        if (!activeId || !panel.open()) return
-        if (document.activeElement instanceof HTMLElement) {
-          document.activeElement.blur()
-        }
-        setTimeout(() => focusTerminalById(activeId), 0)
+      () => [opened(), terminal.active()] as const,
+      ([next, id]) => {
+        if (!next || !id) return
+        const stop = focus(id)
+        onCleanup(stop)
       },
     ),
   )
 
   createEffect(() => {
-    if (panel.open()) return
+    if (opened()) return
     const active = document.activeElement
     if (!(active instanceof HTMLElement)) return
     if (!root?.contains(active)) return
@@ -138,150 +174,156 @@ export function TerminalPanel() {
 
     const activeId = terminal.active()
     if (!activeId) return
-    setTimeout(() => {
+    requestAnimationFrame(() => {
+      if (terminal.active() !== activeId) return
       focusTerminalById(activeId)
-    }, 0)
+    })
   }
 
   return (
-    <Show when={panel.show()}>
+    <div
+      ref={root}
+      id="terminal-panel"
+      role="region"
+      aria-label={language.t("terminal.title")}
+      aria-hidden={!opened()}
+      inert={!opened()}
+      class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
+      classList={{
+        "border-t border-border-weak-base": opened(),
+        "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+          !size.active(),
+      }}
+      style={{ height: opened() ? `${pane()}px` : "0px" }}
+    >
       <div
-        ref={root}
-        id="terminal-panel"
-        role="region"
-        aria-label={language.t("terminal.title")}
-        aria-hidden={!panel.open()}
-        inert={!panel.open()}
-        class="relative w-full shrink-0 overflow-hidden"
+        class="absolute inset-x-0 top-0 flex flex-col"
         classList={{
-          "opacity-100": panel.open(),
-          "opacity-0 pointer-events-none": !panel.open(),
-          "transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+          "translate-y-0": opened(),
+          "translate-y-full pointer-events-none": !opened(),
+          "transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
             !size.active(),
         }}
-        style={{ height: panel.open() ? `${height()}px` : "0px" }}
+        style={{ height: `${pane()}px` }}
       >
-        <div class="size-full flex flex-col border-t border-border-weak-base">
-          <div onPointerDown={() => size.start()}>
-            <ResizeHandle
-              direction="vertical"
-              size={height()}
-              min={100}
-              max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
-              collapseThreshold={50}
-              onResize={(next) => {
-                size.touch()
-                layout.terminal.resize(next)
-              }}
-              onCollapse={close}
-            />
-          </div>
-          <Show
-            when={terminal.ready()}
-            fallback={
-              <div class="flex flex-col h-full pointer-events-none">
-                <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
-                  <For each={handoff()}>
-                    {(title) => (
-                      <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
-                        {title}
-                      </div>
-                    )}
-                  </For>
-                  <div class="flex-1" />
-                  <div class="text-text-weak pr-2">
-                    {language.t("common.loading")}
-                    {language.t("common.loading.ellipsis")}
-                  </div>
-                </div>
-                <div class="flex-1 flex items-center justify-center text-text-weak">
-                  {language.t("terminal.loading")}
-                </div>
-              </div>
-            }
-          >
-            <DragDropProvider
-              onDragStart={handleTerminalDragStart}
-              onDragEnd={handleTerminalDragEnd}
-              onDragOver={handleTerminalDragOver}
-              collisionDetector={closestCenter}
-            >
-              <DragDropSensors />
-              <ConstrainDragYAxis />
-              <div class="flex flex-col h-full">
-                <Tabs
-                  variant="alt"
-                  value={terminal.active()}
-                  onChange={(id) => terminal.open(id)}
-                  class="!h-auto !flex-none"
-                >
-                  <Tabs.List class="h-10 border-b border-border-weaker-base">
-                    <SortableProvider ids={ids()}>
-                      <For each={ids()}>
-                        {(id) => (
-                          <Show when={byId().get(id)}>
-                            {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
-                          </Show>
-                        )}
-                      </For>
-                    </SortableProvider>
-                    <div class="h-full flex items-center justify-center">
-                      <TooltipKeybind
-                        title={language.t("command.terminal.new")}
-                        keybind={command.keybind("terminal.new")}
-                        class="flex items-center"
-                      >
-                        <IconButton
-                          icon="plus-small"
-                          variant="ghost"
-                          iconSize="large"
-                          onClick={terminal.new}
-                          aria-label={language.t("command.terminal.new")}
-                        />
-                      </TooltipKeybind>
+        <div class="hidden md:block" onPointerDown={() => size.start()}>
+          <ResizeHandle
+            direction="vertical"
+            size={pane()}
+            min={100}
+            max={max()}
+            collapseThreshold={50}
+            onResize={(next) => {
+              size.touch()
+              layout.terminal.resize(next)
+            }}
+            onCollapse={close}
+          />
+        </div>
+        <Show
+          when={terminal.ready()}
+          fallback={
+            <div class="flex flex-col h-full pointer-events-none">
+              <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
+                <For each={handoff()}>
+                  {(title) => (
+                    <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+                      {title}
                     </div>
-                  </Tabs.List>
-                </Tabs>
-                <div class="flex-1 min-h-0 relative">
-                  <Show when={terminal.active()} keyed>
-                    {(id) => (
-                      <Show when={byId().get(id)}>
-                        {(pty) => (
-                          <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
-                            <Terminal
-                              pty={pty()}
-                              onConnect={() => terminal.trim(id)}
-                              onCleanup={terminal.update}
-                              onConnectError={() => terminal.clone(id)}
-                            />
-                          </div>
-                        )}
-                      </Show>
-                    )}
-                  </Show>
+                  )}
+                </For>
+                <div class="flex-1" />
+                <div class="text-text-weak pr-2">
+                  {language.t("common.loading")}
+                  {language.t("common.loading.ellipsis")}
                 </div>
               </div>
-              <DragOverlay>
-                <Show when={store.activeDraggable}>
-                  {(draggedId) => (
-                    <Show when={byId().get(draggedId())}>
-                      {(t) => (
-                        <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
-                          {terminalTabLabel({
-                            title: t().title,
-                            titleNumber: t().titleNumber,
-                            t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
-                          })}
+              <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
+            </div>
+          }
+        >
+          <DragDropProvider
+            onDragStart={handleTerminalDragStart}
+            onDragEnd={handleTerminalDragEnd}
+            onDragOver={handleTerminalDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragYAxis />
+            <div class="flex flex-col h-full">
+              <Tabs
+                variant="alt"
+                value={terminal.active()}
+                onChange={(id) => terminal.open(id)}
+                class="!h-auto !flex-none"
+              >
+                <Tabs.List class="h-10 border-b border-border-weaker-base">
+                  <SortableProvider ids={ids()}>
+                    <For each={ids()}>
+                      {(id) => (
+                        <Show when={byId().get(id)}>
+                          {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
+                        </Show>
+                      )}
+                    </For>
+                  </SortableProvider>
+                  <div class="h-full flex items-center justify-center">
+                    <TooltipKeybind
+                      title={language.t("command.terminal.new")}
+                      keybind={command.keybind("terminal.new")}
+                      class="flex items-center"
+                    >
+                      <IconButton
+                        icon="plus-small"
+                        variant="ghost"
+                        iconSize="large"
+                        onClick={terminal.new}
+                        aria-label={language.t("command.terminal.new")}
+                      />
+                    </TooltipKeybind>
+                  </div>
+                </Tabs.List>
+              </Tabs>
+              <div class="flex-1 min-h-0 relative">
+                <Show when={terminal.active()} keyed>
+                  {(id) => (
+                    <Show when={byId().get(id)}>
+                      {(pty) => (
+                        <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
+                          <Terminal
+                            pty={pty()}
+                            autoFocus={opened()}
+                            onConnect={() => terminal.trim(id)}
+                            onCleanup={terminal.update}
+                            onConnectError={() => terminal.clone(id)}
+                          />
                         </div>
                       )}
                     </Show>
                   )}
                 </Show>
-              </DragOverlay>
-            </DragDropProvider>
-          </Show>
-        </div>
+              </div>
+            </div>
+            <DragOverlay>
+              <Show when={store.activeDraggable}>
+                {(draggedId) => (
+                  <Show when={byId().get(draggedId())}>
+                    {(t) => (
+                      <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+                        {terminalTabLabel({
+                          title: t().title,
+                          titleNumber: t().titleNumber,
+                          t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+                        })}
+                      </div>
+                    )}
+                  </Show>
+                )}
+              </Show>
+            </DragOverlay>
+          </DragDropProvider>
+        </Show>
       </div>
-    </Show>
+    </div>
   )
 }

+ 7 - 1
packages/console/app/src/routes/zen/util/handler.ts

@@ -99,7 +99,13 @@ export async function handler(
     const dataDumper = createDataDumper(sessionId, requestId, projectId)
     const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
     const trialProvider = await trialLimiter?.check()
-    const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
+    const rateLimiter = createRateLimiter(
+      modelInfo.id,
+      modelInfo.allowAnonymous,
+      modelInfo.rateLimit,
+      ip,
+      input.request,
+    )
     await rateLimiter?.check()
     const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
     const stickyProvider = await stickyTracker?.get()

+ 40 - 16
packages/console/app/src/routes/zen/util/rateLimiter.ts

@@ -6,39 +6,63 @@ import { i18n } from "~/i18n"
 import { localeFromRequest } from "~/lib/language"
 import { Subscription } from "@opencode-ai/console-core/subscription.js"
 
-export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
+export function createRateLimiter(
+  modelId: string,
+  allowAnonymous: boolean | undefined,
+  rateLimit: number | undefined,
+  rawIp: string,
+  request: Request,
+) {
   if (!allowAnonymous) return
   const dict = i18n(localeFromRequest(request))
 
   const limits = Subscription.getFreeLimits()
-  const limitValue =
-    limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
+  const headerExists = request.headers.has(limits.checkHeader)
+  const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
+  const isDefaultModel = headerExists && !rateLimit
 
   const ip = !rawIp.length ? "unknown" : rawIp
   const now = Date.now()
-  const interval = buildYYYYMMDD(now)
+  const lifetimeInterval = ""
+  const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
+
+  let _isNew: boolean
 
   return {
-    track: async () => {
-      await Database.use((tx) =>
-        tx
-          .insert(IpRateLimitTable)
-          .values({ ip, interval, count: 1 })
-          .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
-      )
-    },
     check: async () => {
       const rows = await Database.use((tx) =>
         tx
           .select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
           .from(IpRateLimitTable)
-          .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
+          .where(
+            and(
+              eq(IpRateLimitTable.ip, ip),
+              isDefaultModel
+                ? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
+                : inArray(IpRateLimitTable.interval, [dailyInterval]),
+            ),
+          ),
       )
-      const total = rows.reduce((sum, r) => sum + r.count, 0)
-      logger.debug(`rate limit total: ${total}`)
-      if (total >= limitValue)
+      const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
+      const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
+      logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
+
+      _isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
+
+      if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
         throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
     },
+    track: async () => {
+      await Database.use((tx) =>
+        tx
+          .insert(IpRateLimitTable)
+          .values([
+            { ip, interval: dailyInterval, count: 1 },
+            ...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
+          ])
+          .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
+      )
+    },
   }
 }
 

+ 1 - 0
packages/console/core/src/model.ts

@@ -28,6 +28,7 @@ export namespace ZenData {
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
     trialProvider: z.string().optional(),
     fallbackProvider: z.string().optional(),
+    rateLimit: z.number().optional(),
     providers: z.array(
       z.object({
         id: z.string(),

+ 29 - 88
packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json

@@ -2,10 +2,7 @@
   "version": "7",
   "dialect": "sqlite",
   "id": "fb311f30-9948-4131-b15c-7d308478a878",
-  "prevIds": [
-    "325559b7-104f-4d2a-a02c-934cfad7cfcc",
-    "4ec9de62-88a7-4bec-91cc-0a759e84db21"
-  ],
+  "prevIds": ["325559b7-104f-4d2a-a02c-934cfad7cfcc", "4ec9de62-88a7-4bec-91cc-0a759e84db21"],
   "ddl": [
     {
       "name": "account_state",
@@ -892,13 +889,9 @@
       "table": "session_share"
     },
     {
-      "columns": [
-        "active_account_id"
-      ],
+      "columns": ["active_account_id"],
       "tableTo": "account",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "SET NULL",
       "nameExplicit": false,
@@ -907,13 +900,9 @@
       "table": "account_state"
     },
     {
-      "columns": [
-        "project_id"
-      ],
+      "columns": ["project_id"],
       "tableTo": "project",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -922,13 +911,9 @@
       "table": "workspace"
     },
     {
-      "columns": [
-        "session_id"
-      ],
+      "columns": ["session_id"],
       "tableTo": "session",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -937,13 +922,9 @@
       "table": "message"
     },
     {
-      "columns": [
-        "message_id"
-      ],
+      "columns": ["message_id"],
       "tableTo": "message",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -952,13 +933,9 @@
       "table": "part"
     },
     {
-      "columns": [
-        "project_id"
-      ],
+      "columns": ["project_id"],
       "tableTo": "project",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -967,13 +944,9 @@
       "table": "permission"
     },
     {
-      "columns": [
-        "project_id"
-      ],
+      "columns": ["project_id"],
       "tableTo": "project",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -982,13 +955,9 @@
       "table": "session"
     },
     {
-      "columns": [
-        "session_id"
-      ],
+      "columns": ["session_id"],
       "tableTo": "session",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -997,13 +966,9 @@
       "table": "todo"
     },
     {
-      "columns": [
-        "session_id"
-      ],
+      "columns": ["session_id"],
       "tableTo": "session",
-      "columnsTo": [
-        "id"
-      ],
+      "columnsTo": ["id"],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1012,101 +977,77 @@
       "table": "session_share"
     },
     {
-      "columns": [
-        "email",
-        "url"
-      ],
+      "columns": ["email", "url"],
       "nameExplicit": false,
       "name": "control_account_pk",
       "entityType": "pks",
       "table": "control_account"
     },
     {
-      "columns": [
-        "session_id",
-        "position"
-      ],
+      "columns": ["session_id", "position"],
       "nameExplicit": false,
       "name": "todo_pk",
       "entityType": "pks",
       "table": "todo"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "account_state_pk",
       "table": "account_state",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "account_pk",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "workspace_pk",
       "table": "workspace",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "project_pk",
       "table": "project",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "message_pk",
       "table": "message",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "part_pk",
       "table": "part",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "project_id"
-      ],
+      "columns": ["project_id"],
       "nameExplicit": false,
       "name": "permission_pk",
       "table": "permission",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "nameExplicit": false,
       "name": "session_pk",
       "table": "session",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "session_id"
-      ],
+      "columns": ["session_id"],
       "nameExplicit": false,
       "name": "session_share_pk",
       "table": "session_share",
@@ -1212,4 +1153,4 @@
     }
   ],
   "renames": []
-}
+}

+ 9 - 1
packages/opencode/src/account/repo.ts

@@ -69,7 +69,15 @@ export class AccountRepo extends ServiceMap.Service<
         db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
       ),
 
-      list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
+      list: Effect.fn("AccountRepo.list")(() =>
+        db((db) =>
+          db
+            .select()
+            .from(AccountTable)
+            .all()
+            .map((row) => decodeAccount({ ...row, active_org_id: null })),
+        ),
+      ),
 
       remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
         db((db) =>

+ 3 - 1
packages/opencode/src/account/service.ts

@@ -346,7 +346,9 @@ export class AccountService extends ServiceMap.Service<
         const expiry = now + (parsed.expires_in ?? 0) * 1000
         const refresh = parsed.refresh_token ?? ""
         if (!refresh) {
-          yield* Effect.logWarning("Server did not return a refresh token — session may expire without ability to refresh")
+          yield* Effect.logWarning(
+            "Server did not return a refresh token — session may expire without ability to refresh",
+          )
         }
 
         yield* repo.persistAccount({

+ 1 - 3
packages/opencode/src/cli/cmd/account.ts

@@ -75,9 +75,7 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
     const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
     return {
       value: a,
-      label: isActive
-        ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)"
-        : `${a.email} ${server}`,
+      label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
     }
   })
 

+ 0 - 12
packages/opencode/src/provider/error.ts

@@ -40,14 +40,6 @@ export namespace ProviderError {
     return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
   }
 
-  function error(providerID: string, error: APICallError) {
-    if (providerID.includes("github-copilot") && error.statusCode === 403) {
-      return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
-    }
-
-    return error.message
-  }
-
   function message(providerID: string, e: APICallError) {
     return iife(() => {
       const msg = e.message
@@ -60,10 +52,6 @@ export namespace ProviderError {
         return "Unknown error"
       }
 
-      const transformed = error(providerID, e)
-      if (transformed !== msg) {
-        return transformed
-      }
       if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) {
         return msg
       }

+ 1 - 3
packages/opencode/test/cli/import.test.ts

@@ -24,9 +24,7 @@ test("only attaches share auth headers for same-origin URLs", () => {
   expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe(
     true,
   )
-  expect(
-    shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com"),
-  ).toBe(false)
+  expect(shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com")).toBe(false)
   expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe(
     true,
   )

+ 0 - 29
packages/opencode/test/session/message-v2.test.ts

@@ -842,35 +842,6 @@ describe("session.message-v2.fromError", () => {
     })
   })
 
-  test("maps github-copilot 403 to reauth guidance", () => {
-    const error = new APICallError({
-      message: "forbidden",
-      url: "https://api.githubcopilot.com/v1/chat/completions",
-      requestBodyValues: {},
-      statusCode: 403,
-      responseHeaders: { "content-type": "application/json" },
-      responseBody: '{"error":"forbidden"}',
-      isRetryable: false,
-    })
-
-    const result = MessageV2.fromError(error, { providerID: "github-copilot" })
-
-    expect(result).toStrictEqual({
-      name: "APIError",
-      data: {
-        message:
-          "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode.",
-        statusCode: 403,
-        isRetryable: false,
-        responseHeaders: { "content-type": "application/json" },
-        responseBody: '{"error":"forbidden"}',
-        metadata: {
-          url: "https://api.githubcopilot.com/v1/chat/completions",
-        },
-      },
-    })
-  })
-
   test("detects context overflow from APICallError provider messages", () => {
     const cases = [
       "prompt is too long: 213462 tokens > 200000 maximum",

+ 7 - 5
packages/opencode/tsconfig.json

@@ -12,10 +12,12 @@
       "@/*": ["./src/*"],
       "@tui/*": ["./src/cli/cmd/tui/*"]
     },
-    "plugins": [{
-      "name": "@effect/language-service",
-      "transform": "@effect/language-service/transform",
-      "namespaceImportPackages": ["effect", "@effect/*"]
-    }]
+    "plugins": [
+      {
+        "name": "@effect/language-service",
+        "transform": "@effect/language-service/transform",
+        "namespaceImportPackages": ["effect", "@effect/*"]
+      }
+    ]
   }
 }

+ 2 - 2
packages/plugin/src/example.ts

@@ -1,5 +1,5 @@
-import { Plugin } from "./index"
-import { tool } from "./tool"
+import { Plugin } from "./index.js"
+import { tool } from "./tool.js"
 
 export const ExamplePlugin: Plugin = async (ctx) => {
   return {

+ 3 - 3
packages/plugin/src/index.ts

@@ -12,10 +12,10 @@ import type {
   Config,
 } from "@opencode-ai/sdk"
 
-import type { BunShell } from "./shell"
-import { type ToolDefinition } from "./tool"
+import type { BunShell } from "./shell.js"
+import { type ToolDefinition } from "./tool.js"
 
-export * from "./tool"
+export * from "./tool.js"
 
 export type ProviderContext = {
   source: "env" | "config" | "custom" | "api"

+ 2 - 2
packages/plugin/tsconfig.json

@@ -3,9 +3,9 @@
   "extends": "@tsconfig/node22/tsconfig.json",
   "compilerOptions": {
     "outDir": "dist",
-    "module": "preserve",
+    "module": "nodenext",
     "declaration": true,
-    "moduleResolution": "bundler",
+    "moduleResolution": "nodenext",
     "lib": ["es2022", "dom", "dom.iterable"]
   },
   "include": ["src"]

+ 87 - 22
packages/ui/src/components/card.css

@@ -1,29 +1,94 @@
 [data-component="card"] {
+  --card-pad-y: 10px;
+  --card-pad-r: 12px;
+  --card-pad-l: 10px;
+
   width: 100%;
   display: flex;
   flex-direction: column;
-  background-color: var(--surface-inset-base);
-  border: 1px solid var(--border-weaker-base);
-  transition: background-color 0.15s ease;
+  position: relative;
+  background: transparent;
+  border: none;
   border-radius: var(--radius-md);
-  padding: 6px 12px;
-  overflow: clip;
-
-  &[data-variant="error"] {
-    background-color: var(--surface-critical-weak);
-    border: 1px solid var(--border-critical-base);
-    color: rgba(218, 51, 25, 0.6);
-
-    /* text-12-regular */
-    font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
-    font-style: normal;
-    font-weight: var(--font-weight-regular);
-    line-height: var(--line-height-large); /* 166.667% */
-    letter-spacing: var(--letter-spacing-normal);
-
-    &[data-component="icon"] {
-      color: var(--icon-critical-active);
-    }
+  padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
+
+  /* text-14-regular */
+  font-family: var(--font-family-sans);
+  font-size: var(--font-size-base);
+  font-style: normal;
+  font-weight: var(--font-weight-regular);
+  line-height: var(--line-height-large);
+  letter-spacing: var(--letter-spacing-normal);
+  color: var(--text-strong);
+
+  --card-gap: 8px;
+  --card-icon: 16px;
+  --card-indent: 0px;
+  --card-line-pad: 8px;
+
+  --card-accent: var(--icon-active);
+
+  &:has([data-slot="card-title"]) {
+    gap: 8px;
+  }
+
+  &:has([data-slot="card-title-icon"]) {
+    --card-indent: calc(var(--card-icon) + var(--card-gap));
+  }
+
+  &::before {
+    content: "";
+    position: absolute;
+    left: 0;
+    top: var(--card-line-pad);
+    bottom: var(--card-line-pad);
+    width: 2px;
+    border-radius: 2px;
+    background-color: var(--card-accent);
+  }
+
+  :where([data-card="title"], [data-slot="card-title"]) {
+    color: var(--text-strong);
+    font-weight: var(--font-weight-medium);
+  }
+
+  :where([data-slot="card-title"]) {
+    display: flex;
+    align-items: center;
+    gap: var(--card-gap);
+  }
+
+  :where([data-slot="card-title"]) [data-component="icon"] {
+    color: var(--card-accent);
+  }
+
+  :where([data-slot="card-title-icon"]) {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: var(--card-icon);
+    height: var(--card-icon);
+    flex: 0 0 auto;
+  }
+
+  :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
+    color: var(--text-weak);
+  }
+
+  :where([data-slot="card-title-icon"])
+    [data-slot="icon-svg"]
+    :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
+    stroke-width: 1.5px !important;
+  }
+
+  :where([data-card="description"], [data-slot="card-description"]) {
+    color: var(--text-base);
+    white-space: pre-wrap;
+    overflow-wrap: anywhere;
+    word-break: break-word;
+  }
+
+  :where([data-card="actions"], [data-slot="card-actions"]) {
+    padding-left: var(--card-indent);
   }
 }

+ 6 - 8
packages/ui/src/components/card.stories.tsx

@@ -1,5 +1,5 @@
 // @ts-nocheck
-import { Card } from "./card"
+import { Card, CardActions, CardDescription, CardTitle } from "./card"
 import { Button } from "./button"
 
 const docs = `### Overview
@@ -49,15 +49,13 @@ export default {
   render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
     return (
       <Card variant={props.variant}>
-        <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
-          <div style={{ flex: 1 }}>
-            <div style={{ fontWeight: 500 }}>Card title</div>
-            <div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
-          </div>
-          <Button size="small" variant="ghost">
+        <CardTitle variant={props.variant}>Card title</CardTitle>
+        <CardDescription>Small supporting text.</CardDescription>
+        <CardActions>
+          <Button size="small" variant="secondary">
             Action
           </Button>
-        </div>
+        </CardActions>
       </Card>
     )
   },

+ 104 - 3
packages/ui/src/components/card.tsx

@@ -1,16 +1,57 @@
 import { type ComponentProps, splitProps } from "solid-js"
+import { Icon, type IconProps } from "./icon"
+
+type Variant = "normal" | "error" | "warning" | "success" | "info"
 
 export interface CardProps extends ComponentProps<"div"> {
-  variant?: "normal" | "error" | "warning" | "success" | "info"
+  variant?: Variant
+}
+
+export interface CardTitleProps extends ComponentProps<"div"> {
+  variant?: Variant
+
+  /**
+   * Optional title icon.
+   *
+   * - `undefined`: picks a default icon based on `variant` (error/warning/success/info)
+   * - `false`/`null`: disables the icon
+   * - `Icon` name: forces a specific icon
+   */
+  icon?: IconProps["name"] | false | null
+}
+
+function pick(variant: Variant) {
+  if (variant === "error") return "circle-ban-sign" as const
+  if (variant === "warning") return "warning" as const
+  if (variant === "success") return "circle-check" as const
+  if (variant === "info") return "help" as const
+  return
+}
+
+function mix(style: ComponentProps<"div">["style"], value?: string) {
+  if (!value) return style
+  if (!style) return { "--card-accent": value }
+  if (typeof style === "string") return `${style};--card-accent:${value};`
+  return { ...(style as Record<string, string | number>), "--card-accent": value }
 }
 
 export function Card(props: CardProps) {
-  const [split, rest] = splitProps(props, ["variant", "class", "classList"])
+  const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"])
+  const variant = () => split.variant ?? "normal"
+  const accent = () => {
+    const v = variant()
+    if (v === "error") return "var(--icon-critical-base)"
+    if (v === "warning") return "var(--icon-warning-active)"
+    if (v === "success") return "var(--icon-success-active)"
+    if (v === "info") return "var(--icon-info-active)"
+    return
+  }
   return (
     <div
       {...rest}
       data-component="card"
-      data-variant={split.variant || "normal"}
+      data-variant={variant()}
+      style={mix(split.style, accent())}
       classList={{
         ...(split.classList ?? {}),
         [split.class ?? ""]: !!split.class,
@@ -20,3 +61,63 @@ export function Card(props: CardProps) {
     </div>
   )
 }
+
+export function CardTitle(props: CardTitleProps) {
+  const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"])
+  const show = () => split.icon !== false && split.icon !== null
+  const name = () => {
+    if (split.icon === false || split.icon === null) return
+    if (typeof split.icon === "string") return split.icon
+    return pick(split.variant ?? "normal")
+  }
+  const placeholder = () => !name()
+  return (
+    <div
+      {...rest}
+      data-slot="card-title"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {show() ? (
+        <span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
+          <Icon name={name() ?? "dash"} size="small" />
+        </span>
+      ) : null}
+      {split.children}
+    </div>
+  )
+}
+
+export function CardDescription(props: ComponentProps<"div">) {
+  const [split, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <div
+      {...rest}
+      data-slot="card-description"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}
+
+export function CardActions(props: ComponentProps<"div">) {
+  const [split, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <div
+      {...rest}
+      data-slot="card-actions"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}

+ 6 - 0
packages/ui/src/components/markdown.css

@@ -60,6 +60,7 @@
   ol {
     margin-top: 0.5rem;
     margin-bottom: 1rem;
+    margin-left: 0;
     padding-left: 1.5rem;
     list-style-position: outside;
   }
@@ -70,6 +71,7 @@
 
   ol {
     list-style-type: decimal;
+    padding-left: 2.25rem;
   }
 
   li {
@@ -98,6 +100,10 @@
     padding-left: 1rem; /* Minimal indent for nesting only */
   }
 
+  li > ol {
+    padding-left: 1.75rem;
+  }
+
   /* Blockquotes */
   blockquote {
     border-left: 2px solid var(--border-weak-base);

+ 0 - 36
packages/ui/src/components/message-part.css

@@ -309,41 +309,6 @@
   }
 }
 
-[data-component="tool-error"] {
-  display: flex;
-  align-items: start;
-  gap: 8px;
-
-  [data-slot="icon-svg"] {
-    color: var(--icon-critical-base);
-    margin-top: 4px;
-  }
-
-  [data-slot="message-part-tool-error-content"] {
-    display: flex;
-    align-items: start;
-    gap: 8px;
-  }
-
-  [data-slot="message-part-tool-error-title"] {
-    font-family: var(--font-family-sans);
-    font-size: var(--font-size-base);
-    font-style: normal;
-    font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large);
-    letter-spacing: var(--letter-spacing-normal);
-    color: var(--text-on-critical-base);
-    white-space: nowrap;
-  }
-
-  [data-slot="message-part-tool-error-message"] {
-    color: var(--text-on-critical-weak);
-    max-height: 240px;
-    overflow-y: auto;
-    word-break: break-word;
-  }
-}
-
 [data-component="tool-output"] {
   white-space: pre;
   padding: 0;
@@ -717,7 +682,6 @@
 [data-component="user-message"] [data-slot="user-message-text"],
 [data-component="text-part"],
 [data-component="reasoning-part"],
-[data-component="tool-error"],
 [data-component="tool-output"],
 [data-component="bash-output"],
 [data-component="edit-content"],

+ 2 - 19
packages/ui/src/components/message-part.tsx

@@ -39,6 +39,7 @@ import { Card } from "./card"
 import { Collapsible } from "./collapsible"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
+import { ToolErrorCard } from "./tool-error-card"
 import { Checkbox } from "./checkbox"
 import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
@@ -1189,25 +1190,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
                   </div>
                 )
               }
-              const [title, ...rest] = cleaned.split(": ")
-              return (
-                <Card variant="error">
-                  <div data-component="tool-error">
-                    <Icon name="circle-ban-sign" size="small" />
-                    <Switch>
-                      <Match when={title && title.length < 30}>
-                        <div data-slot="message-part-tool-error-content">
-                          <div data-slot="message-part-tool-error-title">{title}</div>
-                          <span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
-                        </div>
-                      </Match>
-                      <Match when={true}>
-                        <span data-slot="message-part-tool-error-message">{cleaned}</span>
-                      </Match>
-                    </Switch>
-                  </div>
-                </Card>
-              )
+              return <ToolErrorCard tool={part().tool} error={error()} />
             }}
           </Match>
           <Match when={true}>

+ 54 - 0
packages/ui/src/components/tool-error-card.css

@@ -0,0 +1,54 @@
+[data-component="card"][data-kind="tool-error-card"] {
+  --card-pad-y: 8px;
+  --card-line-pad: 12px;
+
+  > [data-component="collapsible"].tool-collapsible {
+    gap: 0px;
+  }
+
+  > [data-component="collapsible"].tool-collapsible[data-open="true"] {
+    gap: 4px;
+  }
+
+  [data-component="tool-error-card-icon"] [data-component="icon"] {
+    color: var(--card-accent);
+  }
+
+  [data-slot="tool-error-card-content"] {
+    position: relative;
+    padding-left: 24px;
+    margin-bottom: 8px;
+    -webkit-user-select: text;
+    user-select: text;
+  }
+
+  > [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] {
+    padding-right: 40px;
+  }
+
+  [data-slot="tool-error-card-copy"] {
+    position: absolute;
+    top: 0;
+    right: 0;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 0.15s ease;
+    will-change: opacity;
+  }
+
+  &:hover [data-slot="tool-error-card-copy"],
+  &:focus-within [data-slot="tool-error-card-copy"] {
+    opacity: 1;
+    pointer-events: auto;
+  }
+
+  [data-slot="tool-error-card-content"] :where(*)::selection {
+    background: var(--surface-critical-base);
+    color: var(--text-on-critical-base);
+  }
+
+  [data-slot="tool-error-card-content"] :where(*)::-moz-selection {
+    background: var(--surface-critical-base);
+    color: var(--text-on-critical-base);
+  }
+}

+ 96 - 0
packages/ui/src/components/tool-error-card.stories.tsx

@@ -0,0 +1,96 @@
+// @ts-nocheck
+import { ToolErrorCard } from "./tool-error-card"
+
+const docs = `### Overview
+Tool call failure summary styled like a tool trigger.
+
+### API
+- Required: \`tool\` (tool id, e.g. apply_patch, bash)
+- Required: \`error\` (error string)
+
+### Behavior
+- Collapsible; click header to expand/collapse.
+`
+
+const samples = [
+  {
+    tool: "apply_patch",
+    error:
+      "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
+  },
+  {
+    tool: "bash",
+    error: "bash Command failed: exit code 1: bun test --watch",
+  },
+  {
+    tool: "read",
+    error:
+      "read File not found: /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/does-not-exist.tsx",
+  },
+  {
+    tool: "glob",
+    error: "glob Pattern error: Invalid glob pattern: **/*[",
+  },
+  {
+    tool: "grep",
+    error: "grep Regex error: Invalid regular expression: (unterminated group",
+  },
+  {
+    tool: "webfetch",
+    error: "webfetch Request failed: 502 Bad Gateway",
+  },
+  {
+    tool: "websearch",
+    error: "websearch Rate limited: Please try again in 30 seconds",
+  },
+  {
+    tool: "codesearch",
+    error: "codesearch Timeout: exceeded 120s",
+  },
+  {
+    tool: "question",
+    error: "question Dismissed: user dismissed this question",
+  },
+]
+
+export default {
+  title: "UI/ToolErrorCard",
+  id: "components-tool-error-card",
+  component: ToolErrorCard,
+  tags: ["autodocs"],
+  parameters: {
+    docs: {
+      description: {
+        component: docs,
+      },
+    },
+  },
+  args: {
+    tool: "apply_patch",
+    error: samples[0].error,
+  },
+  argTypes: {
+    tool: {
+      control: "select",
+      options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"],
+    },
+    error: {
+      control: "text",
+    },
+  },
+  render: (props: { tool: string; error: string }) => {
+    return <ToolErrorCard tool={props.tool} error={props.error} />
+  },
+}
+
+export const All = {
+  render: () => {
+    return (
+      <div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
+        {samples.map((item) => (
+          <ToolErrorCard tool={item.tool} error={item.error} />
+        ))}
+      </div>
+    )
+  },
+}

+ 112 - 0
packages/ui/src/components/tool-error-card.tsx

@@ -0,0 +1,112 @@
+import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js"
+import { Card, CardDescription } from "./card"
+import { Collapsible } from "./collapsible"
+import { Icon } from "./icon"
+import { IconButton } from "./icon-button"
+import { Tooltip } from "./tooltip"
+import { useI18n } from "../context/i18n"
+
+export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
+  tool: string
+  error: string
+}
+
+export function ToolErrorCard(props: ToolErrorCardProps) {
+  const i18n = useI18n()
+  const [open, setOpen] = createSignal(true)
+  const [copied, setCopied] = createSignal(false)
+  const [split, rest] = splitProps(props, ["tool", "error"])
+  const name = createMemo(() => {
+    const map: Record<string, string> = {
+      read: "ui.tool.read",
+      list: "ui.tool.list",
+      glob: "ui.tool.glob",
+      grep: "ui.tool.grep",
+      webfetch: "ui.tool.webfetch",
+      websearch: "ui.tool.websearch",
+      codesearch: "ui.tool.codesearch",
+      bash: "ui.tool.shell",
+      apply_patch: "ui.tool.patch",
+      question: "ui.tool.questions",
+    }
+    const key = map[split.tool]
+    if (!key) return split.tool
+    return i18n.t(key)
+  })
+  const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim())
+  const tail = createMemo(() => {
+    const value = cleaned()
+    const prefix = `${split.tool} `
+    if (value.startsWith(prefix)) return value.slice(prefix.length)
+    return value
+  })
+
+  const subtitle = createMemo(() => {
+    const parts = tail().split(": ")
+    if (parts.length <= 1) return "Failed"
+    const head = (parts[0] ?? "").trim()
+    if (!head) return "Failed"
+    return head[0] ? head[0].toUpperCase() + head.slice(1) : "Failed"
+  })
+
+  const body = createMemo(() => {
+    const parts = tail().split(": ")
+    if (parts.length <= 1) return cleaned()
+    return parts.slice(1).join(": ").trim() || cleaned()
+  })
+
+  const copy = async () => {
+    const text = cleaned()
+    if (!text) return
+    await navigator.clipboard.writeText(text)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
+      <Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
+        <Collapsible.Trigger>
+          <div data-component="tool-trigger">
+            <div data-slot="basic-tool-tool-trigger-content">
+              <span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
+                <Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
+              </span>
+              <div data-slot="basic-tool-tool-info">
+                <div data-slot="basic-tool-tool-info-structured">
+                  <div data-slot="basic-tool-tool-info-main">
+                    <span data-slot="basic-tool-tool-title">{name()}</span>
+                    <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <Collapsible.Arrow />
+          </div>
+        </Collapsible.Trigger>
+        <Collapsible.Content>
+          <div data-slot="tool-error-card-content">
+            <Show when={open()}>
+              <div data-slot="tool-error-card-copy">
+                <Tooltip value={copied() ? i18n.t("ui.message.copied") : "Copy error"} placement="top" gutter={4}>
+                  <IconButton
+                    icon={copied() ? "check" : "copy"}
+                    size="normal"
+                    variant="ghost"
+                    onMouseDown={(e) => e.preventDefault()}
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      copy()
+                    }}
+                    aria-label={copied() ? i18n.t("ui.message.copied") : "Copy error"}
+                  />
+                </Tooltip>
+              </div>
+            </Show>
+            <Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
+          </div>
+        </Collapsible.Content>
+      </Collapsible>
+    </Card>
+  )
+}

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

@@ -13,6 +13,7 @@
 @import "../components/basic-tool.css" layer(components);
 @import "../components/button.css" layer(components);
 @import "../components/card.css" layer(components);
+@import "../components/tool-error-card.css" layer(components);
 @import "../components/checkbox.css" layer(components);
 @import "../components/file.css" layer(components);
 @import "../components/collapsible.css" layer(components);

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

@@ -131,7 +131,7 @@
   --surface-warning-base: #fcf3cb;
   --surface-warning-weak: #fdfaec;
   --surface-warning-strong: #fbdd46;
-  --surface-critical-base: #feefeb;
+  --surface-critical-base: #fff2f0;
   --surface-critical-weak: #fff8f6;
   --surface-critical-strong: #fc533a;
   --surface-info-base: #fdecfe;
@@ -391,7 +391,7 @@
     --surface-warning-base: #fdf3cf;
     --surface-warning-weak: #fdfaed;
     --surface-warning-strong: #fcd53a;
-    --surface-critical-base: #42120b;
+    --surface-critical-base: #1f0603;
     --surface-critical-weak: #28110c;
     --surface-critical-strong: #fc533a;
     --surface-info-base: #feecfe;

+ 6 - 0
packages/ui/src/theme/themes/oc-2.json

@@ -13,6 +13,9 @@
       "interactive": "#034cff",
       "diffAdd": "#9ff29a",
       "diffDelete": "#fc533a"
+    },
+    "overrides": {
+      "surface-critical-base": "#FFF2F0"
     }
   },
   "dark": {
@@ -26,6 +29,9 @@
       "interactive": "#034cff",
       "diffAdd": "#c8ffc4",
       "diffDelete": "#fc533a"
+    },
+    "overrides": {
+      "surface-critical-base": "#1F0603"
     }
   }
 }

+ 2 - 1
script/beta.ts

@@ -79,7 +79,8 @@ async function fix(pr: PR, files: string[]) {
 async function main() {
   console.log("Fetching open PRs with beta label...")
 
-  const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text()
+  const stdout =
+    await $`gh pr list --state open --draft=false --label beta --json number,title,author,labels --limit 100`.text()
   const prs: PR[] = JSON.parse(stdout).sort((a: PR, b: PR) => a.number - b.number)
 
   console.log(`Found ${prs.length} open PRs with beta label`)