Compare commits

..

25 Commits

Author SHA1 Message Date
Kit Langton
a1b06d63c9 fix(account): resilient orgs fetch (#16944) 2026-03-11 00:39:07 +00:00
Dax Raad
1b6820bab5 sync 2026-03-10 20:13:56 -04:00
Adam
89bf199c07 chore(app): fix tests 2026-03-10 19:03:44 -05:00
Aiden Cline
5acfdd1c5d chore: kill old copilot 403 message that was used for old plugin migration (#16904) 2026-03-10 16:20:41 -05:00
Dax Raad
556703f8ab ci: cancel duplicate workflow runs and add read permissions
- Add concurrency settings to cancel outdated runs when new commits are pushed
- Add contents: read permission for security hardening
- Remove redundant required job that checked test results
2026-03-10 17:17:11 -04:00
Frank
6b9f8fb9b3 zen: raise limit 2026-03-10 15:22:02 -04:00
David Hill
f77e5cf8fb feat(ui): restyle Card and improve tool error cards (#16888)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-10 13:52:32 -05:00
Frank
e6cdc21f2d zen: raise limit 2026-03-10 14:40:18 -04:00
Dax Raad
1fe8d4d7ad ci: exclude draft PRs from beta labeling process to prevent unfinished work from being included in releases 2026-03-10 14:08:55 -04:00
Dax Raad
e44320980d ci: install setuptools to prevent Python distutils errors during dependency installation 2026-03-10 14:08:38 -04:00
Adam
f5d7fe3072 chore: cleanup 2026-03-10 13:00:14 -05:00
Adam
835a27cf51 fix(app): terminal jank 2026-03-10 13:00:14 -05:00
Adam
85afaaa13d fix(app): terminal focus issues and jank 2026-03-10 13:00:14 -05:00
opencode-agent[bot]
490615169e chore: update nix node_modules hashes 2026-03-10 17:14:55 +00:00
Dax
bb232247d0 Fix ESM imports for @opencode-ai/plugin (#16916) 2026-03-10 13:11:36 -04:00
opencode-agent[bot]
94c128f73b chore: generate 2026-03-10 16:56:30 +00:00
Dax
613562f504 core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:53:47 -04:00
Adam
9c4325bcf8 fix(core): don't permit access to system directories (#16891) 2026-03-10 11:32:05 -05:00
Aiden Cline
ad08fd57df chore: rekram1-node is no longer on vacation (#16905) 2026-03-10 10:27:04 -05:00
opencode-agent[bot]
54ba59d3e1 chore: generate 2026-03-10 15:14:46 +00:00
James Long
a4330a225d feat(core): allow passing workspaceID into session create endpoint (#16798) 2026-03-10 11:12:51 -04:00
James Long
69ddc91c35 fix(core): a chunk timeout when processing llm stream (#16366) 2026-03-10 11:12:14 -04:00
James Long
4c4aed5a87 fix(core): make worktrees read the project id from local workspace (#16795) 2026-03-10 11:11:28 -04:00
opencode-agent[bot]
5a40158abf chore: generate 2026-03-10 15:07:35 +00:00
Jérôme Benoit
4dce485854 fix(opencode): add thinking variants support for SAP AI provider (#14958)
Co-authored-by: Test <test@test.com>
Co-authored-by: Stephen Collings <stevoland@gmail.com>
2026-03-10 10:05:45 -05:00
101 changed files with 5931 additions and 1356 deletions

View File

@@ -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

View File

@@ -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"

View File

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

View File

@@ -5,16 +5,8 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -50,7 +42,10 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -73,8 +68,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,5 +4,3 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

1393
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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="
}
}

View File

@@ -43,6 +43,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.29",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -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()

View File

@@ -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"]'

View File

@@ -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")}

View File

@@ -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)

View File

@@ -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") {

View File

@@ -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} />

View File

@@ -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>
)
}

View File

@@ -8,12 +8,6 @@ import { useI18n } from "~/context/i18n"
export function Footer() {
const language = useLanguage()
const i18n = useI18n()
const discordRoute = createMemo(() => {
const locale = language.locale()
return locale === "zh" || locale === "zht"
? { key: "footer.feishu", link: language.route("/feishu") }
: { key: "footer.discord", link: language.route("/discord") }
})
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -38,7 +32,7 @@ export function Footer() {
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
</div>
<div data-slot="cell">
<a href={link()}>{i18n.t(key())}</a>
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
</div>
<div data-slot="cell">
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>

View File

@@ -21,7 +21,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "Docs",
"footer.changelog": "Changelog",
"footer.feishu": "Feishu",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,7 +24,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文档",
"footer.changelog": "更新日志",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,7 +24,6 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文件",
"footer.changelog": "更新日誌",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -1,7 +0,0 @@
import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
)
}

View File

@@ -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()

View File

@@ -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` } }),
)
},
}
}

View File

@@ -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(),

View File

@@ -0,0 +1,17 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`selected_org_id` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `account_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_account_id` text,
FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `selected_org_id`;

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -108,6 +109,7 @@
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",

View File

@@ -0,0 +1,35 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.email, table.url] })],
)

View File

@@ -0,0 +1,43 @@
import { Effect, Option, ServiceMap } from "effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountService,
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}
export namespace Account {
export const Account = AccountSchema
export type Account = AccountSchema
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
}
}

View File

@@ -0,0 +1,146 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
const decodeAccount = Schema.decodeUnknownSync(Account)
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
const ACCOUNT_STATE_ID = 1
const db = <A>(run: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(run),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: orgID },
})
.run()
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: string
refreshToken: string
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
AccountRepo,
AccountRepo.of({
active: Effect.fn("AccountRepo.active")(() =>
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 })),
),
),
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
db((db) =>
Database.transaction((tx) => {
tx.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
).pipe(Effect.asVoid),
),
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
),
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
),
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
),
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) =>
Database.transaction((tx) => {
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
setState(tx, input.id, orgID)
}),
).pipe(Effect.asVoid)
}),
}),
)
}

View File

@@ -0,0 +1,73 @@
import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type AccountError = AccountRepoError | AccountServiceError
export class Login extends Schema.Class<Login>("Login")({
code: Schema.String,
user: Schema.String,
url: Schema.String,
server: Schema.String,
expiry: Schema.Number,
interval: Schema.Number,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>

View File

@@ -0,0 +1,392 @@
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: Org[]
}
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
})
const RemoteOrgs = Schema.Array(RemoteOrg)
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
})
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
})
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
})
const ClientId = Schema.Struct({ client_id: Schema.String })
const DeviceTokenRequest = Schema.Struct({
grant_type: Schema.String,
device_code: Schema.String,
client_id: Schema.String,
})
const clientId = "opencode-cli"
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
const mapAccountServiceError =
(operation: string, message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((error) =>
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
),
)
export class AccountService extends ServiceMap.Service<
AccountService,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError(operation, "HTTP request failed"),
)
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.filterStatusOk(response).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
: Effect.fail(error),
),
mapAccountServiceError(operation),
)
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
const response = yield* execute(
"token.refresh",
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bodyUrlParams({
grant_type: "refresh_token",
refresh_token: found.refresh_token,
}),
),
)
const ok = yield* okOrNone("token.refresh", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
mapAccountServiceError("token.refresh", "Failed to decode response"),
)
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken({
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
})
return Option.some(AccessToken.make(parsed.access_token))
})
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const account = maybeAccount.value
const accessToken = yield* tokenForRow(account)
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
return Option.some({ account, accessToken: accessToken.value })
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"orgs",
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
const ok = yield* okOrNone("orgs", response)
if (Option.isNone(ok)) return []
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
mapAccountServiceError("orgs", "Failed to decode response"),
)
return orgs
.filter((org) => org.id !== undefined && org.name !== undefined)
.map((org) => new Org({ id: org.id!, name: org.name! }))
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"config",
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
const ok = yield* okOrNone("config", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
mapAccountServiceError("config", "Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffect(
"login",
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
),
)
const ok = yield* okOrNone("login", response)
if (Option.isNone(ok)) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
}
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
mapAccountServiceError("login", "Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffect(
"poll",
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("poll", "Failed to decode response"),
)
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const access = parsed.access_token
const fetchUser = executeRead(
"poll.user",
HttpClientRequest.get(`${input.server}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(User)(r).pipe(
mapAccountServiceError("poll.user", "Failed to decode response"),
),
),
)
const fetchOrgs = executeRead(
"poll.orgs",
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
mapAccountServiceError("poll.orgs", "Failed to decode response"),
),
),
)
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
const userId = user.id
const userEmail = user.email
if (!userId || !userEmail) {
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
const now = yield* Clock.currentTimeMillis
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* repo.persistAccount({
id: userId,
email: userEmail,
url: input.server,
accessToken: access,
refreshToken: refresh,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: userEmail })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}

