Compare commits

...

33 Commits

Author SHA1 Message Date
Aiden Cline 17550f3871 fix(mcp): narrow call tool results 2026-06-13 19:27:38 -05:00
Aiden Cline abd6e4e045 fix(mcp): handle tool result errors 2026-06-13 19:23:55 -05:00
Aiden Cline f55a931f59 feat(mcp): support client roots (#32230) 2026-06-13 19:13:15 -05:00
opencode 42f339c681 sync release versions for v1.17.6 2026-06-13 22:39:08 +00:00
James Long 409ba229ab fix(core): ignore unavailable project copy roots (#32234) 2026-06-13 18:10:59 -04:00
Aiden Cline 252223a4a7 fix(tui): handle move directory errors (#32226) 2026-06-13 15:57:14 -05:00
Aiden Cline 11fd6f8255 chore(mcp): declare client capabilities (#32222) 2026-06-13 15:29:38 -05:00
opencode 7143bf8ff0 sync release versions for v1.17.5 2026-06-13 19:59:03 +00:00
opencode-agent[bot] 414c037b21 chore: generate 2026-06-13 19:14:37 +00:00
OpeOginni 632f94fa68 fix(opencode): add authorization header to fetch requests in RunCommand (#29877) 2026-06-13 14:13:15 -05:00
opencode-agent[bot] a6e3afe048 chore: generate 2026-06-13 18:11:18 +00:00
santigc6 3f174531b9 feat(opencode): add external browser OAuth for snowflake cortex provider (#31700)
Co-authored-by: santiago.gonzalezcarvajcentenera <santiago.gonzalezcarvajcentenera@colaborador.elcorteingles.es>
Co-authored-by: David Fierro <14184197+davidfierro@users.noreply.github.com>
Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com>
Co-authored-by: Cortex Code <noreply@snowflake.com>
2026-06-13 13:09:45 -05:00
opencode-agent[bot] fcca731a96 chore: update nix node_modules hashes 2026-06-13 16:45:25 +00:00
Aiden Cline c7dee9c609 fix(opencode): recover expired MCP sessions (#32088) 2026-06-13 11:20:59 -05:00
Luke Parker 45e4606fa4 feat(app): bring v2 visibility settings to web (#32174) 2026-06-13 13:13:12 +02:00
Luke Parker 9ae4a5139f fix(app): expand terminal resize gutter hitbox (#32169) 2026-06-13 09:23:01 +00:00
opencode-agent[bot] d73348719e chore: generate 2026-06-13 07:34:53 +00:00
Luke Parker 2630f457b9 fix(app): fade overflowing titlebar tabs (#32082) 2026-06-13 17:33:31 +10:00
opencode-agent[bot] dbbe67f066 chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-12 22:52:55 +00:00
Sebastian fff0ec294c fix(tui): prevent duplicate renderable IDs (#32110) 2026-06-13 00:51:32 +02:00
Aiden Cline cf2d1dd3e9 fix(tui): restore subtask invocation spacing (#32109) 2026-06-12 17:18:05 -05:00
Stefan Avram 27ca0f8825 docs: update North Mini Code privacy terms (#32096) 2026-06-12 16:44:25 -04:00
opencode-agent[bot] 73dbd8a312 chore: generate 2026-06-12 20:00:30 +00:00
Frank 443f103ee3 go: kimi 2.7 code
deploy / deploy (push) Has been cancelled
2026-06-12 15:57:13 -04:00
opencode-agent[bot] 6c36b585c3 chore: generate 2026-06-12 18:46:51 +00:00
James Long c2e6b18076 feat(core): refactor project copies for v2 (#31943) 2026-06-12 14:45:16 -04:00
opencode-agent[bot] 8d97c8d412 chore: generate 2026-06-12 18:35:01 +00:00
Adam f8b357b267 fix(data): mobile breakpoint for nav 2026-06-12 13:33:00 -05:00
Adam 621796d8ce feat(stats): add world map markers 2026-06-12 13:32:59 -05:00
Adam ba2455ecc7 feat(stats): use catalog pricing in efficiency and token costs 2026-06-12 13:32:59 -05:00
Adam 44308dfd7e fix(stats): tighten truncated label line height 2026-06-12 13:32:59 -05:00
opencode-agent[bot] b000256f57 chore: generate 2026-06-12 18:16:37 +00:00
James Long 30b2544fe1 refactor(opencode): build server from layer nodes (#32086) 2026-06-12 14:14:51 -04:00
178 changed files with 4749 additions and 2351 deletions
+27 -26
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.4",
"version": "1.17.6",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -110,7 +110,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -146,7 +146,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -173,7 +173,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -195,7 +195,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -219,7 +219,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -239,7 +239,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.4",
"version": "1.17.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -330,7 +330,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -384,7 +384,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -398,7 +398,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"effect": "catalog:",
},
@@ -410,7 +410,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -441,7 +441,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -457,7 +457,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -476,7 +476,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -494,7 +494,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.4",
"version": "1.17.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -622,7 +622,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -660,7 +660,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -675,7 +675,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -689,7 +689,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -702,7 +702,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -735,7 +735,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -754,7 +754,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -794,7 +794,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -821,7 +821,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -870,7 +870,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -914,6 +914,7 @@
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
"x86_64-linux": "sha256-Y+8gz+NfFSb4dysjho4CCaQpx012vvajnptfd3cdh/k=",
"aarch64-linux": "sha256-dfkEJ57XvxCM4huM5Ij7tSy+JUPKYmBMZDGZKZ5xSXA=",
"aarch64-darwin": "sha256-CJ8dvzGzJp5sgaNJYHwxRc1xK9bWA8zyTmDFi4wTI3o=",
"x86_64-darwin": "sha256-7/kwFObujL4z4544Q0D2LN/SxT8sHnJyVrzmBXbJajA="
}
}
+2 -1
View File
@@ -149,6 +149,7 @@
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch"
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.4",
"version": "1.17.6",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1372,7 +1372,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
@@ -155,11 +155,9 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -322,7 +320,7 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<Show
when={isDesktopV2}
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
@@ -444,23 +442,21 @@ export function SessionHeader() {
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<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 shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -479,32 +475,30 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
@@ -1,16 +1,14 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
@@ -94,7 +92,6 @@ export const SettingsGeneralV2: Component = () => {
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
@@ -136,12 +133,6 @@ export const SettingsGeneralV2: Component = () => {
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
@@ -186,14 +177,6 @@ export const SettingsGeneralV2: Component = () => {
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
@@ -383,18 +366,6 @@ export const SettingsGeneralV2: Component = () => {
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
@@ -407,18 +378,6 @@ export const SettingsGeneralV2: Component = () => {
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -709,6 +668,7 @@ export const SettingsGeneralV2: Component = () => {
</div>
)
// We can probably remove this, right?
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-v2-section">
@@ -723,26 +683,6 @@ export const SettingsGeneralV2: Component = () => {
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRowV2>
<Show when={linux()}>
<SettingsRowV2
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
</Show>
@@ -763,13 +703,13 @@ export const SettingsGeneralV2: Component = () => {
<SoundsSection />
<UpdatesSection />
<Show when={desktop()}>
<UpdatesSection />
</Show>
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
<AdvancedSection />
</div>
</>
)
+46
View File
@@ -0,0 +1,46 @@
@keyframes titlebar-tab-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes titlebar-tab-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
/* The narrow ranges preserve the binary thresholds of scrollLeft > 0 and
remaining scroll distance > 1px. Hidden defaults make an inactive timeline
(no overflow) a no-op. Unsupported browsers also degrade to no fades. */
[data-slot="titlebar-tabs"] {
timeline-scope: --titlebar-tabs-scroll;
[data-slot^="titlebar-tabs-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --titlebar-tabs-scroll) and (timeline-scope: --titlebar-tabs-scroll) {
[data-slot="titlebar-tabs-scroll"] {
scroll-timeline: --titlebar-tabs-scroll x;
}
[data-slot="titlebar-tabs-fade-left"] {
animation: titlebar-tab-fade-left linear both;
animation-timeline: --titlebar-tabs-scroll;
animation-range: 0 0.1px;
}
[data-slot="titlebar-tabs-fade-right"] {
animation: titlebar-tab-fade-right linear both;
animation-timeline: --titlebar-tabs-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
+84 -67
View File
@@ -34,11 +34,13 @@ import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import "./titlebar.css"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -435,92 +437,107 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
state={!!homeMatch() ? "pressed" : undefined}
/>
<div
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={tabScrollRef}
>
<div class="flex min-w-0 flex-row items-center gap-1.5">
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={(el) => {
tabScrollRef = el
createResizeObserver(el, refreshTabsAreOverflowing)
}}
>
<div
class="flex min-w-0 flex-row items-center gap-1.5"
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
>
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
onMount(() => {
refreshTabsAreOverflowing()
})
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
<TabNavItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
return (
<>
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
+20 -5
View File
@@ -153,6 +153,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
)
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
createEffect(() => {
if (typeof document === "undefined") return
@@ -187,7 +196,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
showFileTree,
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
@@ -195,11 +204,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
showSearch,
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
showStatus,
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
@@ -235,15 +244,21 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
showCustomAgents,
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
visibility: {
fileTree: visible(showFileTree),
search: visible(showSearch),
status: visible(showStatus),
customAgents: visible(showCustomAgents),
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
+4 -4
View File
@@ -825,17 +825,17 @@ export const dict = {
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showSearch.description": "Show the search and command palette button in the title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
+3 -4
View File
@@ -759,16 +759,15 @@ export const dict = {
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
"settings.general.row.showSearch.description":
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
"settings.general.row.showSearch.description": "Показувати кнопку пошуку та палітри команд у заголовку",
"settings.general.row.showTerminal.title": "Термінал",
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
+1 -2
View File
@@ -275,8 +275,7 @@ export default function Page() {
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
showFileTree: settings.general.showFileTree(),
visible: settings.visibility.fileTree(),
opened: layout.fileTree.opened(),
}),
)
@@ -12,10 +12,9 @@ import {
} from "./helpers"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled v2 file tree", () => {
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
test("does not reserve space for a disabled file tree", () => {
expect(shouldShowFileTree({ visible: false, opened: true })).toBe(false)
expect(shouldShowFileTree({ visible: true, opened: true })).toBe(true)
})
})
+2 -2
View File
@@ -20,8 +20,8 @@ type TabsInput = {
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) {
return input.opened && (!input.desktopV2 || input.showFileTree)
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
return input.opened && input.visible
}
export const createSessionTabs = (input: TabsInput) => {
@@ -19,7 +19,6 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
@@ -54,7 +53,6 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const platform = usePlatform()
const settings = useSettings()
const sync = useSync()
const file = useFile()
@@ -64,16 +62,14 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => !desktopV2() || settings.general.showFileTree())
const shown = settings.visibility.fileTree
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: desktopV2(),
showFileTree: settings.general.showFileTree(),
visible: shown(),
opened: layout.fileTree.opened(),
}),
)
@@ -14,6 +14,7 @@ import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
@@ -26,6 +27,7 @@ export function TerminalPanel() {
const terminal = useTerminal()
const language = useLanguage()
const command = useCommand()
const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened())
@@ -198,35 +200,38 @@ export function TerminalPanel() {
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
class="relative w-full shrink-0 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 class="hidden md:block" onPointerDown={() => size.start()}>
<ResizeHandle
classList={{
"-top-1": settings.general.newLayoutDesigns(),
}}
direction="vertical"
size={pane()}
min={100}
max={max()}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<div
class="absolute inset-x-0 top-0 flex flex-col"
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": opened(),
"pointer-events-none": !opened(),
}}
style={{ height: `${pane()}px` }}
>
<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={
@@ -7,7 +7,6 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
@@ -41,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
@@ -70,8 +68,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const shown = settings.visibility.fileTree
const messages = () => {
const id = params.id
@@ -547,7 +544,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: desktopV2() && !settings.general.showCustomAgents(),
disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -555,7 +552,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: desktopV2() && !settings.general.showCustomAgents(),
disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(-1),
}),
]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.4",
"version": "1.17.6",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.4",
"version": "1.17.6",
"type": "module",
"license": "MIT",
"scripts": {
+4 -4
View File
@@ -250,7 +250,7 @@ export const dict = {
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
"go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
"go.hero.body":
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item2": "حدود سخية ووصول موثوق",
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
"go.problem.item4":
"يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
"يتضمن GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
"go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا",
@@ -323,7 +323,7 @@ export const dict = {
"go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.",
"go.faq.q3": "هل Go هو نفسه Zen؟",
"go.faq.a3":
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.faq.q4": "كم تكلفة Go؟",
"go.faq.a4.p1.beforePricing": "تكلفة Go",
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
@@ -346,7 +346,7 @@ export const dict = {
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9":
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
+4 -4
View File
@@ -254,7 +254,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
"go.hero.body":
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
@@ -304,7 +304,7 @@ export const dict = {
"go.problem.item2": "Limites generosos e acesso confiável",
"go.problem.item3": "Feito para o maior número possível de programadores",
"go.problem.item4":
"Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"Inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"go.how.title": "Como o Go funciona",
"go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
@@ -330,7 +330,7 @@ export const dict = {
"go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.",
"go.faq.q3": "O Go é o mesmo que o Zen?",
"go.faq.a3":
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.faq.q4": "Quanto custa o Go?",
"go.faq.a4.p1.beforePricing": "O Go custa",
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
@@ -354,7 +354,7 @@ export const dict = {
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9":
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
+4 -4
View File
@@ -252,7 +252,7 @@ export const dict = {
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
"go.hero.body":
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item2": "Generøse grænser og pålidelig adgang",
"go.problem.item3": "Bygget til så mange programmører som muligt",
"go.problem.item4":
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"go.how.title": "Hvordan Go virker",
"go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
@@ -327,7 +327,7 @@ export const dict = {
"go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.faq.q4": "Hvad koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -350,7 +350,7 @@ export const dict = {
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
+4 -4
View File
@@ -254,7 +254,7 @@ export const dict = {
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
"go.hero.body":
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
@@ -303,7 +303,7 @@ export const dict = {
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
"go.problem.item4":
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
"go.how.title": "Wie Go funktioniert",
"go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
@@ -329,7 +329,7 @@ export const dict = {
"go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.",
"go.faq.q3": "Ist Go dasselbe wie Zen?",
"go.faq.a3":
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.faq.q4": "Wie viel kostet Go?",
"go.faq.a4.p1.beforePricing": "Go kostet",
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
@@ -353,7 +353,7 @@ export const dict = {
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9":
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
+4 -4
View File
@@ -250,7 +250,7 @@ export const dict = {
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.hero.title": "Low cost coding models for everyone",
"go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
@@ -298,7 +298,7 @@ export const dict = {
"go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4":
"Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
"Includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
"go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
@@ -323,7 +323,7 @@ export const dict = {
"go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3":
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$5 first month",
@@ -347,7 +347,7 @@ export const dict = {
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
+4 -4
View File
@@ -255,7 +255,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
"go.hero.body":
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
@@ -305,7 +305,7 @@ export const dict = {
"go.problem.item2": "Límites generosos y acceso fiable",
"go.problem.item3": "Creado para tantos programadores como sea posible",
"go.problem.item4":
"Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
"Incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
"go.how.title": "Cómo funciona Go",
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
"go.how.step1.title": "Crear una cuenta",
@@ -330,7 +330,7 @@ export const dict = {
"go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.",
"go.faq.q3": "¿Es Go lo mismo que Zen?",
"go.faq.a3":
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.faq.q4": "¿Cuánto cuesta Go?",
"go.faq.a4.p1.beforePricing": "Go cuesta",
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
@@ -354,7 +354,7 @@ export const dict = {
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
"go.faq.a9":
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
+4 -4
View File
@@ -256,7 +256,7 @@ export const dict = {
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.banner.text": "MiniMax M3 bénéficie de limites dutilisation 3x supérieures pour une durée limitée",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"go.hero.title": "Modèles de code à faible coût pour tous",
"go.hero.body":
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
@@ -305,7 +305,7 @@ export const dict = {
"go.problem.item2": "Limites généreuses et accès fiable",
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
"go.problem.item4":
"Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash",
"Inclut GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash",
"go.how.title": "Comment fonctionne Go",
"go.how.body":
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
@@ -331,7 +331,7 @@ export const dict = {
"go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.",
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
"go.faq.a3":
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"go.faq.q4": "Combien coûte Go ?",
"go.faq.a4.p1.beforePricing": "Go coûte",
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
@@ -354,7 +354,7 @@ export const dict = {
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
"go.faq.a9":
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
+4 -4
View File
@@ -252,7 +252,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.banner.text": "MiniMax M3 offre limiti di utilizzo 3x superiori per un periodo limitato",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
"go.hero.body":
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item2": "Limiti generosi e accesso affidabile",
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
"go.problem.item4":
"Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"Include GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"go.how.title": "Come funziona Go",
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
"go.how.step1.title": "Crea un account",
@@ -326,7 +326,7 @@ export const dict = {
"go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.",
"go.faq.q3": "Go è lo stesso di Zen?",
"go.faq.a3":
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.faq.q4": "Quanto costa Go?",
"go.faq.a4.p1.beforePricing": "Go costa",
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
@@ -350,7 +350,7 @@ export const dict = {
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
"go.faq.a9":
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
+4 -4
View File
@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.banner.text": "MiniMax M3の利用上限が期間限定で3倍に",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
"Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
"go.hero.body":
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item2": "十分な制限と安定したアクセス",
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
"go.problem.item4":
"GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む",
"GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む",
"go.how.title": "Goの仕組み",
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
"go.how.step1.title": "アカウントを作成",
@@ -326,7 +326,7 @@ export const dict = {
"go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。",
"go.faq.q3": "GoはZenと同じですか?",
"go.faq.a3":
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
"go.faq.q4": "Goの料金は?",
"go.faq.a4.p1.beforePricing": "Goは",
"go.faq.a4.p1.pricingLink": "最初の月$5",
@@ -350,7 +350,7 @@ export const dict = {
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
"go.faq.a9":
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
+4 -4
View File
@@ -248,7 +248,7 @@ export const dict = {
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.banner.text": "MiniMax M3 사용 한도가 한시적으로 3배 확대됩니다",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
"go.hero.body":
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
@@ -298,7 +298,7 @@ export const dict = {
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
"go.problem.item4":
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함",
"GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함",
"go.how.title": "Go 작동 방식",
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
"go.how.step1.title": "계정 생성",
@@ -322,7 +322,7 @@ export const dict = {
"go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.",
"go.faq.q3": "Go는 Zen과 같은가요?",
"go.faq.a3":
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
"go.faq.q4": "Go 비용은 얼마인가요?",
"go.faq.a4.p1.beforePricing": "Go 비용은",
"go.faq.a4.p1.pricingLink": "첫 달 $5",
@@ -345,7 +345,7 @@ export const dict = {
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
"go.faq.a9":
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
+4 -4
View File
@@ -252,7 +252,7 @@ export const dict = {
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.banner.text": "MiniMax M3 får 3x bruksgrense i en begrenset periode",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Rimelige kodemodeller for alle",
"go.hero.body":
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item2": "Rause grenser og pålitelig tilgang",
"go.problem.item3": "Bygget for så mange programmerere som mulig",
"go.problem.item4":
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"go.how.title": "Hvordan Go fungerer",
"go.how.body":
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
@@ -327,7 +327,7 @@ export const dict = {
"go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.faq.q4": "Hva koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -351,7 +351,7 @@ export const dict = {
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
+4 -4
View File
@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.banner.text": "MiniMax M3 oferuje 3x wyższe limity użycia przez ograniczony czas",
"go.meta.description":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
"go.hero.body":
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
@@ -302,7 +302,7 @@ export const dict = {
"go.problem.item2": "Hojne limity i niezawodny dostęp",
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
"go.problem.item4":
"Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash",
"Zawiera GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash",
"go.how.title": "Jak działa Go",
"go.how.body":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
@@ -328,7 +328,7 @@ export const dict = {
"go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.",
"go.faq.q3": "Czy Go to to samo co Zen?",
"go.faq.a3":
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"go.faq.q4": "Ile kosztuje Go?",
"go.faq.a4.p1.beforePricing": "Go kosztuje",
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
@@ -352,7 +352,7 @@ export const dict = {
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
"go.faq.a9":
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
+4 -4
View File
@@ -256,7 +256,7 @@ export const dict = {
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.banner.text": "MiniMax M3 получает 3x лимиты использования на ограниченное время",
"go.meta.description":
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"go.hero.title": "Недорогие модели для кодинга для всех",
"go.hero.body":
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
@@ -306,7 +306,7 @@ export const dict = {
"go.problem.item2": "Щедрые лимиты и надежный доступ",
"go.problem.item3": "Создан для максимального числа программистов",
"go.problem.item4":
"Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash",
"Включает GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash",
"go.how.title": "Как работает Go",
"go.how.body":
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
@@ -332,7 +332,7 @@ export const dict = {
"go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.",
"go.faq.q3": "Go — это то же самое, что и Zen?",
"go.faq.a3":
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"go.faq.q4": "Сколько стоит Go?",
"go.faq.a4.p1.beforePricing": "Go стоит",
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
@@ -356,7 +356,7 @@ export const dict = {
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
"go.faq.a9":
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
+4 -4
View File
@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.banner.text": "MiniMax M3 เพิ่มโควตาการใช้งานเป็น 3 เท่าในช่วงเวลาจำกัด",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.hero.body":
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
"go.problem.item4":
"รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"รวมถึง GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"go.how.title": "Go ทำงานอย่างไร",
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
"go.how.step1.title": "สร้างบัญชี",
@@ -324,7 +324,7 @@ export const dict = {
"go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้",
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
"go.faq.a3":
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้",
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้",
"go.faq.q4": "Go ราคาเท่าไหร่?",
"go.faq.a4.p1.beforePricing": "Go ราคา",
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
@@ -347,7 +347,7 @@ export const dict = {
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
"go.faq.a9":
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
+4 -4
View File
@@ -254,7 +254,7 @@ export const dict = {
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.banner.text": "MiniMax M3 sınırlı bir süre için 3x kullanım limiti sunuyor",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
"go.hero.body":
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
@@ -304,7 +304,7 @@ export const dict = {
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
"go.problem.item4":
"GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir",
"GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir",
"go.how.title": "Go nasıl çalışır?",
"go.how.body":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
@@ -330,7 +330,7 @@ export const dict = {
"go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.",
"go.faq.q3": "Go, Zen ile aynı mı?",
"go.faq.a3":
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
"go.faq.q4": "Go ne kadar?",
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
"go.faq.a4.p1.pricingLink": "İlk ay $5",
@@ -354,7 +354,7 @@ export const dict = {
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
"go.faq.a9":
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
+4 -4
View File
@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.banner.text": "MiniMax M3 отримує 3x ліміти використання протягом обмеженого часу",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.hero.title": "Недорогі моделі кодування для всіх",
"go.hero.body":
"Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
@@ -302,7 +302,7 @@ export const dict = {
"go.problem.item2": "Щедрі ліміти та надійний доступ",
"go.problem.item3": "Створено для якомога більшої кількості програмістів",
"go.problem.item4":
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash",
"Включає GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash",
"go.how.title": "Як працює Go",
"go.how.body":
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
@@ -328,7 +328,7 @@ export const dict = {
"go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.",
"go.faq.q3": "Чи Go те саме, що Zen?",
"go.faq.a3":
"Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.faq.q4": "Скільки коштує Go?",
"go.faq.a4.p1.beforePricing": "Go коштує",
"go.faq.a4.p1.pricingLink": "$5 за перший місяць",
@@ -351,7 +351,7 @@ export const dict = {
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
"go.faq.a9":
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
"zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.",
"zen.api.error.modelNotSupported": "Модель {{model}} не підтримується",
+4 -4
View File
@@ -242,7 +242,7 @@ export const dict = {
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.banner.text": "MiniMax M3 限时享受 3 倍使用额度",
"go.meta.description":
"Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
"Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
"go.hero.title": "人人可用的低成本编程模型",
"go.hero.body":
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
@@ -290,7 +290,7 @@ export const dict = {
"go.problem.item2": "充裕的限额和可靠的访问",
"go.problem.item3": "为尽可能多的程序员打造",
"go.problem.item4":
"包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash",
"包含 GLM-5.1, GLM-5, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash",
"go.how.title": "Go 如何工作",
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "创建账户",
@@ -312,7 +312,7 @@ export const dict = {
"go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。",
"go.faq.q3": "Go 和 Zen 一样吗?",
"go.faq.a3":
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。",
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。",
"go.faq.q4": "Go 多少钱?",
"go.faq.a4.p1.beforePricing": "Go 费用为",
"go.faq.a4.p1.pricingLink": "首月 $5",
@@ -334,7 +334,7 @@ export const dict = {
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
"go.faq.a9":
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
+4 -4
View File
@@ -242,7 +242,7 @@ export const dict = {
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.banner.text": "MiniMax M3 限時享有 3 倍使用額度",
"go.meta.description":
"Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
"Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
"go.hero.title": "低成本全民編碼模型",
"go.hero.body":
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
@@ -290,7 +290,7 @@ export const dict = {
"go.problem.item2": "寬裕的限額與穩定存取",
"go.problem.item3": "專為盡可能多的程式設計師打造",
"go.problem.item4":
"包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash",
"包含 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash",
"go.how.title": "Go 如何運作",
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "建立帳號",
@@ -312,7 +312,7 @@ export const dict = {
"go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。",
"go.faq.q3": "Go 與 Zen 一樣嗎?",
"go.faq.a3":
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。",
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。",
"go.faq.q4": "Go 費用是多少?",
"go.faq.a4.p1.beforePricing": "Go 費用為",
"go.faq.a4.p1.pricingLink": "首月 $5",
@@ -334,7 +334,7 @@ export const dict = {
"go.faq.q9": "免費模型與 Go 有什麼區別?",
"go.faq.a9":
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
+2 -3
View File
@@ -25,7 +25,7 @@ const checkLoggedIn = query(async () => {
const models = [
{ name: "GLM-5.1", provider: "DeepInfra, Fireworks AI, Z.ai" },
{ name: "GLM-5", provider: "DeepInfra, Fireworks AI, Z.ai" },
{ name: "Kimi K2.5", provider: "Moonshot AI" },
{ name: "Kimi K2.7 Code", provider: "Moonshot AI" },
{ name: "Kimi K2.6", provider: "Moonshot AI" },
{ name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" },
{ name: "MiMo-V2.5", provider: "Xiaomi MiMo" },
@@ -34,7 +34,6 @@ const models = [
{ name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" },
{ name: "MiniMax M3", provider: "MiniMax" },
{ name: "MiniMax M2.7", provider: "MiniMax" },
{ name: "MiniMax M2.5", provider: "MiniMax" },
{ name: "DeepSeek V4 Pro", provider: "DeepSeek" },
{ name: "DeepSeek V4 Flash", provider: "DeepSeek" },
]
@@ -64,7 +63,7 @@ function LimitsGraph(props: { href: string }) {
const graph = [
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "240ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "250ms" },
@@ -257,20 +257,19 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
</p>
<h3 data-slot="promo-models-title">{i18n.t("workspace.lite.promo.modelsTitle")}</h3>
<ul data-slot="promo-models">
<li>Kimi K2.5</li>
<li>Kimi K2.7 Code</li>
<li>Kimi K2.6</li>
<li>GLM-5</li>
<li>GLM-5.1</li>
<li>MiMo-V2.5-Pro</li>
<li>MiMo-V2.5</li>
<li>MiniMax M2.5</li>
<li>MiniMax M2.7</li>
<li>GLM-5</li>
<li>MiniMax M3</li>
<li>Qwen3.6 Plus</li>
<li>Qwen3.7 Plus</li>
<li>MiniMax M2.7</li>
<li>Qwen3.7 Max</li>
<li>Qwen3.7 Plus</li>
<li>Qwen3.6 Plus</li>
<li>DeepSeek V4 Pro</li>
<li>DeepSeek V4 Flash</li>
<li>MiMo-V2.5</li>
<li>MiMo-V2.5-Pro</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<div data-slot="subscribe-actions">
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.4",
"version": "1.17.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.4",
"version": "1.17.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.4",
"version": "1.17.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.4",
"version": "1.17.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+13 -3
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "abd2f920-b822-49af-b8a7-2e48367d424f",
"prevIds": ["f25f9126-c7dc-4882-9ff4-af27e11d2da1"],
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"ddl": [
{
"name": "workspace",
@@ -622,7 +622,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -630,6 +630,16 @@
"entityType": "columns",
"table": "project_directory"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "strategy",
"entityType": "columns",
"table": "project_directory"
},
{
"type": "integer",
"notNull": true,
+1
View File
@@ -36,5 +36,6 @@ export const migrations = (
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,29 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260612174303_project_dir_strategy",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project_directory\` ADD \`strategy\` text;`)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_project_directory\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`type\` text,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_project_directory\`(\`project_id\`, \`directory\`, \`type\`, \`time_created\`) SELECT \`project_id\`, \`directory\`, \`type\`, \`time_created\` FROM \`project_directory\`;`,
)
yield* tx.run(`DROP TABLE \`project_directory\`;`)
yield* tx.run(`ALTER TABLE \`__new_project_directory\` RENAME TO \`project_directory\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
} satisfies DatabaseMigration.Migration
+2 -1
View File
@@ -101,7 +101,8 @@ export default {
CREATE TABLE \`project_directory\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`type\` text NOT NULL,
\`type\` text,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
+12
View File
@@ -9,11 +9,14 @@ import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories"
import { EventV2 } from "./event"
import { Credential } from "./credential"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
@@ -63,6 +66,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
ProjectCopy.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
Pty.locationLayer,
@@ -98,6 +102,11 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
)
// Kick off a background project copy refresh to update locations now that we
// have a location
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
return Layer.mergeAll(
boot,
services,
@@ -110,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
runner,
builtInTools,
referenceGuidance,
projectCopyRefresh,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
@@ -120,10 +130,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
@@ -70,14 +70,17 @@ export const SnowflakeCortexPlugin = PluginV2.define({
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const pat =
process.env.SNOWFLAKE_CORTEX_PAT ?? (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
(typeof evt.options.token === "string" ? evt.options.token : undefined) ??
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible({
...evt.options,
...(pat ? { apiKey: pat } : {}),
...(token ? { apiKey: token } : {}),
fetch: cortexFetch(upstream) as typeof fetch,
} as any)
}),
+21 -47
View File
@@ -2,60 +2,41 @@ export * as ProjectV2 from "./project"
export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { asc, desc, eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath, withStatics } from "./schema"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
import { Database } from "./database/database"
import { Git } from "./git"
import { LayerNode } from "./effect/layer-node"
import { Hash } from "./util/hash"
import { ProjectDirectoryTable } from "./project/sql"
import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
])
export type Vcs = typeof Vcs.Type
export const Vcs = ProjectSchema.Vcs
export type Vcs = ProjectSchema.Vcs
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
export const DirectoriesInput = Schema.Struct({
projectID: ID,
}).annotate({ identifier: "Project.DirectoriesInput" })
export const DirectoriesInput = ProjectDirectories.ListInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Schema.Array(
Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["main", "root", "git_worktree"]),
}),
).annotate({ identifier: "Project.Directories" })
export const Directories = ProjectDirectories.ListOutput
export type Directories = typeof Directories.Type
export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
readonly vcs?: Vcs
}
export interface Interface {
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<
{
previous?: ID
id: ID
directory: AbsolutePath
vcs?: Vcs
},
never
>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
*
@@ -73,19 +54,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const projectDirectories = yield* ProjectDirectories.Service
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type }))
return yield* projectDirectories.list(input.projectID)
})
const cached = Effect.fnUntraced(function* (dir: string) {
@@ -156,8 +130,8 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provideMerge(ProjectDirectories.defaultLayer),
)
export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node])
+10 -19
View File
@@ -1,20 +1,18 @@
import path from "path"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy"
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy"
export function makeStrategies(input: {
export function makeGitWorktreeStrategy(input: {
git: Git.Interface
fs: FSUtil.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
const repo = (sourceDirectory: AbsolutePath) =>
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
const gitWorktree: Strategy = {
id: "git_worktree",
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
@@ -30,18 +28,11 @@ export function makeStrategies(input: {
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
const entries = yield* input.git.worktreeList(found)
return yield* Effect.forEach(entries, (entry) =>
entry === core
? Effect.succeed(undefined)
: input.canonical(entry).pipe(
Effect.map((directory) => ({ directory })),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
input.canonical(entry).pipe(
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
return yield* input.fs.isFile(path.join(inputDirectory, ".git"))
}),
}
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
} satisfies Strategy
}
+154 -133
View File
@@ -1,34 +1,29 @@
export * as ProjectCopy from "./copy"
import { and, eq, inArray } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LayerNode } from "../effect/layer-node"
import { Project } from "../project"
import { ProjectDirectoryTable } from "./sql"
import { makeStrategies } from "./copy-strategies"
import { ProjectDirectories } from "./directories"
import { makeGitWorktreeStrategy } from "./copy-strategies"
import { Slug } from "../util/slug"
import { EventV2 } from "../event"
import { Database } from "../database/database"
import { Location } from "../location"
import { PluginBoot } from "../plugin/boot"
export const StrategyID = Schema.Literal("git_worktree")
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export type StrategyID = typeof StrategyID.Type
export const DetectInput = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.DetectInput" })
export type DetectInput = typeof DetectInput.Type
export const CreateInput = Schema.Struct({
projectID: Project.ID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
name: Schema.optional(Schema.String),
context: Schema.optional(Schema.String),
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export type CreateInput = typeof CreateInput.Type
@@ -44,12 +39,22 @@ export const RefreshInput = Schema.Struct({
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export type Copy = typeof Copy.Type
export type DirectoryType = "main" | "root" | StrategyID
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "copy"]),
}).annotate({ identifier: "ProjectCopy.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
@@ -66,16 +71,27 @@ export class DirectoryUnavailableError extends Schema.TaggedErrorClass<Directory
{ directory: AbsolutePath },
) {}
export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
"ProjectCopy.StrategyNotFoundError",
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"ProjectCopy.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| StrategyNotFoundError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
@@ -88,8 +104,7 @@ export interface Strategy {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = {
@@ -100,21 +115,46 @@ export const Event = {
}
export interface Interface {
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const boot = yield* PluginBoot.Service
const copies = yield* Service
yield* Effect.gen(function* () {
yield* boot.wait()
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
const result = yield* copies.refresh({ projectID: location.project.id })
yield* Effect.logInfo("project copy refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const events = yield* EventV2.Service
const directories = yield* ProjectDirectories.Service
const db = (yield* Database.Service).db
const events = yield* EventV2.Service
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* events.publish(Event.Updated, { projectID })
})
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(FSUtil.resolve(input))
@@ -122,76 +162,34 @@ export const layer = Layer.effect(
return resolved
})
const registry = makeStrategies({ git, fs, canonical })
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)),
)
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, projectID),
eq(ProjectDirectoryTable.directory, copyDirectory),
),
)
.get()
if (row) return false
yield* tx
.insert(ProjectDirectoryTable)
.values({ project_id: projectID, directory: copyDirectory, type })
.run()
return true
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) {
return (
(yield* db
.delete(ProjectDirectoryTable)
.where(
and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)),
)
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* events.publish(Event.Updated, { projectID })
})
const strategy = (id: StrategyID) => registry.get(id) as Strategy
const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) {
for (const strategy of registry.values()) {
if (yield* strategy.detect(input.directory)) return strategy.id
}
return undefined
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
@@ -202,78 +200,101 @@ export const layer = Layer.effect(
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* strategy(input.strategy).create({
const result = yield* selected.create({
directory: copyDirectory,
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
sourceDirectory,
})
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
yield* changed(
input.projectID,
yield* directories.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
behavior: "replace",
}),
)
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const copyDirectory = yield* canonical(input.directory)
const id = yield* detect({ directory: copyDirectory })
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
directory: copyDirectory,
force: input.force,
})
yield* changed(
input.projectID,
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
)
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const roots = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
inArray(ProjectDirectoryTable.type, ["main", "root"]),
),
)
.all()
.pipe(Effect.orDie)
const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
concurrency: "unbounded",
})
const stored = yield* directories.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(registry.values(), (strategy) =>
strategy
.list(sourceDirectory)
.pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const stored = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
const inserted = yield* Effect.forEach(discovered, (item) =>
insert(input.projectID, item.directory, item.type),
).pipe(Effect.map((items) => items.some(Boolean)))
const removed = yield* Effect.forEach(stored, (item) =>
fs
.isDir(item.directory)
.pipe(
Effect.flatMap((exists) =>
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
directories.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
behavior: "replace",
},
tx,
),
),
),
).pipe(Effect.map((items) => items.some(Boolean)))
yield* changed(input.projectID, inserted || removed)
removed: Effect.forEach(removed, (directory) =>
directories.remove({ projectID: input.projectID, directory }, tx),
),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({ detect, create, remove, refresh })
return Service.of({
register,
create,
remove,
refresh,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])
export const locationLayer = layer
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
+159
View File
@@ -0,0 +1,159 @@
export * as ProjectDirectories from "./directories"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { LayerNode } from "../effect/layer-node"
import { AbsolutePath, optionalOmitUndefined } from "../schema"
import { ProjectSchema } from "./schema"
import { ProjectDirectoryTable } from "./sql"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
export interface Directory {
readonly directory: AbsolutePath
readonly strategy?: string
}
export const CreateInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
strategy: Schema.optional(Schema.String),
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
})
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
})
export type RemoveInput = typeof RemoveInput.Type
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ListInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Project.DirectoriesInput" })
export type ListInput = typeof ListInput.Type
export const ListOutput = Schema.Array(
Schema.Struct({
directory: AbsolutePath,
strategy: optionalOmitUndefined(Schema.String),
}),
).annotate({ identifier: "Project.Directories" })
export type ListOutput = typeof ListOutput.Type
export interface Interface {
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
readonly get: (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
const insert = (tx ?? db)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
const query =
input.behavior === "replace"
? insert.onConflictDoUpdate({
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
: isNotNull(ProjectDirectoryTable.strategy),
})
: insert.onConflictDoNothing()
return (
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
)
})
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const contains = Effect.fn("ProjectDirectories.contains")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
return (
(yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
})
return Service.of({
list,
get,
contains,
create,
remove,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = LayerNode.make(layer, [Database.node])
+20
View File
@@ -0,0 +1,20 @@
export * as ProjectSchema from "./schema"
import { Schema } from "effect"
import { AbsolutePath, withStatics } from "../schema"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
])
export type Vcs = typeof Vcs.Type
+6 -5
View File
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
import { ProjectSchema } from "./schema"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectV2.ID>().primaryKey(),
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: DatabasePath.absoluteColumn().notNull(),
vcs: text(),
name: text(),
@@ -21,11 +21,12 @@ export const ProjectDirectoryTable = sqliteTable(
"project_directory",
{
project_id: text()
.$type<ProjectV2.ID>()
.$type<ProjectSchema.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: text().notNull(),
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
directory: DatabasePath.absoluteColumn().notNull(),
type: text().$type<"main" | "root" | "git_worktree">(),
strategy: text(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
+2
View File
@@ -3,6 +3,7 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { LayerNode } from "./effect/layer-node"
import { Entry, Match } from "./filesystem/schema"
import { FSUtil } from "./fs-util"
import { AppProcess, collectStream, waitForAbort } from "./process"
@@ -285,3 +286,4 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node])
+4
View File
@@ -4,6 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { LayerNode } from "../effect/layer-node"
import { httpClient } from "../effect/layer-node-platform"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { which } from "../util/which"
@@ -127,4 +129,6 @@ export namespace RipgrepBinary {
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node])
}
+1 -1
View File
@@ -27,7 +27,7 @@ import { ApplicationTools } from "../src/tool/application-tools"
const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
applicationTools,
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
LocationServiceMap.layer.pipe(
Layer.provide(applicationTools),
Layer.provide(
+4 -1
View File
@@ -11,6 +11,7 @@ import { Git } from "@opencode-ai/core/git"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -22,11 +23,13 @@ import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
const project = Project.layer.pipe(
Layer.provide(database),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(directories),
)
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
@@ -45,7 +48,7 @@ const layer = MoveSession.layer.pipe(
Layer.provide(sessions),
)
const it = testEffect(
Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions),
Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
)
function abs(input: string) {
@@ -72,6 +72,48 @@ describe("SnowflakeCortexPlugin", () => {
),
)
it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(SnowflakeCortexPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
},
{},
)
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("falls back to options.token when no Snowflake env token is set", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(SnowflakeCortexPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
token: "options-token",
},
},
{},
)
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("sets includeUsage on the SDK options", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
+116 -29
View File
@@ -12,23 +12,28 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(directoriesLayer),
Layer.provide(eventLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
function abs(input: string) {
return AbsolutePath.make(input)
}
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
@@ -55,7 +60,7 @@ function setup() {
.pipe(Effect.orDie)
yield* db
.insert(ProjectDirectoryTable)
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
.values({ project_id: projectID, directory: sourceDirectory })
.run()
.pipe(Effect.orDie)
return { root, sourceDirectory, projectID, db }
@@ -65,7 +70,7 @@ function setup() {
function stored(projectID: Project.ID) {
return Database.Service.use(({ db }) =>
db
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.all()
@@ -77,18 +82,40 @@ function stored(projectID: Project.ID) {
}
describe("ProjectCopy", () => {
it.live("detects linked git worktrees but not root checkouts", () =>
it.effect("accepts arbitrary non-empty strategy ids", () =>
Effect.sync(() => {
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
}),
)
it.effect("rejects duplicate strategies and reports unavailable ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const target = abs(`${input.root.path}-copy-detected`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
const strategy: ProjectCopy.Strategy = {
id: ProjectCopy.StrategyID.make("test/duplicate"),
create: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
list: () => Effect.succeed([]),
}
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
yield* copy.register(strategy)
expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
const error = yield* copy
.create({
projectID: input.projectID,
strategy: unavailable,
sourceDirectory: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
name: "copy",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
}),
)
@@ -110,7 +137,7 @@ describe("ProjectCopy", () => {
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
@@ -118,15 +145,15 @@ describe("ProjectCopy", () => {
expect(created.directory).toBe(target)
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: created.directory, type: "git_worktree" as const },
{ directory: input.sourceDirectory, strategy: null },
{ directory: created.directory, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
@@ -142,7 +169,7 @@ describe("ProjectCopy", () => {
)
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
@@ -158,7 +185,7 @@ describe("ProjectCopy", () => {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
@@ -166,6 +193,28 @@ describe("ProjectCopy", () => {
}),
)
it.live("preserves copies whose stored strategy is unavailable", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const unavailable = abs(`${input.root.path}-copy-unavailable`)
yield* Effect.promise(() => fs.mkdir(unavailable))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
yield* input.db
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
.run()
.pipe(Effect.orDie)
const error = yield* copy
.remove({ projectID: input.projectID, directory: unavailable, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
}),
)
it.live("adds a numeric suffix when a copy directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
@@ -181,7 +230,7 @@ describe("ProjectCopy", () => {
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
@@ -219,7 +268,7 @@ describe("ProjectCopy", () => {
const error = yield* copy
.create({
projectID: input.projectID,
strategy: "git_worktree",
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
@@ -227,7 +276,8 @@ describe("ProjectCopy", () => {
.pipe(Effect.flip)
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
if (error instanceof ProjectCopy.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
}),
)
@@ -263,25 +313,30 @@ describe("ProjectCopy", () => {
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* input.db
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: target })
.run()
.pipe(Effect.orDie)
const fiber = yield* events
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* copy.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: discovered, type: "git_worktree" as const },
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
@@ -303,18 +358,50 @@ describe("ProjectCopy", () => {
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: discovered, type: "git_worktree" as const },
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
)
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
updated: [],
removed: [],
})
}),
)
it.live("refresh removes missing ordinary checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
const missing = abs(`${input.root.path}-missing-checkout`)
yield* input.db
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: missing })
.run()
.pipe(Effect.orDie)
const copy = yield* ProjectCopy.Service
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
)
})
@@ -0,0 +1,63 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, directories))
const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories")
function setup() {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie),
)
}
describe("ProjectDirectories", () => {
it.effect("decodes directory schemas", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
}),
)
it.effect("creates once and ignores conflicts", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
expect(yield* service.create({ projectID, directory })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
}),
)
it.effect("replaces the strategy when requested", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
yield* service.create({ projectID, directory, strategy: "old/strategy" })
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
}),
)
})
+5 -50
View File
@@ -4,24 +4,27 @@ import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
const it = testEffect(
Layer.mergeAll(
ProjectV2.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(directoriesLayer),
Layer.provide(databaseLayer),
),
databaseLayer,
directoriesLayer,
),
)
@@ -51,54 +54,6 @@ async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("Project directories schemas", () => {
it.effect("decodes project directory input and inline directory results", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual(
{
projectID: ProjectV2.ID.make("project"),
},
)
expect(
Schema.decodeUnknownSync(ProjectV2.Directories)([
{ directory: AbsolutePath.make("/tmp/project"), type: "main" },
]),
).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }])
}),
)
it.effect("lists stored project directories newest first for the requested project", () =>
Effect.gen(function* () {
const project = yield* ProjectV2.Service
const { db } = yield* Database.Service
const projectID = ProjectV2.ID.make("directories-project")
const otherID = ProjectV2.ID.make("directories-other")
yield* db
.insert(ProjectTable)
.values([
{ id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
{ id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectDirectoryTable)
.values([
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 },
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
])
.run()
.pipe(Effect.orDie)
expect(yield* project.directories({ projectID })).toEqual([
{ directory: AbsolutePath.make("/repo/z"), type: "root" },
{ directory: AbsolutePath.make("/repo/a"), type: "main" },
])
}),
)
})
describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.4",
"version": "1.17.6",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.4",
"version": "1.17.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.4",
"version": "1.17.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.6",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -477,25 +477,6 @@ export const ProvidersLoginCommand = effectCmd({
)
}
if (provider === "snowflake-cortex") {
const account = yield* promptValue(
yield* Prompt.text({
message: "Snowflake Account Identifier",
placeholder: "xy12345.us-east-1",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}),
)
const pat = yield* promptValue(
yield* Prompt.password({
message: "Programmatic Access Token (PAT)",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}),
)
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: pat, metadata: { account } }))
yield* Prompt.outro("Done")
return
}
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+8 -2
View File
@@ -840,7 +840,10 @@ export const RunCommand = effectCmd({
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
const headers = new Headers(request.headers)
const auth = ServerAuth.header()
if (auth) headers.set("Authorization", auth)
return Server.Default().app.fetch(new Request(request, { headers }))
}) as typeof globalThis.fetch
try {
@@ -875,7 +878,10 @@ export const RunCommand = effectCmd({
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
const headers = new Headers(request.headers)
const auth = ServerAuth.header()
if (auth) headers.set("Authorization", auth)
return Server.Default().app.fetch(new Request(request, { headers }))
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
@@ -202,7 +202,6 @@ function match<T extends PanelEntry>(query: string, entries: T[]) {
}
function PanelShell(props: {
id: string
title: string
countVisible?: boolean
query: string
@@ -279,7 +278,7 @@ function PanelShell(props: {
</>
)
return (
<box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{minimal() ? (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
@@ -298,14 +297,7 @@ function PanelShell(props: {
</box>
)}
{minimal() ? (
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={false}
backgroundColor="transparent"
flexShrink={0}
>
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
<box
width="100%"
height={1}
@@ -317,7 +309,6 @@ function PanelShell(props: {
</box>
) : (
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
@@ -549,7 +540,6 @@ export function RunCommandMenuBody(props: {
return (
<PanelShell
id="run-direct-footer-command-panel"
title="Commands"
countVisible={false}
query={query()}
@@ -565,7 +555,6 @@ export function RunCommandMenuBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-command-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -649,7 +638,6 @@ export function RunSubagentSelectBody(props: {
return (
<PanelShell
id="run-direct-footer-subagent-panel"
title="Select subagent"
query={query()}
count={items().length}
@@ -664,7 +652,6 @@ export function RunSubagentSelectBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-subagent-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -748,7 +735,6 @@ export function RunQueuedPromptSelectBody(props: {
return (
<PanelShell
id="run-direct-footer-queued-panel"
title="Queued prompts"
query={query()}
count={items().length}
@@ -763,7 +749,6 @@ export function RunQueuedPromptSelectBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-queued-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -827,7 +812,6 @@ export function RunSkillSelectBody(props: {
return (
<PanelShell
id="run-direct-footer-skill-panel"
title="Skills"
query={query()}
count={items().length}
@@ -842,7 +826,6 @@ export function RunSkillSelectBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-skill-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -927,7 +910,6 @@ export function RunVariantSelectBody(props: {
return (
<PanelShell
id="run-direct-footer-variant-panel"
title="Select variant"
query={query()}
count={items().length}
@@ -942,7 +924,6 @@ export function RunVariantSelectBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-variant-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -1050,7 +1031,6 @@ export function RunModelSelectBody(props: {
return (
<PanelShell
id="run-direct-footer-model-panel"
title="Select model"
query={query()}
count={items().length}
@@ -1065,7 +1045,6 @@ export function RunModelSelectBody(props: {
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-model-list"
theme={props.theme}
items={items}
selected={menu.selected}
@@ -115,7 +115,6 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
}
export function RunFooterMenu(props: {
id?: string
theme: Accessor<RunFooterTheme>
items: Accessor<RunFooterMenuItem[]>
selected: Accessor<number>
@@ -226,7 +225,6 @@ export function RunFooterMenu(props: {
}
return (
<box
id={props.id ?? "run-direct-footer-menu"}
width="100%"
height={props.rows()}
backgroundColor={props.background ? props.theme().shade : transparent}
@@ -96,7 +96,6 @@ export function RejectField(props: {
return (
<textarea
id="run-direct-footer-permission-reject"
width="100%"
minHeight={1}
maxHeight={3}
@@ -258,15 +257,8 @@ export function RunPermissionBody(props: {
})
return (
<box
id="run-direct-footer-permission-body"
width="100%"
height="100%"
flexDirection="column"
backgroundColor={props.theme.surface}
>
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
<box
id="run-direct-footer-permission-head"
flexDirection="column"
gap={1}
paddingLeft={1}
@@ -303,7 +295,6 @@ export function RunPermissionBody(props: {
fallback={
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
<box
id="run-direct-footer-permission-reject-bar"
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
backgroundColor={props.theme.line}
@@ -433,7 +424,6 @@ export function RunPermissionBody(props: {
</box>
<box
id="run-direct-footer-permission-actions"
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
backgroundColor={props.theme.pane}
@@ -250,10 +250,9 @@ export function RunPromptBody(props: {
})
return (
<box id="run-direct-footer-prompt" width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}>
<box width="100%">
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
<textarea
id="run-direct-footer-composer"
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
@@ -268,9 +268,8 @@ export function RunQuestionBody(props: {
})
return (
<box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
<box width="100%" height="100%" flexDirection="column">
<box
id="run-direct-footer-question-panel"
flexDirection="column"
gap={1}
paddingLeft={1}
@@ -281,14 +280,13 @@ export function RunQuestionBody(props: {
backgroundColor={props.theme.surface}
>
<Show when={!single()}>
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<For each={props.request.questions}>
{(item, index) => {
const active = () => state().tab === index()
const answered = () => (state().answers[index()]?.length ?? 0) > 0
return (
<box
id={`run-direct-footer-question-tab-${index()}`}
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
@@ -304,7 +302,6 @@ export function RunQuestionBody(props: {
}}
</For>
<box
id="run-direct-footer-question-tab-confirm"
paddingLeft={1}
paddingRight={1}
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
@@ -382,7 +379,6 @@ export function RunQuestionBody(props: {
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
return (
<box
id={`run-direct-footer-question-option-${index()}`}
flexDirection="column"
gap={0}
onMouseOver={() => {
@@ -428,7 +424,6 @@ export function RunQuestionBody(props: {
<Show when={questionCustom(props.request, state())}>
<box
id="run-direct-footer-question-option-custom"
flexDirection="column"
gap={0}
onMouseOver={() => {
@@ -480,7 +475,6 @@ export function RunQuestionBody(props: {
>
<box paddingLeft={3}>
<textarea
id="run-direct-footer-question-custom"
width="100%"
minHeight={1}
maxHeight={4}
@@ -518,7 +512,6 @@ export function RunQuestionBody(props: {
</box>
<box
id="run-direct-footer-question-actions"
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
gap={1}
@@ -119,13 +119,7 @@ export function RunFooterSubagentBody(props: {
})
return (
<box
id="run-direct-footer-subagent"
width="100%"
height="100%"
flexDirection="column"
backgroundColor={footer().surface}
>
<box width="100%" height="100%" flexDirection="column" backgroundColor={footer().surface}>
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
<Show when={tab()}>
{(current) => (
@@ -621,7 +621,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return (
<box
id="run-direct-footer-shell"
width="100%"
height="100%"
border={false}
@@ -631,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
padding={0}
>
<Show when={panel() || inspecting()}>
<box id="run-direct-footer-panel-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
<box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
</Show>
<Show
@@ -641,7 +640,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<For each={[promptView()]}>
{() => (
<box
id="run-direct-footer-composer-frame"
width="100%"
flexShrink={0}
border={panel() || prompt() ? false : ["left"]}
@@ -656,7 +654,6 @@ export function RunFooterView(props: RunFooterViewProps) {
}
>
<box
id="run-direct-footer-composer-area"
width="100%"
flexGrow={1}
paddingLeft={0}
@@ -666,7 +663,7 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor={panel() || prompt() ? "transparent" : theme().surface}
gap={0}
>
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<box width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<Switch>
<Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody
@@ -804,7 +801,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && menu()}>
<RunFooterMenu
id="run-direct-footer-complete"
theme={theme}
items={composer.options}
selected={composer.selected}
@@ -818,7 +814,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && !menu()}>
<box
id="run-direct-footer-statusline"
width="100%"
height={1}
flexDirection="row"
@@ -826,20 +821,13 @@ export function RunFooterView(props: RunFooterViewProps) {
flexShrink={0}
backgroundColor={statuslineBackground()}
>
<box
id="run-direct-footer-statusline-mode"
paddingLeft={1}
paddingRight={1}
backgroundColor={theme().statusAccent}
flexShrink={0}
>
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{modeLabel()}</span>
</text>
</box>
<box
id="run-direct-footer-statusline-main"
flexDirection="row"
gap={1}
flexGrow={1}
@@ -850,19 +838,12 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor="transparent"
>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" flexShrink={0}>
<box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
</Show>
<text
id="run-direct-footer-statusline-text"
fg={statusColor()}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<Show when={busy() && !exiting()} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
@@ -873,12 +854,7 @@ export function RunFooterView(props: RunFooterViewProps) {
</box>
<Show when={activityMeta().length > 0}>
<box
id="run-direct-footer-statusline-meta"
paddingRight={1}
backgroundColor="transparent"
flexShrink={1}
>
<box paddingRight={1} backgroundColor="transparent" flexShrink={1}>
<text fg={theme().muted} wrapMode="none" truncate>
{activityMeta()}
</text>
@@ -887,12 +863,7 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={responsive().statusline.showModel && modelStatus()}>
{(info) => (
<box
id="run-direct-footer-statusline-model"
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
>
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
{info().model}
<Show when={info().provider}>
@@ -912,13 +883,7 @@ export function RunFooterView(props: RunFooterViewProps) {
<For each={contextHints()}>
{(hint, index) => (
<box
id={`run-direct-footer-statusline-${hint.kind}`}
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
maxWidth={24}
>
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={24}>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
{sectionSeparator()}
@@ -932,13 +897,7 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={commandHint()}>
{(hint) => (
<box
id="run-direct-footer-statusline-hint"
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
maxWidth={18}
>
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
{sectionSeparator()}
@@ -955,7 +914,6 @@ export function RunFooterView(props: RunFooterViewProps) {
}
>
<box
id="run-direct-footer-subagent-frame"
width="100%"
flexGrow={1}
flexShrink={1}
@@ -34,8 +34,6 @@ type ActiveEntry = {
rendered: boolean
}
let nextId = 0
function commitMarkdownBlocks(input: {
surface: ScrollbackSurface
renderable: MarkdownRenderable
@@ -152,12 +150,10 @@ export class RunScrollbackStream {
const surface = this.renderer.createScrollbackSurface({
startOnNewLine: entryFlags(commit).startOnNewLine,
})
const id = `run-scrollback-entry-${nextId++}`
const style = entryLook(commit, this.theme.entry)
const renderable =
body.type === "text"
? new TextRenderable(surface.renderContext, {
id,
content: "",
width: "100%",
wrapMode: "word",
@@ -166,7 +162,6 @@ export class RunScrollbackStream {
})
: body.type === "code"
? new CodeRenderable(surface.renderContext, {
id,
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme),
@@ -178,7 +173,6 @@ export class RunScrollbackStream {
treeSitterClient: this.treeSitterClient,
})
: new MarkdownRenderable(surface.renderContext, {
id,
content: "",
syntaxStyle: entrySyntax(commit, this.theme),
width: "100%",
@@ -322,7 +322,6 @@ export function entryWriter(input: {
export function spacerWriter(): ScrollbackWriter {
return (ctx: ScrollbackRenderContext) => ({
root: new TextRenderable(ctx.renderContext, {
id: "run-scrollback-spacer",
width: Math.max(1, Math.trunc(ctx.width)),
height: 1,
content: "",
@@ -45,8 +45,6 @@ type Cell = {
mark: "text" | "full" | "mix" | "top"
}
let id = 0
function cells(line: string): Cell[] {
const list: Cell[] = []
for (const char of line) {
@@ -117,7 +115,6 @@ function write(
root.add(
new TextRenderable(ctx.renderContext, {
id: `run-direct-splash-line-${id++}`,
position: "absolute",
left: line.left,
top: line.top,
@@ -246,7 +243,6 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
}
const root = new BoxRenderable(ctx.renderContext, {
id: `run-direct-splash-${kind}-${id++}`,
position: "absolute",
left: 0,
top: 0,
+20
View File
@@ -3,6 +3,7 @@ import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type CallToolResult,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
@@ -63,6 +64,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
timeout,
},
)
if (!isCallToolResult(result)) return result
if (result.isError) throw new Error(errorText(result))
if (result.structuredContent === undefined || result.structuredContent === null) return result
return {
...result,
@@ -72,6 +75,23 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
})
}
function isCallToolResult(result: Awaited<ReturnType<Client["callTool"]>>): result is CallToolResult {
return "content" in result && Array.isArray(result.content)
}
function errorText(result: CallToolResult) {
const content = result.content.flatMap((item) => {
if (item.type === "text") return [item.text]
if (item.type === "resource" && "text" in item.resource) return [item.resource.text]
return []
})
if (result.structuredContent !== undefined && result.structuredContent !== null) {
const structured = JSON.stringify(result.structuredContent)
if (structured !== undefined) content.push(structured)
}
return [...new Set(content.filter((item) => item.trim().length > 0))].join("\n\n") || "MCP tool returned an error"
}
export function fetch<T extends { name: string }>(
clientName: string,
client: Client,
+30 -5
View File
@@ -1,14 +1,16 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
ListRootsRequestSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
type Tool as MCPToolDef,
@@ -35,6 +37,18 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { McpCatalog } from "./catalog"
const DEFAULT_TIMEOUT = 30_000
const CLIENT_OPTIONS = {
capabilities: {
// https://github.com/anomalyco/opencode/issues/11948
// sampling: {},
// https://github.com/anomalyco/opencode/issues/23066
// elicitation: {},
// https://github.com/anomalyco/opencode/issues/2308
roots: {},
// https://github.com/anomalyco/opencode/issues/28567
// tasks: {},
},
} satisfies ClientOptions
export const Resource = Schema.Struct({
name: Schema.String,
@@ -70,6 +84,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
type MCPClient = Client
function createClient(directory: string) {
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
return client
}
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "MCPStatusConnected",
})
@@ -180,19 +202,21 @@ export const layer = Layer.effect(
* Connect a client via the given transport with resource safety:
* on failure the transport is closed; on success the caller owns it.
*/
const connectTransport = (transport: Transport, timeout: number) =>
Effect.acquireUseRelease(
const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) {
const directory = yield* InstanceState.directory
return yield* Effect.acquireUseRelease(
Effect.succeed(transport),
(t) =>
Effect.tryPromise({
try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion })
const client = createClient(directory)
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
}),
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
)
})
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
@@ -765,10 +789,11 @@ export const layer = Layer.effect(
authProvider,
requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined,
})
const directory = yield* InstanceState.directory
return yield* Effect.tryPromise({
try: () => {
const client = new Client({ name: "opencode", version: InstallationVersion })
const client = createClient(directory)
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
+2
View File
@@ -19,6 +19,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -75,6 +76,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
SnowflakeCortexAuthPlugin,
XaiAuthPlugin,
]
}
@@ -0,0 +1,529 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createServer } from "http"
import open from "open"
const OAUTH_CLIENT_ID = "LOCAL_APPLICATION"
const OAUTH_CALLBACK_HOST = "127.0.0.1"
const OAUTH_CALLBACK_PATH = "/"
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
interface PkceCodes {
verifier: string
challenge: string
}
interface TokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type?: string
}
interface PendingOAuth {
account: string
state: string
pkce: PkceCodes
resolve: (tokens: TokenResponse) => void
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
let oauthServerPort: number | undefined
function normalizeAccount(input: string) {
return input
.trim()
.replace(/^https?:\/\//, "")
.replace(/\.snowflakecomputing\.com\/?$/, "")
.replace(/\/+$/, "")
}
function generateRandomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
.map((b) => chars[b % chars.length])
.join("")
}
function base64UrlEncode(buffer: ArrayBuffer) {
const binary = String.fromCharCode(...new Uint8Array(buffer))
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(64)
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return {
verifier,
challenge: base64UrlEncode(hash),
}
}
function callbackUrl() {
if (!oauthServerPort) throw new Error("Snowflake OAuth callback server is not running")
return `http://${OAUTH_CALLBACK_HOST}:${oauthServerPort}${OAUTH_CALLBACK_PATH}`
}
export function oauthScope(role: string | undefined) {
if (!role) return "refresh_token"
return /^[-_A-Za-z0-9]+$/.test(role)
? `refresh_token session:role:${role}`
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
}
function authHeaders() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
function authBasicHeader() {
return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}`
}
function buildAuthorizeUrl(account: string, role: string | undefined, state: string, pkce: PkceCodes) {
const scope = oauthScope(role)
const params = new URLSearchParams({
client_id: OAUTH_CLIENT_ID,
response_type: "code",
redirect_uri: callbackUrl(),
scope,
state,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})
return `https://${account}.snowflakecomputing.com/oauth/authorize?${params.toString()}`
}
async function exchangeCodeForToken(account: string, code: string, pkce: PkceCodes) {
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
method: "POST",
headers: {
...authHeaders(),
Authorization: authBasicHeader(),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: callbackUrl(),
client_id: OAUTH_CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`Snowflake token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
const token = (await response.json()) as TokenResponse
if (!token.access_token) throw new Error("Snowflake token response did not include access_token")
if (!token.refresh_token) {
throw new Error(
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
)
}
return token
}
async function refreshAccessToken(account: string, refreshToken: string) {
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
method: "POST",
headers: {
...authHeaders(),
Authorization: authBasicHeader(),
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
}).toString(),
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`Snowflake token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
const token = (await response.json()) as TokenResponse
if (!token.access_token) throw new Error("Snowflake refresh response did not include access_token")
return token
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:36rem; padding:2rem;">
<h1 style="color:#7ee787;">Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 1500)</script>
</body>
</html>`
const htmlError = (message: string) => `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:48rem; padding:2rem;">
<h1 style="color:#ff7b72;">Authorization Failed</h1>
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
</div>
</body>
</html>`
async function startOAuthServer() {
if (oauthServer) return
oauthServer = createServer((req, res) => {
const host = req.headers.host || `${OAUTH_CALLBACK_HOST}:${oauthServerPort ?? 0}`
const url = new URL(req.url || "/", `http://${host}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const state = url.searchParams.get("state")
const code = url.searchParams.get("code")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
// CSRF guard: validate state before processing any callback
if (!pendingOAuth || state !== pendingOAuth.state) {
const message = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(message))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message))
return
}
const current = pendingOAuth
pendingOAuth = undefined
if (error) {
const message = errorDescription || error
current.reject(new Error(message))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(htmlError(message))
return
}
if (!code) {
const message = "Missing authorization code"
current.reject(new Error(message))
res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message))
return
}
exchangeCodeForToken(current.account, code, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(0, OAUTH_CALLBACK_HOST, () => {
const address = oauthServer!.address()
if (!address || typeof address === "string") {
reject(new Error("Unable to resolve Snowflake OAuth callback port"))
return
}
oauthServerPort = address.port
resolve()
})
oauthServer!.on("error", reject)
})
}
function stopOAuthServer() {
if (!oauthServer) return
oauthServer.close()
oauthServer = undefined
oauthServerPort = undefined
}
function waitForOAuthCallback(account: string, pkce: PkceCodes, state: string): Promise<TokenResponse> {
if (pendingOAuth) {
pendingOAuth.reject(new Error("Superseded by a newer Snowflake authorize request"))
pendingOAuth = undefined
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (!pendingOAuth) return
pendingOAuth = undefined
stopOAuthServer()
reject(new Error("Snowflake OAuth callback timeout - authorization took too long"))
}, OAUTH_TIMEOUT_MS)
pendingOAuth = {
account,
state,
pkce,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = [
{
type: "text" as const,
key: "account",
message: "Snowflake Account Identifier",
placeholder: "myorg-myaccount",
validate: (value: string) => (value && value.trim().length > 0 ? undefined : "Required"),
},
{
type: "text" as const,
key: "role",
message: "Snowflake Role (optional)",
placeholder: "PUBLIC",
},
]
return {
auth: {
provider: "snowflake-cortex",
async loader(getAuth, _provider) {
let auth = await getAuth()
if (auth.type !== "oauth") return {}
let refreshPromise:
| Promise<{
access: string
refresh: string
expires: number
}>
| undefined
const oauth = auth as typeof auth & { refresh: string; access: string; expires: number; accountId?: string }
if (oauth.accountId && oauth.refresh && oauth.expires && oauth.expires <= Date.now()) {
try {
const tokens = await refreshAccessToken(oauth.accountId, oauth.refresh)
const refreshedRefresh = tokens.refresh_token || oauth.refresh
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
await _input.client.auth
.set({
path: { id: "snowflake-cortex" },
body: {
type: "oauth",
access: tokens.access_token,
refresh: refreshedRefresh,
expires: refreshedExpires,
...(oauth.accountId && { accountId: oauth.accountId }),
},
})
.catch(() => {})
} catch {}
}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
let currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
let currentOauth = currentAuth as typeof currentAuth & {
refresh: string
access: string
expires: number
accountId?: string
}
if (!currentOauth.accountId) throw new Error("Snowflake OAuth auth is missing accountId")
const accountId = currentOauth.accountId
const refresh = async () => {
if (!refreshPromise) {
const refreshToken = currentOauth.refresh
refreshPromise = refreshAccessToken(accountId, refreshToken)
.then(async (tokens) => {
const refreshedRefresh = tokens.refresh_token || refreshToken
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
await _input.client.auth
.set({
path: { id: "snowflake-cortex" },
body: {
type: "oauth",
access: tokens.access_token,
refresh: refreshedRefresh,
expires: refreshedExpires,
...(accountId && { accountId }),
},
})
.catch(() => {})
return {
access: tokens.access_token,
refresh: refreshedRefresh,
expires: refreshedExpires,
}
})
.finally(() => {
refreshPromise = undefined
})
}
const refreshed = await refreshPromise
currentOauth = { ...currentOauth, ...refreshed }
}
const prepareRequest = () => {
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
if (init?.headers) {
const entries =
init.headers instanceof Headers
? init.headers.entries()
: Array.isArray(init.headers)
? init.headers
: Object.entries(init.headers as Record<string, string | undefined>)
for (const [key, value] of entries) {
if (value !== undefined) headers.set(key, String(value))
}
}
headers.set("authorization", `Bearer ${currentOauth.access}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)
let body = init?.body
if (body && typeof body === "string") {
try {
const parsed = JSON.parse(body)
if ("max_tokens" in parsed) {
parsed.max_completion_tokens = parsed.max_tokens
delete parsed.max_tokens
body = JSON.stringify(parsed)
}
} catch {}
}
return { ...init, headers, body }
}
const transformResponse = async (response: Response) => {
if (!response.ok && response.status === 400) {
try {
const errorData = await response.clone().json()
const errorMessage = String(errorData.message || errorData.error || "")
if (errorMessage.toLowerCase().includes("conversation complete")) {
return new Response(
JSON.stringify({
choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }],
}),
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
)
}
} catch {}
}
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
const reader = response.body.getReader()
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const stream = new ReadableStream({
async pull(ctrl) {
const { done, value } = await reader.read()
if (done) {
ctrl.close()
return
}
const text = decoder.decode(value, { stream: true })
ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"')))
},
cancel() {
reader.cancel()
},
})
return new Response(stream, { headers: response.headers, status: response.status })
}
return response
}
const expiresSoon =
!currentOauth.expires ||
!currentOauth.access ||
currentOauth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS
if (expiresSoon) await refresh()
const response = await fetch(requestInput, prepareRequest())
if (response.status === 401) {
await refresh()
return transformResponse(await fetch(requestInput, prepareRequest()))
}
return transformResponse(response)
},
}
},
methods: [
{
type: "oauth",
label: "Login with Snowflake (External Browser)",
prompts,
async authorize(inputs = {}) {
const account = normalizeAccount(inputs.account || "")
if (!account) throw new Error("Snowflake account is required")
await startOAuthServer()
const pkce = await generatePKCE()
const state = generateRandomString(64)
const role = (inputs.role || "").trim() || undefined
const url = buildAuthorizeUrl(account, role, state, pkce)
const callbackPromise = waitForOAuthCallback(account, pkce, state)
await open(url).catch(() => undefined)
return {
url,
instructions:
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
method: "auto" as const,
async callback() {
try {
const tokens = await callbackPromise
return {
type: "success" as const,
refresh: tokens.refresh_token!,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
accountId: account,
}
} catch {
return { type: "failed" as const }
} finally {
stopOAuthServer()
}
},
}
},
},
{
type: "api",
label: "Paste PAT or bearer token manually",
prompts: prompts.filter((item) => item.key === "account"),
},
],
},
}
}
+15 -25
View File
@@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -14,7 +15,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -138,7 +138,7 @@ export const layer = Layer.effect(
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
const projectCopy = yield* ProjectCopy.Service
const projectDirectories = yield* ProjectDirectories.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const { db } = yield* Database.Service
@@ -197,6 +197,12 @@ export const layer = Layer.effect(
.run()
}
// Project directories may be shared across distinct
// checkouts which have diverged. Clear the directory
// list and rely on it being re-populated to ensure
// accuracy
yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run()
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
@@ -221,27 +227,11 @@ export const layer = Layer.effect(
}) {
if (input.projectID === ProjectV2.ID.global) return
const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
const type = yield* projectCopy.detect({ directory: opened })
yield* db
.transaction(
(d) =>
Effect.gen(function* () {
const hasMain = yield* d
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(eq(ProjectDirectoryTable.project_id, input.projectID), eq(ProjectDirectoryTable.type, "main")),
)
.get()
yield* d
.insert(ProjectDirectoryTable)
.values({ directory: opened, project_id: input.projectID, type: type ?? (hasMain ? "root" : "main") })
.onConflictDoNothing()
.run()
}),
{ behavior: "immediate" },
)
yield* projectDirectories
.create({
directory: opened,
projectID: input.projectID,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }),
@@ -505,7 +495,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
@@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [
AppProcess.node,
CrossSpawnSpawner.node,
ProjectV2.node,
ProjectCopy.node,
ProjectDirectories.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
+75 -62
View File
@@ -853,17 +853,23 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const account =
env["SNOWFLAKE_ACCOUNT"] ??
(auth?.type === "api" ? auth.metadata?.account : undefined) ??
(auth?.type === "oauth" ? auth.accountId : undefined) ??
input.options?.account
const pat = env["SNOWFLAKE_CORTEX_PAT"] ?? (auth?.type === "api" ? auth.key : undefined) ?? input.options?.apiKey
const envToken = env["SNOWFLAKE_CORTEX_TOKEN"] ?? env["SNOWFLAKE_CORTEX_PAT"]
const apiKeyToken = auth?.type === "api" ? auth.key : undefined
const oauthToken = auth?.type === "oauth" ? auth.access : undefined
const configToken = input.options?.token ?? input.options?.apiKey
if (!account || !pat) {
const missing = [!account && "SNOWFLAKE_ACCOUNT", !pat && "SNOWFLAKE_CORTEX_PAT"].filter(Boolean).join(", ")
const token = envToken ?? apiKeyToken ?? oauthToken ?? configToken
if (!account || !token) {
const missing = [!account && "SNOWFLAKE_ACCOUNT", !token && "SNOWFLAKE_CORTEX_TOKEN"].filter(Boolean).join(", ")
return {
autoload: false,
async getModel() {
throw new Error(
`Snowflake Cortex: missing credentials (${missing}). Set via env var, opencode auth, or provider options.`,
`Snowflake Cortex: missing credentials (${missing}). Provide a bearer token (OAuth, JWT, or PAT) via env var, opencode auth, or provider options.`,
)
},
}
@@ -871,66 +877,73 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
const options: Record<string, any> = { baseURL, apiKey: token }
// Only skip provider-level fetch when the token is from OAuth with no override.
// For OAuth tokens, the plugin auth loader's combined fetch handles
// OAuth refresh + snowflake transformations in one place.
// For env/config/API-key tokens, the provider fetch applies snowflake
// transformations directly.
const useOAuthHandler =
oauthToken !== undefined && envToken === undefined && apiKeyToken === undefined && configToken === undefined
if (!useOAuthHandler) {
options.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body)
if ("max_tokens" in body) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch {}
}
const response = await fetch(url, init)
if (!response.ok && response.status === 400) {
try {
const errorData = await response.clone().json()
const errorMessage = String(errorData.message || errorData.error || "")
if (errorMessage.toLowerCase().includes("conversation complete")) {
return new Response(
JSON.stringify({
choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }],
}),
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
)
}
} catch {}
}
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
const reader = response.body.getReader()
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const stream = new ReadableStream({
async pull(ctrl) {
const { done, value } = await reader.read()
if (done) {
ctrl.close()
return
}
const text = decoder.decode(value, { stream: true })
ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"')))
},
cancel() {
reader.cancel()
},
})
return new Response(stream, { headers: response.headers, status: response.status })
}
return response
}
}
return {
autoload: input.source === "config",
options: {
baseURL,
apiKey: pat,
fetch: async (url: RequestInfo | URL, init?: RequestInit) => {
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body)
if ("max_tokens" in body) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch {}
}
const response = await fetch(url, init)
// Cortex returns 400 "conversation complete" as a normal stop condition
if (!response.ok && response.status === 400) {
try {
const errorData = await response.clone().json()
const errorMessage = String(errorData.message || errorData.error || "")
if (errorMessage.toLowerCase().includes("conversation complete")) {
return new Response(
JSON.stringify({
choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }],
}),
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
)
}
} catch {}
}
// Cortex returns role:"" in streaming deltas; the AI SDK schema requires "assistant"
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
const reader = response.body.getReader()
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const stream = new ReadableStream({
async pull(ctrl) {
const { done, value } = await reader.read()
if (done) {
ctrl.close()
return
}
const text = decoder.decode(value, { stream: true })
ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"')))
},
cancel() {
reader.cancel()
},
})
return new Response(stream, { headers: response.headers, status: response.status })
}
return response
},
},
options,
}
}),
}
@@ -1,87 +1,31 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
const root = "/experimental/project/:projectID/copy"
const CopyQuery = Schema.Struct({
workspace: WorkspaceRoutingQueryFields.workspace,
export const GenerateNamePayload = Schema.Struct({
context: Schema.optional(Schema.String),
})
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
name: ProjectCopy.CreateInput.fields.name,
context: ProjectCopy.CreateInput.fields.context,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
force: ProjectCopy.RemoveInput.fields.force,
})
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
export const ProjectCopyApi = HttpApi.make("projectCopyName").add(
HttpApiGroup.make("projectCopyName")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: CopyQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.create",
summary: "Create project copy",
description: "Create a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.delete("remove", root, {
params: { projectID: ProjectV2.ID },
query: CopyQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.remove",
summary: "Remove project copy",
description: "Remove a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: ApiProjectCopyError,
payload: GenerateNamePayload,
success: Schema.Struct({ name: Schema.String }),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",
summary: "Refresh project copies",
description: "Discover local project copies using one or all configured strategies.",
identifier: "experimental.projectCopy.generateName",
summary: "Generate project copy name",
description: "Generate a short name for a project copy from task context.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy naming routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
@@ -1,75 +1,45 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { Git } from "@opencode-ai/core/git"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Agent } from "@/agent/agent"
import { Provider } from "@/provider/provider"
import { LLM } from "@/session/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { LLMEvent } from "@opencode-ai/llm"
import { Effect, Stream } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
import { Agent } from "@/agent/agent"
import { LLM } from "@/session/llm"
import { LLMEvent } from "@opencode-ai/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Provider } from "@/provider/provider"
import { Slug } from "@opencode-ai/core/util/slug"
const FALLBACK_AGENT: Agent.Info = {
name: "title",
mode: "primary" as const,
const COPY_NAME_AGENT: Agent.Info = {
name: "project-copy-name",
mode: "primary",
permission: [],
options: {},
native: true,
prompt: "",
}
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new ApiProjectCopyError({
name: "ProjectCopyError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
)
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
const agent = yield* Agent.Service
const provider = yield* Provider.Service
const service = yield* ProjectCopy.Service
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
const text = context?.trim()
if (!text) return Slug.create()
const [titleAgent, fallback] = yield* Effect.all(
[
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
],
{ concurrency: 2 },
)
const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!fallback) return Slug.create()
const model = titleAgent.model
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
: ((yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
const model =
(yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID))
const sessionID = SessionID.descending()
const result = yield* llm
.stream({
agent: titleAgent,
agent: COPY_NAME_AGENT,
user: {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: titleAgent.name,
agent: COPY_NAME_AGENT.name,
model: { providerID: model.providerID, modelID: model.id },
},
system: [],
@@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
model,
sessionID,
retries: 2,
messages: [
{
role: "user",
content: `Generate a short 3-4 word name that describes this task:\n${text}`,
},
],
messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
@@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
Stream.mkString,
)
const output = result.trim()
return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
return output ? slugify(output.split(/\s+/).slice(0, 3).join(" ")) : Slug.create()
})
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
const name =
ctx.payload.name ??
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
return yield* badRequest(
service.create({
...ctx.payload,
name,
projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
}),
)
})
const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof RemovePayload.Type
}) {
yield* badRequest(
service.remove({
...ctx.payload,
projectID: ctx.params.projectID,
}),
)
})
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
yield* badRequest(
service.refresh({
projectID: ctx.params.projectID,
}),
)
})
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
return handlers.handle("generateName", (ctx) =>
generateName(ctx.payload.context).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project copy name generation failed", {
projectID: ctx.params.projectID,
cause,
}).pipe(Effect.as(Slug.create())),
),
Effect.map((name) => ({ name })),
),
)
}),
)
@@ -143,15 +81,3 @@ function slugify(input: string) {
.replace(/^-+/, "")
.replace(/-+$/, "")
}
function message(error: ProjectCopy.Error) {
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
return `Project copy source not found: ${error.directory}`
if (error instanceof ProjectCopy.DestinationExistsError)
return `Project copy destination already exists: ${error.directory}`
if (error instanceof ProjectCopy.DirectoryUnavailableError)
return `Project copy directory unavailable: ${error.directory}`
if (error instanceof ProjectCopy.StrategyNotFoundError)
return `Project copy strategy not found for: ${error.directory}`
return error.message
}
@@ -1,61 +1,66 @@
import { Config as EffectConfig, Context, Effect, Layer } from "effect"
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
import {
FetchHttpClient,
HttpClient,
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerResponse,
} from "effect/unstable/http"
import { HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Observability from "@opencode-ai/core/observability"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { BackgroundJob } from "@/background/job"
import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/observability"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Config } from "@/config/config"
import { Workspace } from "@/control-plane/workspace"
import { Env } from "@/env"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Format } from "@/format"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Installation } from "@/installation"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Permission } from "@/permission"
import { Installation } from "@/installation"
import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { SessionProcessor } from "@/session/processor"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Truncate } from "@/tool/truncate"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Npm } from "@opencode-ai/core/npm"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { lazy } from "@/util/lazy"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
@@ -193,6 +198,64 @@ type RouteRequirements =
| HttpRouter.Request<"Requires", unknown>
| HttpRouter.Request<"GlobalRequires", never>
const app = LayerNode.group([
Npm.node,
FSUtil.node,
Database.node,
Auth.node,
Account.node,
Config.node,
Env.node,
Git.node,
Ripgrep.node,
Storage.node,
Snapshot.node,
Plugin.node,
ModelsDev.node,
Provider.node,
ProviderAuth.node,
Agent.node,
Skill.node,
Discovery.node,
Question.node,
Permission.node,
Todo.node,
Session.node,
SessionProjector.node,
SessionStatus.node,
BackgroundJob.node,
RuntimeFlags.node,
EventV2Bridge.node,
SessionRunState.node,
SessionProcessor.node,
SessionCompaction.node,
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
Instruction.node,
LLM.node,
LSP.node,
MCP.node,
McpAuth.node,
Command.node,
Truncate.node,
ToolRegistry.node,
Format.node,
Project.node,
Vcs.node,
Workspace.node,
Worktree.node,
Installation.node,
ShareNext.node,
SessionShare.node,
InstanceStore.node,
httpClient,
EventV2.node,
ProjectV2.node,
ProjectCopy.node,
PtyTicket.node,
])
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
@@ -209,58 +272,14 @@ export function createRoutes(
errorLayer,
compressionLayer,
corsVaryFix,
fenceLayer.pipe(Layer.provide(Database.defaultLayer)),
fenceLayer,
cors(corsOptions),
Database.defaultLayer,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
BackgroundJob.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
LLM.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
ModelsDev.defaultLayer,
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
MoveSession.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
RuntimeFlags.defaultLayer,
Session.defaultLayer,
SessionCompaction.defaultLayer,
SessionPrompt.defaultLayer,
SessionRevert.defaultLayer,
SessionShare.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
EventV2Bridge.defaultLayer,
EventV2.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
FSUtil.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
]),
Layer.provide(LayerNode.buildLayer(app)),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(Ripgrep.defaultLayer),
Layer.provide(InstanceLayer.layer),
Layer.provideMerge(Observability.layer),
Layer.provide(Observability.layer),
)
}
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA, type BoxRenderable } from "@opentui/core"
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
@@ -248,6 +248,45 @@ function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
)
}
function child(root: BoxRenderable | RootRenderable, index: number) {
return root.getChildren()[index] as BoxRenderable
}
function boxPath(root: BoxRenderable | RootRenderable, name: string): BoxRenderable[] | undefined {
for (const item of root.getChildren()) {
if (item.constructor.name === name) return root instanceof BoxRenderable ? [root] : []
if (!(item instanceof BoxRenderable)) continue
const path = boxPath(item, name)
if (path) return root instanceof BoxRenderable ? [root, ...path] : path
}
}
function footerComposerFrame(root: BoxRenderable | RootRenderable) {
return boxPath(root, "TextareaRenderable")!.at(-5)!
}
function footerStatusline(root: BoxRenderable | RootRenderable) {
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
if (
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
)
return box
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
throw new Error("Footer statusline not found")
}
function panelMenu(root: BoxRenderable | RootRenderable) {
const panel = child(child(root, 0), 0)
const content = child(panel, 0)
return child(content.getChildren().at(-1) as BoxRenderable, 0)
}
test("direct footer composer area does not adopt footer surface", async () => {
const surface = RGBA.fromHex("#123456")
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
@@ -255,7 +294,7 @@ test("direct footer composer area does not adopt footer surface", async () => {
try {
await app.renderOnce()
const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable
const area = child(footerComposerFrame(app.renderer.root), 0)
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
setTheme({
@@ -588,7 +627,7 @@ test("direct subagent panel renders active subagents", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow")
@@ -626,7 +665,7 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Queued prompts")
expect(frame).toContain("fix the auth test")
@@ -648,12 +687,12 @@ test.skip("direct footer recreates the frame across command panel transitions",
await app.renderOnce()
for (let index = 0; index < 3; index++) {
const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable
const composerFrame = footerComposerFrame(app.renderer.root)
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Commands")
expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame)
expect(footerComposerFrame(app.renderer.root)).not.toBe(composerFrame)
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Commands")
@@ -952,13 +991,14 @@ test("direct footer shows editable prompts and additional queued work while runn
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable
const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
const statusline = footerStatusline(app.renderer.root)
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
const mode = statusItems[0]
const main = statusItems[1]
const spinner = main.getChildren()[0]
const model = statusItems[2]
const queued = statusItems[3]
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined()
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
@@ -1276,7 +1316,7 @@ test("direct model panel renders current model selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select model")
expect(frame).toContain("Search")
@@ -1319,7 +1359,7 @@ test("direct variant panel renders current variant selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
@@ -0,0 +1,50 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
const posts: Array<{ method: string; session: string | null }> = []
let initializeCount = 0
let pingCount = 0
const server = Bun.serve({
port: 0,
async fetch(request) {
if (request.method === "GET") return new Response(null, { status: 405 })
if (request.method === "DELETE") return new Response(null, { status: 200 })
const message = (await request.json()) as { id?: number; method: string }
const session = request.headers.get("mcp-session-id")
posts.push({ method: message.method, session })
if (message.method === "initialize") {
initializeCount++
return Response.json(
{
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: { name: "test", version: "1" },
},
},
{ headers: { "mcp-session-id": initializeCount === 1 ? "expired" : "replacement" } },
)
}
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
pingCount++
if (pingCount === 1) return new Response("Session not found", { status: 404 })
return Response.json({ jsonrpc: "2.0", id: message.id, result: {} })
},
})
const client = new Client({ name: "test", version: "1" })
try {
await client.connect(new StreamableHTTPClientTransport(server.url))
await client.ping()
process.stdout.write(JSON.stringify(posts))
} finally {
await client.close()
server.stop(true)
}
@@ -0,0 +1,55 @@
import { describe, expect, mock, test } from "bun:test"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { convertTool } from "../../src/mcp/catalog"
const definition = {
name: "example",
description: "Example tool",
inputSchema: { type: "object" as const, properties: {} },
}
function tool(result: CallToolResult | { toolResult: unknown }) {
const callTool = mock(async () => result)
const converted = convertTool(definition, { callTool } as unknown as Client)
if (!converted.execute) throw new Error("expected executable tool")
return { callTool, execute: converted.execute }
}
describe("mcp catalog", () => {
test("returns ordinary tool results", async () => {
const result = {
content: [{ type: "text" as const, text: "ordinary output" }],
structuredContent: { value: 42 },
}
const converted = tool(result)
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).resolves.toEqual({
...result,
content: [{ type: "text", text: '{"value":42}' }],
})
expect(converted.callTool).toHaveBeenCalledTimes(1)
})
test("returns task tool results", async () => {
const result = { toolResult: { taskId: "task-1" } }
const converted = tool(result)
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).resolves.toBe(result)
})
test("throws MCP tool errors with text and structured diagnostics", async () => {
const converted = tool({
isError: true,
content: [
{ type: "text", text: "validation failed" },
{ type: "resource", resource: { uri: "error://details", text: "labels must be an object" } },
],
structuredContent: { field: "labels", expected: "object" },
})
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).rejects.toThrow(
'validation failed\n\nlabels must be an object\n\n{"field":"labels","expected":"object"}',
)
})
})
+34 -2
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect, mock, beforeEach } from "bun:test"
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
@@ -38,6 +39,8 @@ interface MockClientState {
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
>
closed: boolean
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
notificationHandlers: Map<unknown, (...args: any[]) => any>
}
@@ -75,6 +78,7 @@ function getOrCreateClientState(name?: string): MockClientState {
promptPages: {},
resourcePages: {},
closed: false,
requestHandlers: new Map(),
notificationHandlers: new Map(),
}
clientStates.set(key, state)
@@ -149,8 +153,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
_state!: MockClientState
transport: any
constructor(_opts: any) {
constructor(_info: any, options?: MockClientState["clientOptions"]) {
clientCreateCount++
this._state = getOrCreateClientState(lastCreatedClientName)
this._state.clientOptions = options
}
async connect(transport: { start: () => Promise<void> }) {
@@ -160,6 +166,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
this._state = getOrCreateClientState(lastCreatedClientName)
}
setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise<any>) {
this._state.requestHandlers.set(schema, handler)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler)
}
@@ -251,6 +261,28 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
return status[server]?.status
}
it.instance(
"advertises and lists the instance directory as its root",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
lastCreatedClientName = "roots"
yield* mcp.add("roots", { type: "local", command: ["echo", "test"] })
const state = getOrCreateClientState("roots")
expect(state.clientOptions?.capabilities?.roots).toEqual({})
expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined()
const handler = state.requestHandlers.get(ListRootsRequestSchema)
expect(handler).toBeDefined()
const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing")))
expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] })
}),
),
{ config: { mcp: {} } },
)
it.instance(
"local mcp cwd resolves relative paths against instance directory",
() =>
@@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
@@ -89,6 +89,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
// Mock the MCP SDK Client to trigger OAuth flow
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
setRequestHandler() {}
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}

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