View File

@@ -0,0 +1,194 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
if (email) {
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* Prompt.outro("Logged out from " + email)
return
}
const active = yield* service.active()
const activeID = Option.map(active, (a) => a.id)
yield* Prompt.intro("Log out")
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
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}`,
}
})
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
if (Option.isNone(selected)) return
yield* service.remove(selected.value.id)
yield* Prompt.outro("Logged out from " + selected.value.email)
})
interface OrgChoice {
orgID: OrgID
accountID: AccountID
label: string
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
if (opts.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
if (Option.isNone(selected)) return
const choice = selected.value
yield* service.use(choice.accountID, Option.some(choice.orgID))
yield* Prompt.outro("Switched to " + choice.label)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
},
})
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null
}
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
} catch {
return false
}
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
return
}
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

View File

@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
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, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
index = match
} else if (plugin.auth.methods.length > 1) {
const selected = await prompts.select({
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})),
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
index = parseInt(selected)
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -171,11 +165,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
return false
}
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
@@ -203,19 +192,20 @@ export function resolvePluginProviders(input: {
return result
}
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const AuthListCommand = cmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers",
async handler() {
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
@@ -231,7 +221,6 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
},
})
export const AuthLoginCommand = cmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
anthropic: "Claude Max or API key",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -461,10 +449,10 @@ export const AuthLoginCommand = cmd({
},
})
export const AuthLogoutCommand = cmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
async handler() {
async handler(_args) {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")

View File

@@ -402,9 +402,12 @@ function App() {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
workspaceID,
})
dialog.clear()
},

View File

@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],

View File

@@ -47,7 +47,7 @@ async function openWorkspace(input: {
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({}).catch(() => undefined)
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",

View File

@@ -37,6 +37,7 @@ import { DialogSkill } from "../dialog-skill"
export type PromptProps = {
sessionID?: string
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
@@ -542,7 +543,9 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({})
const res = await sdk.client.session.create({
workspaceID: props.workspaceID,
})
if (res.error) {
console.log("Creating a session failed:", res.error)

View File

@@ -5,6 +5,7 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
workspaceID?: string
}
export type SessionRoute = {

View File

@@ -121,6 +121,7 @@ export function Home() {
promptRef.set(r)
}}
hint={Hint}
workspaceID={route.workspaceID}
/>
</box>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>

View File

@@ -383,7 +383,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then((res) => copy(res.data!.share!.url))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to share session",
variant: "error",
})
})
dialog.clear()
},
},
@@ -486,7 +491,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to unshare session",
variant: "error",
})
})
dialog.clear()
},
},

View File

@@ -0,0 +1,25 @@
import * as prompts from "@clack/prompts"
import { Effect, Option } from "effect"
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
export const spinner = () => {
const s = prompts.spinner()
return {
start: (msg: string) => Effect.sync(() => s.start(msg)),
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
}
}

View File

@@ -12,6 +12,7 @@ import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import {
type ParseError as JsoncParseError,
applyEdits,
@@ -32,7 +33,7 @@ import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
import { Account } from "@/account"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
@@ -108,10 +109,6 @@ export namespace Config {
}
}
const token = await Control.token()
if (token) {
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
@@ -178,6 +175,32 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([
Account.config(active.id, active.active_org_id),
Account.token(active.id),
])
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
}
} catch (err: any) {
log.debug("failed to fetch remote account config", { error: err?.message ?? err })
}
}
// Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
@@ -972,6 +995,14 @@ export namespace Config {
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),

View File

@@ -1,5 +1,5 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "@/project/project.sql"
import { ProjectTable } from "../project/project.sql"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),

View File

@@ -1,22 +0,0 @@
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import { eq } from "drizzle-orm"
import { Timestamps } from "@/storage/schema.sql"
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)

View File

@@ -1,67 +0,0 @@
import { eq, and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ControlAccountTable } from "./control.sql"
import z from "zod"
export * from "./control.sql"
export namespace Control {
export const Account = z.object({
email: z.string(),
url: z.string(),
})
export type Account = z.infer<typeof Account>
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
return {
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
const res = await fetch(`${row.url}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
}).toString(),
})
if (!res.ok) return
const json = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}
Database.use((db) =>
db
.update(ControlAccountTable)
.set({
access_token: json.access_token,
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
.run(),
)
return json.access_token
}
}

View File

@@ -0,0 +1,4 @@
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)

View File

@@ -11,6 +11,7 @@ import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
export namespace File {
const log = Log.create({ service: "file" })
@@ -345,10 +346,7 @@ export namespace File {
if (isGlobalHome) {
const dirs = new Set<string>()
const ignore = new Set<string>()
if (process.platform === "darwin") ignore.add("Library")
if (process.platform === "win32") ignore.add("AppData")
const ignore = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)

View File

@@ -0,0 +1,59 @@
import path from "path"
import os from "os"
const home = os.homedir()
// macOS directories that trigger TCC (Transparency, Consent, and Control)
// permission prompts when accessed by a non-sandboxed process.
const DARWIN_HOME = [
// Media
"Music",
"Pictures",
"Movies",
// User-managed folders synced via iCloud / subject to TCC
"Downloads",
"Desktop",
"Documents",
// Other system-managed
"Public",
"Applications",
"Library",
]
const DARWIN_LIBRARY = [
"Application Support/AddressBook",
"Calendars",
"Mail",
"Messages",
"Safari",
"Cookies",
"Application Support/com.apple.TCC",
"PersonalizationPortrait",
"Metadata/CoreSpotlight",
"Suggestions",
]
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
export namespace Protected {
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
}
}

View File

@@ -14,6 +14,7 @@ import type ParcelWatcher from "@parcel/watcher"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
import { Protected } from "./protected"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -76,7 +77,7 @@ export namespace FileWatcher {
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()],
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {

View File

@@ -3,7 +3,8 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { AuthCommand } from "./cli/cmd/auth"
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall"
@@ -134,7 +135,11 @@ let cli = yargs(hideBin(process.argv))
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
.command(AuthCommand)
.command(LoginCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ProvidersCommand)
.command(AgentCommand)
.command(UpgradeCommand)
.command(UninstallCommand)

View File

@@ -1,5 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "@/storage/schema.sql"
import { Timestamps } from "../storage/schema.sql"
export const ProjectTable = sqliteTable("project", {
id: text().primaryKey(),

View File

@@ -88,6 +88,12 @@ export namespace Project {
}
}
function readCachedId(dir: string) {
return Filesystem.readText(path.join(dir, "opencode"))
.then((x) => x.trim())
.catch(() => undefined)
}
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
@@ -101,19 +107,43 @@ export namespace Project {
const gitBinary = which("git")
// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
.then((x) => x.trim())
.catch(() => undefined)
let id = await readCachedId(dotgit)
if (!gitBinary) {
return {
id: id ?? "global",
worktree: sandbox,
sandbox: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const worktree = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const common = gitpath(sandbox, await result.text())
// Avoid going to parent of sandbox when git-common-dir is empty.
return common === sandbox ? sandbox : path.dirname(common)
})
.catch(() => undefined)
if (!worktree) {
return {
id: id ?? "global",
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
// In the case of a git worktree, it can't cache the id
// because `.git` is not a folder, but it always needs the
// same project id as the common dir, so we resolve it now
if (id == null) {
id = await readCachedId(path.join(worktree, ".git"))
}
// generate id from root commit
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
@@ -132,7 +162,7 @@ export namespace Project {
return {
id: "global",
worktree: sandbox,
sandbox: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
@@ -147,7 +177,7 @@ export namespace Project {
return {
id: "global",
worktree: sandbox,
sandbox: sandbox,
sandbox,
vcs: "git",
}
}
@@ -161,33 +191,14 @@ export namespace Project {
if (!top) {
return {
id,
sandbox,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
sandbox = top
const worktree = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const common = gitpath(sandbox, await result.text())
// Avoid going to parent of sandbox when git-common-dir is empty.
return common === sandbox ? sandbox : path.dirname(common)
})
.catch(() => undefined)
if (!worktree) {
return {
id,
sandbox,
worktree: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
return {
id,
sandbox,

View File

@@ -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
}

View File

@@ -46,6 +46,8 @@ import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
const DEFAULT_CHUNK_TIMEOUT = 120_000
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -85,6 +87,54 @@ export namespace Provider {
})
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
const reader = res.body.getReader()
const body = new ReadableStream<Uint8Array>({
async pull(ctrl) {
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const id = setTimeout(() => {
const err = new Error("SSE read timed out")
ctl.abort(err)
void reader.cancel(err)
reject(err)
}, ms)
reader.read().then(
(part) => {
clearTimeout(id)
resolve(part)
},
(err) => {
clearTimeout(id)
reject(err)
},
)
})
if (part.done) {
ctrl.close()
return
}
ctrl.enqueue(part.value)
},
async cancel(reason) {
ctl.abort(reason)
await reader.cancel(reason)
},
})
return new Response(body, {
headers: new Headers(res.headers),
status: res.status,
statusText: res.statusText,
})
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
@@ -1092,21 +1142,23 @@ export namespace Provider {
if (existing) return existing
const customFetch = options["fetch"]
const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
delete options["chunkTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
// Preserve custom fetch if it exists, wrap it with timeout logic
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
const signals: AbortSignal[] = []
if (options["timeout"] !== undefined && options["timeout"] !== null) {
const signals: AbortSignal[] = []
if (opts.signal) signals.push(opts.signal)
if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"]))
if (opts.signal) signals.push(opts.signal)
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
signals.push(AbortSignal.timeout(options["timeout"]))
const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
opts.signal = combined
}
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined
// Strip openai itemId metadata following what codex does
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
@@ -1126,11 +1178,14 @@ export namespace Provider {
}
}
return fetchFn(input, {
const res = await fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]

View File

@@ -657,9 +657,21 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
return {}
case "@mymediset/sap-ai-provider":
case "@jerome-benoit/sap-ai-provider-v2":
if (model.api.id.includes("anthropic")) {
if (isAnthropicAdaptive) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
},
effort,
},
]),
)
}
return {
high: {
thinking: {
@@ -675,7 +687,26 @@ export namespace ProviderTransform {
},
}
}
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
if (model.api.id.includes("gemini") && id.includes("2.5")) {
return {
high: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 16000,
},
},
max: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 24576,
},
},
}
}
if (model.api.id.includes("gpt") || /\bo[1-9]/.test(model.api.id)) {
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
return {}
}
return {}
}

View File

@@ -219,6 +219,7 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: Identifier.schema("workspace").optional(),
})
.optional(),
async (input) => {
@@ -227,6 +228,7 @@ export namespace Session {
directory: Instance.directory,
title: input?.title,
permission: input?.permission,
workspaceID: input?.workspaceID,
})
},
)
@@ -242,6 +244,7 @@ export namespace Session {
const title = getForkedTitle(original.title)
const session = await createNext({
directory: Instance.directory,
workspaceID: original.workspaceID,
title,
})
const msgs = await messages({ sessionID: input.sessionID })
@@ -292,6 +295,7 @@ export namespace Session {
id?: string
title?: string
parentID?: string
workspaceID?: string
directory: string
permission?: PermissionNext.Ruleset
}) {
@@ -301,7 +305,7 @@ export namespace Session {
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
workspaceID: WorkspaceContext.workspaceID,
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,

View File

@@ -1,9 +1,9 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "@/snapshot"
import type { PermissionNext } from "@/permission/next"
import { Timestamps } from "@/storage/schema.sql"
import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission/next"
import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">

View File

@@ -1,4 +1,5 @@
import { Bus } from "@/bus"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
@@ -11,8 +12,51 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext {
const log = Log.create({ service: "share-next" })
type ApiEndpoints = {
create: string
sync: (shareId: string) => string
remove: (shareId: string) => string
data: (shareId: string) => string
}
function apiEndpoints(resource: string): ApiEndpoints {
return {
create: `/api/${resource}`,
sync: (shareId) => `/api/${resource}/${shareId}/sync`,
remove: (shareId) => `/api/${resource}/${shareId}`,
data: (shareId) => `/api/${resource}/${shareId}/data`,
}
}
const legacyApi = apiEndpoints("share")
const consoleApi = apiEndpoints("shares")
export async function url() {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
const req = await request()
return req.baseUrl
}
export async function request(): Promise<{
headers: Record<string, string>
api: ApiEndpoints
baseUrl: string
}> {
const headers: Record<string, string> = {}
const active = Account.active()
if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl }
}
const token = await Account.token(active.id)
if (!token) {
throw new Error("No active account token available for sharing")
}
headers["authorization"] = `Bearer ${token}`
headers["x-org-id"] = active.active_org_id
return { headers, api: consoleApi, baseUrl: active.url }
}
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -68,15 +112,20 @@ export namespace ShareNext {
export async function create(sessionID: string) {
if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID })
const result = await fetch(`${await url()}/api/share`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: sessionID }),
})
.then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string })
if (!response.ok) {
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`)
}
const result = (await response.json()) as { id: string; url: string; secret: string }
Database.use((db) =>
db
.insert(SessionShareTable)
@@ -159,16 +208,19 @@ export namespace ShareNext {
const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({
secret: share.secret,
data: Array.from(queued.data.values()),
}),
})
if (!response.ok) {
log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
}
}, 1000)
queue.set(sessionID, { timeout, data: dataMap })
}
@@ -178,15 +230,21 @@ export namespace ShareNext {
log.info("removing share", { sessionID })
const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({
secret: share.secret,
}),
})
if (!response.ok) {
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`)
}
Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
}

View File

@@ -1,6 +1,6 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { SessionTable } from "../session/session.sql"
import { Timestamps } from "@/storage/schema.sql"
import { Timestamps } from "../storage/schema.sql"
export const SessionShareTable = sqliteTable("session_share", {
session_id: text()

View File

@@ -39,7 +39,7 @@ export namespace Database {
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase<Schema>
type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
@@ -93,7 +93,7 @@ export namespace Database {
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite, schema })
const db = drizzle({ client: sqlite })
// Apply schema migrations
const entries =
@@ -124,7 +124,7 @@ export namespace Database {
Client.reset()
}
export type TxOrDb = Transaction | Client
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
const ctx = Context.create<{
tx: TxOrDb

View File

@@ -1,5 +1,5 @@
export { ControlAccountTable } from "../control/control.sql"
export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
export { ProjectTable } from "../project/project.sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
export { SessionShareTable } from "../share/share.sql"
export { ProjectTable } from "../project/project.sql"
export { WorkspaceTable } from "../control-plane/workspace.sql"

View File

@@ -0,0 +1,11 @@
import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
client.pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
)

View File

@@ -0,0 +1,17 @@
import { Schema } from "effect"
/**
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
*
* @example
* export const Foo = fooSchema.pipe(
* withStatics((schema) => ({
* zero: schema.makeUnsafe(0),
* from: Schema.decodeUnknownOption(schema),
* }))
* )
*/
export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))

View File

@@ -413,7 +413,7 @@ export namespace Worktree {
await runStartScripts(info.directory, { projectID, extra })
}
void start().catch((error) => {
return start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
}

View File

@@ -0,0 +1,338 @@
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccountID, OrgID } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
}),
)
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.effect(
"list returns empty when no accounts exist",
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
it.effect(
"active returns none when no accounts exist",
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.effect(
"persistAccount inserts and getRow retrieves",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_123",
refreshToken: "rt_456",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe("user-1")
expect(value.email).toBe("test@example.com")
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
}),
)
it.effect(
"persistAccount sets the active account and org",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
// Last persisted account is active with its org
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.effect(
"list returns all accounts",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "a@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "b@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}),
)
it.effect(
"remove deletes an account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) => r.remove(id))
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isNone(row)).toBe(true)
}),
)
it.effect(
"use stores the selected org and marks the account active",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active1).id).toBe(id1)
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
}),
)
it.effect(
"persistToken updates token fields",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "old_token",
refreshToken: "old_refresh",
expiry: 1000,
orgID: Option.none(),
}),
)
const expiry = Date.now() + 7200_000
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.some(expiry),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("new_token")
expect(value.refresh_token).toBe("new_refresh")
expect(value.token_expiry).toBe(expiry)
}),
)
it.effect(
"persistToken with no expiry sets token_expiry to null",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "old_token",
refreshToken: "old_refresh",
expiry: 1000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.none(),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).token_expiry).toBeNull()
}),
)
it.effect(
"persistAccount upserts on conflict",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_v1",
refreshToken: "rt_v1",
expiry: 1000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_v2",
refreshToken: "rt_v2",
expiry: 2000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_v2")
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.effect(
"remove clears active state when deleting the active account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) => r.remove(id))
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.effect(
"getRow returns none for nonexistent account",
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
}),
)

View File

@@ -0,0 +1,223 @@
import { expect } from "bun:test"
import { Effect, Layer, Option, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountService } from "../../src/account/service"
import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
}),
)
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) =>
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
req,
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
)
const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
it.effect(
"orgsByAccount groups orgs per account",
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
r.persistAccount({
id: AccountID.make("user-1"),
email: "one@example.com",
url: "https://one.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: AccountID.make("user-2"),
email: "two@example.com",
url: "https://two.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
const seen = yield* Ref.make<string[]>([])
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")])
}
if (req.url === "https://two.example.com/api/orgs") {
return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
}
return json(req, [], 404)
}),
)
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
])
expect(yield* Ref.get(seen)).toEqual([
"GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs",
])
}),
)
it.effect(
"token refresh persists the new token",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: "at_old",
refreshToken: "rt_old",
expiry: Date.now() - 1_000,
orgID: Option.none(),
}),
)
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/oauth/token"
? json(req, {
access_token: "at_new",
refresh_token: "rt_new",
expires_in: 60,
})
: json(req, {}, 404),
),
)
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_new")
expect(value.refresh_token).toBe("rt_new")
expect(value.token_expiry).toBeGreaterThan(Date.now())
}),
)
it.effect(
"config sends the selected org header",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.set(seen, {
auth: req.headers.authorization,
org: req.headers["x-org-id"],
})
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
}
return json(req, {}, 404)
}),
)
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(yield* Ref.get(seen)).toEqual({
auth: "Bearer at_1",
org: "org-9",
})
}),
)
it.effect(
"poll stores the account and first org on success",
Effect.gen(function* () {
const login = new Login({
code: "device-code",
user: "user-code",
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: 600,
interval: 5,
})
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
? json(req, {
access_token: "at_1",
refresh_token: "rt_1",
expires_in: 60,
})
: req.url === "https://one.example.com/api/user"
? json(req, { id: "user-1", email: "user@example.com" })
: req.url === "https://one.example.com/api/orgs"
? json(req, [org("org-1", "One")])
: json(req, {}, 404),
),
)
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
expect(res.email).toBe("user@example.com")
}
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: "user-1",
email: "user@example.com",
active_org_id: "org-1",
}),
)
}),
)

View File

@@ -1,5 +1,10 @@
import { test, expect } from "bun:test"
import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
import {
parseShareUrl,
shouldAttachShareAuthHeaders,
transformShareData,
type ShareData,
} from "../../src/cli/cmd/import"
// parseShareUrl tests
test("parses valid share URLs", () => {
@@ -15,6 +20,17 @@ test("rejects invalid URLs", () => {
expect(parseShareUrl("not-a-url")).toBeNull()
})
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://control.example.com:443/share/abc", "https://control.example.com")).toBe(
true,
)
expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false)
})
// transformShareData tests
test("transforms share data to storage format", () => {
const data: ShareData[] = [

View File

@@ -1,5 +1,5 @@
import { test, expect, describe } from "bun:test"
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
import { resolvePluginProviders } from "../../src/cli/cmd/providers"
import type { Hooks } from "@opencode-ai/plugin"
function hookWithAuth(provider: string): Hooks {

View File

@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach } from "bun:test"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
@@ -242,6 +243,52 @@ test("preserves env variables when adding $schema to config", async () => {
}
})
test("resolves env templates in account config with account token", async () => {
const originalActive = Account.active
const originalConfig = Account.config
const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.config = mock(async () => ({
provider: {
opencode: {
options: {
apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
},
},
},
}))
Account.token = mock(async () => AccessToken.make("st_test_token"))
try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
},
})
} finally {
Account.active = originalActive
Account.config = originalConfig
Account.token = originalToken
if (originalControlToken !== undefined) {
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
} else {
delete process.env["OPENCODE_CONSOLE_TOKEN"]
}
}
})
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -0,0 +1,7 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})

View File

@@ -260,6 +260,7 @@ test("env variable takes precedence, config merges options", async () => {
anthropic: {
options: {
timeout: 60000,
chunkTimeout: 15000,
},
},
},
@@ -277,6 +278,7 @@ test("env variable takes precedence, config merges options", async () => {
expect(providers["anthropic"]).toBeDefined()
// Config options should be merged
expect(providers["anthropic"].options.timeout).toBe(60000)
expect(providers["anthropic"].options.chunkTimeout).toBe(15000)
},
})
})

View File

@@ -2479,4 +2479,144 @@ describe("ProviderTransform.variants", () => {
expect(result).toEqual({})
})
})
describe("@jerome-benoit/sap-ai-provider-v2", () => {
test("anthropic models return thinking variants", () => {
const model = createMockModel({
id: "sap-ai-core/anthropic--claude-sonnet-4",
providerID: "sap-ai-core",
api: {
id: "anthropic--claude-sonnet-4",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinking: {
type: "enabled",
budgetTokens: 16000,
},
})
expect(result.max).toEqual({
thinking: {
type: "enabled",
budgetTokens: 31999,
},
})
})
test("anthropic 4.6 models return adaptive thinking variants", () => {
const model = createMockModel({
id: "sap-ai-core/anthropic--claude-sonnet-4-6",
providerID: "sap-ai-core",
api: {
id: "anthropic--claude-sonnet-4-6",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
expect(result.low).toEqual({
thinking: {
type: "adaptive",
},
effort: "low",
})
expect(result.max).toEqual({
thinking: {
type: "adaptive",
},
effort: "max",
})
})
test("gemini 2.5 models return thinkingConfig variants", () => {
const model = createMockModel({
id: "sap-ai-core/gcp--gemini-2.5-pro",
providerID: "sap-ai-core",
api: {
id: "gcp--gemini-2.5-pro",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 16000,
},
})
expect(result.max).toEqual({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 24576,
},
})
})
test("gpt models return reasoningEffort variants", () => {
const model = createMockModel({
id: "sap-ai-core/azure-openai--gpt-4o",
providerID: "sap-ai-core",
api: {
id: "azure-openai--gpt-4o",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
test("o-series models return reasoningEffort variants", () => {
const model = createMockModel({
id: "sap-ai-core/azure-openai--o3-mini",
providerID: "sap-ai-core",
api: {
id: "azure-openai--o3-mini",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
test("sonar models return empty object", () => {
const model = createMockModel({
id: "sap-ai-core/perplexity--sonar-pro",
providerID: "sap-ai-core",
api: {
id: "perplexity--sonar-pro",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("mistral models return empty object", () => {
const model = createMockModel({
id: "sap-ai-core/mistral--mistral-large",
providerID: "sap-ai-core",
api: {
id: "mistral--mistral-large",
url: "https://api.ai.sap",
npm: "@jerome-benoit/sap-ai-provider-v2",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
})
})

View File

@@ -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",

View File

@@ -0,0 +1,76 @@
import { test, expect, mock } from "bun:test"
import { ShareNext } from "../../src/share/share-next"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { Config } from "../../src/config/config"
test("ShareNext.request uses legacy share API without active org account", async () => {
const originalActive = Account.active
const originalConfigGet = Config.get
Account.active = mock(() => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try {
const req = await ShareNext.request()
expect(req.api.create).toBe("/api/share")
expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync")
expect(req.api.remove("shr_123")).toBe("/api/share/shr_123")
expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data")
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
} finally {
Account.active = originalActive
Config.get = originalConfigGet
}
})
test("ShareNext.request uses org share API with auth headers when account is active", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.token = mock(async () => AccessToken.make("st_test_token"))
try {
const req = await ShareNext.request()
expect(req.api.create).toBe("/api/shares")
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123")
expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data")
expect(req.baseUrl).toBe("https://control.example.com")
expect(req.headers).toEqual({
authorization: "Bearer st_test_token",
"x-org-id": "org-1",
})
} finally {
Account.active = originalActive
Account.token = originalToken
}
})
test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.token = mock(async () => undefined)
try {
await expect(ShareNext.request()).rejects.toThrow("No active account token available for sharing")
} finally {
Account.active = originalActive
Account.token = originalToken
}
})

View File

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

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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"]

View File

@@ -1295,6 +1295,7 @@ export class Session2 extends HeyApiClient {
parentID?: string
title?: string
permission?: PermissionRuleset
workspaceID?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -1308,6 +1309,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
{ in: "body", key: "permission" },
{ in: "body", key: "workspaceID" },
],
},
],

View File

@@ -1225,7 +1225,11 @@ export type ProviderConfig = {
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
*/
timeout?: number | false
[key: string]: unknown | string | boolean | number | false | undefined
/**
* Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.
*/
chunkTimeout?: number
[key: string]: unknown | string | boolean | number | false | number | undefined
}
}
@@ -2764,6 +2768,7 @@ export type SessionCreateData = {
parentID?: string
title?: string
permission?: PermissionRuleset
workspaceID?: string
}
path?: never
query?: {

View File

@@ -1832,6 +1832,10 @@
},
"permission": {
"$ref": "#/components/schemas/PermissionRuleset"
},
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
}
}
}
@@ -10108,6 +10112,12 @@
"const": false
}
]
},
"chunkTimeout": {
"description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": {}

View File

@@ -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;
padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
&[data-variant="error"] {
background-color: var(--surface-critical-weak);
border: 1px solid var(--border-critical-base);
color: rgba(218, 51, 25, 0.6);
/* 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);
/* 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);
--card-gap: 8px;
--card-icon: 16px;
--card-indent: 0px;
--card-line-pad: 8px;
&[data-component="icon"] {
color: var(--icon-critical-active);
}
--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);
}
}

View File

@@ -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>
)
},

View File

@@ -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>
)
}

View File

@@ -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);

View File

@@ -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"],

View File

@@ -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}>

View File

@@ -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);
}
}

View File

@@ -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>
)
},
}

View File

@@ -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>
)
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
}
}
}

View File

@@ -244,7 +244,7 @@ You can configure the providers and models you want to use in your OpenCode conf
The `small_model` option configures a separate model for lightweight tasks like title generation. By default, OpenCode tries to use a cheaper model if one is available from your provider, otherwise it falls back to your main model.
Provider options can include `timeout` and `setCacheKey`:
Provider options can include `timeout`, `chunkTimeout`, and `setCacheKey`:
```json title="opencode.json"
{
@@ -253,6 +253,7 @@ Provider options can include `timeout` and `setCacheKey`:
"anthropic": {
"options": {
"timeout": 600000,
"chunkTimeout": 30000,
"setCacheKey": true
}
}
@@ -261,6 +262,7 @@ Provider options can include `timeout` and `setCacheKey`:
```
- `timeout` - Request timeout in milliseconds (default: 300000). Set to `false` to disable.
- `chunkTimeout` - Timeout in milliseconds between streamed response chunks. If no chunk arrives in time, the request is aborted.
- `setCacheKey` - Ensure a cache key is always set for designated provider.
You can also configure [local models](/docs/models#local). [Learn more](/docs/models).

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