Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 873cb69319 chore: drop unused exports across opencode 2026-05-21 17:15:30 -04:00
247 changed files with 3182 additions and 6085 deletions
+23 -34
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -44,7 +44,6 @@
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
@@ -85,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -169,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -193,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
"version": "1.15.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -239,7 +238,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
@@ -254,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -309,7 +308,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -323,7 +322,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -353,7 +352,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -369,7 +368,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -382,7 +381,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -400,7 +399,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.10",
"version": "1.15.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -494,7 +493,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
@@ -538,7 +537,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -576,7 +575,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -591,7 +590,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -626,7 +625,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -675,7 +674,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.10",
"version": "1.15.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2178,7 +2177,7 @@
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
@@ -4912,7 +4911,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@@ -5838,8 +5837,6 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -5956,11 +5953,7 @@
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@@ -6662,10 +6655,6 @@
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+1
View File
@@ -278,6 +278,7 @@ new sst.cloudflare.x.SolidStart("Console", {
//VITE_API_URL: gateway.url.apply((url) => url!),
VITE_AUTH_URL: auth.url.apply((url) => url!),
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
PLACEHOLDER: "keepalive",
},
transform: {
server: {
+1 -10
View File
@@ -3,7 +3,6 @@
stdenv,
bun,
nodejs,
darwin,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
@@ -15,12 +14,7 @@ let
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
inherit (opencode)
version
src
node_modules
patches
;
inherit (opencode) version src node_modules;
nativeBuildInputs = [
bun
@@ -29,9 +23,6 @@ stdenv.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
"x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=",
"aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=",
"aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=",
"x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4="
}
}
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.7",
"description": "",
"type": "module",
"exports": {
@@ -42,10 +42,10 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@opencode-ai/core": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
@@ -54,7 +54,6 @@
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
+14 -9
View File
@@ -48,17 +48,22 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
{ preload: Session.preload },
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
</SessionProviders>
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -312,7 +317,7 @@ export function AppInterface(props: {
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
@@ -41,7 +41,9 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
() =>
providers.all().find((x) => x.id === props.provider) ??
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -524,7 +526,7 @@ export function DialogConnectProvider(props: { provider: string }) {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
return instructions.split(":")[1]?.trim()
}
return instructions
})
@@ -106,7 +106,7 @@ export function DialogCustomProvider(props: Props) {
form,
t: language.t,
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
batch(() => {
setForm("err", output.err)
@@ -91,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="w-full">
<List
class="w-full px-0"
key={(p) => p.id}
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
@@ -35,7 +35,7 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -132,7 +132,7 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
+17 -87
View File
@@ -23,8 +23,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { StatusPopover } from "./status-popover"
import { SDKProvider } from "@/context/sdk"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -225,13 +223,19 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const openNewSession = () => {
if (params.dir) {
navigate(`/${params.dir}/session`)
return
}
const project = layout.projects.list()[0]
if (!project) return "/"
if (!project) {
navigate("/")
return
}
return `/${base64Encode(project.worktree)}/session`
navigate(`/${base64Encode(project.worktree)}/session`)
}
type Tab = { dir: string; sessionId: string; href: string }
@@ -298,21 +302,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
return tabsStore.find((tab) => tab.href === href)
if (!tabsStore.some((tab) => tab.href === href)) return
return href
}
const closeCurrentSessionTab = () => {
const tab = currentSessionTab()
if (!tab) return false
tabsStoreActions.removeTab(tab.href)
return true
}
const closeNewSessionTab = () => {
if (!(params.dir && !params.id)) return false
const last = tabsStore[tabsStore.length - 1]
if (last) navigate(last.href)
else navigate("/")
const href = currentSessionTab()
if (!href) return false
tabsStoreActions.removeTab(href)
return true
}
@@ -322,7 +319,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
(event) => {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "w") return
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
if (!closeCurrentSessionTab()) return
event.preventDefault()
event.stopPropagation()
@@ -330,63 +327,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{ capture: true },
)
command.register(() => {
const commands = [
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) navigate(next.href)
},
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) navigate(next.href)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigate(tab.href)
},
}
}),
]
return commands
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
@@ -451,8 +391,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
onClick={openNewSession}
aria-label={language.t("command.session.new")}
/>
}
@@ -465,15 +404,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</Show>
<div class="min-w-0 flex-1" />
</div>
<Show when={currentSessionTab()?.dir} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</SDKProvider>
)}
</Show>
<TitlebarUpdatePill update={props.update} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
-55
View File
@@ -232,9 +232,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
throwOnError: true,
})
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSdkContext>>()
const dirSdkContextRefCounts = new Map<string, number>()
return {
url: currentServer.http.url,
client: sdk,
@@ -252,58 +249,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
...opts,
})
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) - 1)
if (dirSdkContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSdkContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSdkContext(directory)
dirSyncContexts.set(directory, ctx)
dirSdkContextRefCounts.set(directory, 1)
return ctx
},
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string) {
const globalSDK = useGlobalSDK()
const client = globalSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = globalSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
}
+11 -4
View File
@@ -1,4 +1,12 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
@@ -29,7 +37,6 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
type GlobalStore = {
ready: boolean
@@ -39,7 +46,7 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
@@ -114,7 +121,7 @@ function createGlobalSync() {
return pathQuery.data ?? EMPTY
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
return providerQuery.data ?? EMPTY
},
@@ -1,78 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap"
import type { State, VcsCache } from "./types"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
await bootstrapDirectory({
directory: "/project",
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
})
})
@@ -5,6 +5,7 @@ import type {
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Session,
Todo,
@@ -19,7 +20,6 @@ import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../global-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
type GlobalStore = {
ready: boolean
@@ -28,7 +28,7 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
@@ -208,7 +208,7 @@ export async function bootstrapDirectory(input: {
config: Config
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider: ProviderListResponse
}
queryClient: QueryClient
}) {
@@ -1,63 +1,10 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../global-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
import { createChildStoreManager } from "./child-store"
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
path: (directory: string | null) => ({
queryKey: [directory, "path"],
queryFn: async () => ({
state: "",
config: "",
worktree: "",
directory: directory ?? "",
home: "",
}),
}),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi
function createOwner(callback: (owner: Owner) => void) {
return createRoot((dispose) => {
const owner = getOwner()
if (!owner) throw new Error("owner required")
callback(owner)
return dispose
})
}
beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: () => [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
],
}))
createChildStoreManager = (await import("./child-store")).createChildStoreManager
})
describe("createChildStoreManager", () => {
test("does not evict the active directory during mark", () => {
@@ -75,8 +22,8 @@ describe("createChildStoreManager", () => {
onBootstrap() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
queryOptions: {} as any,
global: { provider: null! },
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -90,35 +37,4 @@ describe("createChildStoreManager", () => {
expect(manager.children[directory]).toBeDefined()
})
test("starts new child stores as loading and bootstraps them on first access", () => {
const bootstraps: string[] = []
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project")
expect(store.status).toBe("loading")
expect(bootstraps).toEqual(["/project"])
} finally {
dispose()
}
})
})
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -17,7 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
export function createChildStoreManager(input: {
owner: Owner
@@ -28,7 +27,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
global: {
provider: NormalizedProviderListResponse
provider: ProviderListResponse
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -191,9 +190,10 @@ export function createChildStoreManager(input: {
return !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
@@ -8,6 +8,7 @@ import type {
Part,
Path,
PermissionRequest,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
@@ -15,7 +16,6 @@ import type {
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -38,7 +38,7 @@ export type State = {
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: NormalizedProviderListResponse
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
+5 -16
View File
@@ -1,5 +1,4 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -18,23 +17,13 @@ export function normalizeAgentList(input: unknown): Agent[] {
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
),
),
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const validModel = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
const provider = providers.all().find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
+40 -2
View File
@@ -1,11 +1,49 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
init: (props: { directory: Accessor<string> }) => {
const globalSDK = useGlobalSDK()
return globalSDK.createDirSyncContext(props.directory)
const directory = createMemo(props.directory)
const client = createMemo(() =>
globalSDK.createClient({
directory: directory(),
throwOnError: true,
}),
)
const emitter = createGlobalEmitter<SDKEventMap>()
createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
})
return {
get directory() {
return directory()
},
get client() {
return client()
},
event: emitter,
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
},
})
+55 -5
View File
@@ -1,4 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -9,8 +10,26 @@ function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -108,9 +127,40 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID]
}
export const useSync = () => {
const globalSync = useGlobalSync()
const sdk = useSDK()
return globalSync.createDirSyncContext(sdk.directory)
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const globalSync = useGlobalSync()
const sdk = useSDK()
return globalSync.createDirSyncContext(sdk.directory)
},
})
+5 -22
View File
@@ -1,7 +1,6 @@
import { useGlobalSync } from "@/context/global-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createMemo } from "solid-js"
export const popularProviders = [
@@ -30,32 +29,16 @@ export function useProviders() {
return {
all: () => providers().all,
default: () => providers().default,
popular: () =>
pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
connected: () => {
const connected = new Set(providers().connected)
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
return providers().all.filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
return [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
},
}
}
+5 -3
View File
@@ -6,7 +6,7 @@ import { createEffect, createMemo, createResource, type ParentProps, Show } from
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
@@ -81,8 +81,10 @@ export default function Layout(props: ParentProps) {
return (
<Show when={resolved()} keyed>
{(resolved) => (
<SDKProvider directory={resolved}>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
<SDKProvider directory={() => resolved}>
<SyncProvider>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
)}
</Show>
+181 -57
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -18,6 +18,7 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -466,6 +467,11 @@ function LegacyHome() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [promptText, setPromptText] = createSignal("")
const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
@@ -474,6 +480,8 @@ function LegacyHome() {
.slice(0, 5)
})
const currentProject = createMemo(() => recent()[0]?.worktree)
const serverDotClass = createMemo(() => {
const healthy = server.healthy()
if (healthy === true) return "bg-icon-success-base"
@@ -512,69 +520,185 @@ function LegacyHome() {
}
}
function handleModelSelect() {
dialog.show(() => <DialogSelectModel />)
}
function toggleAgent() {
const agents = ["frontend-specialist", "build", "general"]
const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
setSelectedAgent(agents[nextIndex])
}
function handleSubmit() {
const projectToOpen = currentProject()
if (projectToOpen) {
openProject(projectToOpen)
} else {
chooseProject()
}
}
const activeModelName = createMemo(() => {
const model = sync.data.config.model
if (!model) return "GPT-5.7 Pro"
const parts = model.split("/")
return parts[parts.length - 1]
})
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
<div class="flex flex-col items-center gap-3 mb-10">
<div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
<Logo class="w-48 opacity-15" />
</div>
<Button
size="normal"
variant="ghost"
class="text-12-regular text-text-weak px-3"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-1.5 rounded-full mr-2": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
</div>
<Switch>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Match when={recent().length > 0}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
<textarea
class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
placeholder="Ask anything, / for commands, @ for context..."
value={promptText()}
onInput={(e) => setPromptText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}}
/>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={toggleAgent}
>
<Icon name="sliders" size="small" class="shrink-0" />
<span>Agent: {selectedAgent()}</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
<div class="relative">
<Button
size="large"
size="small"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
<Icon name="folder" size="small" class="shrink-0" />
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
<Show when={showProjectsDropdown()}>
<div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
<div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
{language.t("home.recentProjects")}
</div>
<For each={recent()}>
{(project) => (
<button
class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
onClick={() => {
openProject(project.worktree)
setShowProjectsDropdown(false)
}}
>
<span class="truncate">{getFilename(project.worktree)}</span>
<span class="text-10-regular text-text-weak shrink-0 pl-2">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</span>
</button>
)}
</For>
<div class="border-t border-border-weak-base my-1" />
<button
class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
onClick={() => {
setShowProjectsDropdown(false)
chooseProject()
}}
>
<Icon name="folder-add-left" size="small" />
{language.t("command.project.open")}
</button>
</div>
</Show>
</div>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
>
<Icon name="branch" size="small" class="shrink-0" />
<span>Branch: dev</span>
</Button>
</div>
</div>
</div>
</Match>
<Match when={true}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
<div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
Ask anything, / for commands, @ for context...
</div>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={chooseProject}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Open project</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
</div>
</div>
<Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
+18 -20
View File
@@ -89,7 +89,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@@ -154,7 +154,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_NEW_DESIGN,
autoselect: !initialDirectory && !USE_HOME_DESIGN,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1028,6 +1028,19 @@ export default function Layout(props: ParentProps) {
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
}
}),
{
id: "provider.connect",
title: language.t("command.provider.connect"),
@@ -1142,21 +1155,6 @@ export default function Layout(props: ParentProps) {
},
]
if (!USE_NEW_DESIGN)
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
commands.push({
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
})
})
for (const [id] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
@@ -1821,7 +1819,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2305,7 +2303,7 @@ export default function Layout(props: ParentProps) {
<div
class="shrink-0 px-3 py-3"
classList={{
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
@@ -2363,7 +2361,7 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_NEW_DESIGN) {
if (USE_HOME_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
+5 -9
View File
@@ -18,7 +18,6 @@ import {
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { debounce } from "@solid-primitives/scheduled"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
@@ -59,7 +58,6 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
@@ -264,10 +262,8 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
@@ -481,7 +477,7 @@ export default function Page() {
: skipToken,
}
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
// avoids suspense
@@ -1737,12 +1733,12 @@ export default function Page() {
</Tabs>
</Show>
{/* Session panel */}
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
}}
style={{
width: sessionPanelWidth(),
@@ -1,14 +0,0 @@
import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
})
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +0,0 @@
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
}
@@ -55,7 +55,7 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { sessionKey, tabs, view, params } = useSessionLayout()
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
@@ -207,7 +207,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<Show when={isDesktop()}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"scripts": {
@@ -10,12 +10,9 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
// temporarily disable check headers
//const headersExist = Object.entries(limits.checkHeaders).every(
// ([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
//)
//const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const headersExist = true
const headersExist = Object.entries(limits.checkHeaders).every(
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
)
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headersExist && !rateLimit
@@ -0,0 +1,19 @@
import { createHash } from "crypto"
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function GET() {
const zenData = ZenData.list("full")
return new Response(
JSON.stringify({
hash: createHash("sha1").update(JSON.stringify(zenData)).digest("hex"),
timestamp: Date.now(),
check1: "alpha-di-k2.6" in zenData.models,
check2: "qwen3.6-plus-free" in zenData.models,
}),
{
headers: {
"Content-Type": "application/json",
},
},
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.7",
"$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.15.10",
"version": "1.15.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -5,14 +5,12 @@ export const Resource = new Proxy(
{},
{
get(_target, prop: string) {
console.log(`111 ${prop}`)
if (`SST_RESOURCE_${prop}` in env) {
console.log(`222 ${prop}`)
// @ts-expect-error
const value = env[`SST_RESOURCE_${prop}`]
return typeof value === "string" ? JSON.parse(value) : value
}
if (prop in env) {
// @ts-expect-error
const value = env[prop]
console.log(`333 ${value}`)
return typeof value === "string" ? JSON.parse(value) : value
} else if (prop === "App") {
// @ts-expect-error
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.7",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -67,7 +67,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
},
+1 -4
View File
@@ -3,10 +3,9 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -40,8 +39,6 @@ export namespace AppFileSystem {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
-114
View File
@@ -1,114 +0,0 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { AppFileSystem } from "./filesystem"
import { AppProcess } from "./process"
export interface Repo {
/**
* The root directory of the working tree that contains the input path.
*
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
* `/home/me/app-feature`.
*/
readonly directory: AbsolutePath
/**
* The shared Git storage directory used by this repo and any linked worktrees.
*
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
* For a linked worktree at `/home/me/app-feature` whose main checkout is
* `/home/me/app`, this is usually `/home/me/app/.git`.
*/
readonly store: AbsolutePath
}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
readonly roots: (repo: Repo) => Effect.Effect<string[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const proc = yield* AppProcess.Service
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotgit) return undefined
const cwd = path.dirname(dotgit)
const git = run(cwd, proc)
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
if (commonDir.exitCode !== 0) return undefined
return {
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
} satisfies Repo
})
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
if (result.exitCode !== 0) return []
return result.text
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()
})
return Service.of({ find, remote, roots })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
)
interface Result {
readonly exitCode: number
readonly text: string
}
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { Context, Deferred, Effect, Layer, Scope } from "effect"
import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
@@ -25,8 +25,7 @@ function resolveLocation(options: Record<string, any>) {
}
function vertexEndpoint(location: string) {
if (location === "global") return "aiplatform.googleapis.com"
return `${location}-aiplatform.googleapis.com`
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
}
function replaceVertexVars(value: string, project: string | undefined, location: string) {
@@ -132,25 +131,16 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
const project =
typeof evt.options.project === "string"
? evt.options.project
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT)
const location =
typeof evt.options.location === "string"
? evt.options.location
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global")
evt.sdk = mod.createVertexAnthropic({
...evt.options,
project,
location,
// Continental multi-regions (eu, us) require Regional Endpoint Platform
// domains; the default {region}-aiplatform.googleapis.com does not resolve.
...((location === "eu" || location === "us") && project && !evt.options.baseURL
? {
baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`,
}
: {}),
project:
typeof evt.options.project === "string"
? evt.options.project
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT),
location:
typeof evt.options.location === "string"
? evt.options.location
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"),
})
}),
"aisdk.language": Effect.fn(function* (evt) {
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"),
-129
View File
@@ -1,129 +0,0 @@
export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath, withStatics } from "./schema"
import { AppFileSystem } from "./filesystem"
import { Git } from "./git"
import { Hash } from "./util/hash"
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
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
vcs: Schema.optional(Vcs),
}) {}
export interface Interface {
readonly resolve: (input: AbsolutePath) => Effect.Effect<
{
previous?: ID
id: ID
directory: AbsolutePath
vcs?: Vcs
},
never
>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
*
* This exists while the old opencode project service and this core project
* service work together: core resolves the ID, while the old service still owns
* database migration and persistence. The old service should call this after it
* finishes migrating from `resolve().previous` to `resolve().id`; once project
* persistence moves into core, this separate bridge method can go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
Effect.map((value) => (value ? ID.make(value) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
const origin = yield* git.remote(repo)
if (!origin) return undefined
const normalized = url(origin)
if (!normalized) return undefined
return ID.make(Hash.fast(`git-remote:${normalized}`))
})
function url(input: string) {
const value = input.trim()
if (!value) return undefined
try {
const parsed = new URL(value)
if (parsed.protocol === "file:") return undefined
return parts(parsed.hostname, parsed.pathname)
} catch {
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
if (scp) return parts(scp[2], scp[3])
return undefined
}
}
function parts(host: string, name: string) {
const pathname = name
.replace(/^\/+/, "")
.replace(/\.git\/?$/, "")
.replace(/\/+$/, "")
if (!host || !pathname) return undefined
return `${host.toLowerCase()}/${pathname}`
}
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
const root = (yield* git.roots(repo))[0]
return root ? ID.make(root) : undefined
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.directory,
vcs: { type: "git" as const, store: repo.store },
}
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ resolve, commit })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
-6
View File
@@ -1,11 +1,5 @@
import { Option, Schema, SchemaGetter } from "effect"
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = typeof AbsolutePath.Type
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = typeof RelativePath.Type
/**
* Integer greater than zero.
*/
@@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model } from "./provider-helper"
import { it, model, provider } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = []
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
@@ -109,73 +109,6 @@ describe("GoogleVertexAnthropicPlugin", () => {
),
)
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "claude-sonnet-4-5"),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu" },
},
{},
)
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
"https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models",
)
}),
)
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "claude-sonnet-4-5"),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" },
},
{},
)
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1")
}),
)
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
yield* plugin.add(GoogleVertexAnthropicPlugin)
const sdkResult = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", " claude-sonnet-4-5 "),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "us" },
},
{},
)
const languageResult = yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", " claude-sonnet-4-5 "),
sdk: sdkResult.sdk,
options: {},
},
{},
)
const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string }
expect(language.config.baseURL).toBe(
"https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models",
)
expect(language.modelId).toBe("claude-sonnet-4-5")
}),
)
it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
@@ -160,32 +160,6 @@ describe("GoogleVertexPlugin", () => {
),
)
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}
provider.options.aisdk.provider.project = "config-project"
provider.options.aisdk.provider.location = "eu"
}),
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
})
}),
)
it.effect("defaults location to us-central1 when only project is configured", () =>
withEnv(
{
-220
View File
@@ -1,220 +0,0 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
return AbsolutePath.make(value)
}
function real(value: string) {
return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
}
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
await $`git config user.name Test`.cwd(dir).quiet()
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
}
async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
)
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
)
it.live("falls back to root commit when origin is missing", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
)
it.live("prefers normalized origin over root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
)
it.live("normalizes ssh and https remotes to the same id", () =>
Effect.gen(function* () {
const ssh = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const https = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("ignores file remotes and falls back to root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
}),
)
it.live("returns previous cached id from common dir", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
)
it.live("does not write the cache while resolving", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* Project.Service
yield* project.resolve(abs(tmp.path))
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
}),
)
it.live("resolves from nested directories to repo root", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.directory).toBe(yield* real(tmp.path))
}),
)
it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const worktree = `${tmp.path}-worktree`
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* Project.Service
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.10",
"version": "1.15.7",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -15
View File
@@ -1,7 +1,4 @@
import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
@@ -18,11 +15,6 @@ const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
@@ -54,7 +46,6 @@ export function setRelaunchHandler(handler: () => void) {
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
}
export function getBackgroundColor(): string | undefined {
@@ -74,10 +65,6 @@ function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function defaultBackgroundColor() {
return oc2Background[tone()]
}
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
const mode = theme.mode ?? tone()
return {
@@ -133,7 +120,7 @@ export function createMainWindow() {
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
backgroundColor,
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -191,7 +178,7 @@ export function createLoadingWindow() {
show: true,
autoHideMenuBar: true,
icon: iconPath(),
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.7",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.7",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.10"
version = "1.15.7"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.7",
"$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.15.10",
"version": "1.15.7",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.7",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1
View File
@@ -14,6 +14,7 @@ import {
ToolChoice,
ToolDefinition,
type ContentPart,
ToolCallPart,
ToolResultPart,
} from "./schema"
import { make as makeTool, type ToolSchema } from "./tool"
@@ -14,7 +14,6 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -97,18 +96,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
})
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
// Anthropic accepts either a plain string or an ordered array of text/image
// blocks inside `tool_result.content`. The array form is required when a tool
// returns image bytes (screenshot, image search, etc.) so they can be passed
// to the model as proper image inputs instead of being JSON-stringified into
// the prompt — which silently inflates context by megabytes and can push the
// conversation over the model's token limit.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
const AnthropicToolResultBlock = Schema.Struct({
type: Schema.tag("tool_result"),
tool_use_id: Schema.String,
content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]),
content: Schema.String,
is_error: Schema.optional(Schema.Boolean),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -206,13 +197,7 @@ const AnthropicEvent = Schema.Struct({
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
@@ -313,33 +298,6 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
} satisfies AnthropicImageBlock
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
if (item.mediaType.startsWith("image/"))
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: item.mediaType,
data: ProviderShared.mediaBase64(item),
},
} satisfies AnthropicImageBlock
return yield* invalid(`Anthropic Messages tool-result media content only supports images, got ${item.mediaType}`)
})
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
// Text / json / error results stay as a string for backward compatibility
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
@@ -402,7 +360,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
content.push({
type: "tool_result",
tool_use_id: part.id,
content: yield* lowerToolResultContent(part),
content: ProviderShared.toolResultText(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
})
@@ -709,18 +667,9 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
return [{ ...state, lifecycle, usage }, events]
}
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty.
const providerErrorMessage = (event: AnthropicEvent): string => {
const type = event.error?.type
const message = event.error?.message
if (type && message) return `${type}: ${message}`
return message || type || "Anthropic Messages stream error"
}
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[LLMEvent.providerError({ message: providerErrorMessage(event) })],
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
]
const step = (state: ParserState, event: AnthropicEvent) => {
+4 -67
View File
@@ -14,8 +14,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
@@ -57,16 +55,6 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesFunctionCallOutput = Schema.Union([
Schema.String,
Schema.Array(OpenAIResponsesFunctionCallOutputContent),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
@@ -81,7 +69,7 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: OpenAIResponsesFunctionCallOutput,
output: Schema.String,
}),
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
@@ -178,17 +166,6 @@ const OpenAIResponsesStreamItem = Schema.Struct({
})
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
// OpenAI Responses surfaces provider failures in two related shapes. The
// streaming `error` event carries the details at the top level
// (`{ type: "error", code, message, param, sequence_number }`), while
// `response.failed` carries them under `response.error`. We capture both so
// the parser can surface a useful provider-error message in either path.
const OpenAIResponsesErrorPayload = Schema.Struct({
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenAIResponsesEvent = Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
@@ -201,14 +178,12 @@ const OpenAIResponsesEvent = Schema.Struct({
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
param: Schema.optional(Schema.String),
})
type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
@@ -275,27 +250,6 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function*
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
if (item.mediaType.startsWith("image/"))
return {
type: "input_image" as const,
image_url: ProviderShared.mediaDataUrl(item),
}
return yield* invalid(`OpenAI Responses tool-result media content only supports images, got ${item.mediaType}`)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -344,11 +298,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
})
input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) })
}
}
@@ -646,27 +596,14 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step
return [{ ...state, lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => {
const nested = event.response?.error ?? undefined
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code || fallback
}
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })],
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
@@ -1,6 +1,7 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema"
import { Auth } from "../auth"
import { LLMError, TransportReason, type LLMRequest } from "../../schema"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,3 +1,4 @@
import { Stream } from "effect"
import { LLMClient } from "../../src/route"
import type { Tools } from "../../src/tool"
import type { RunOptions } from "../../src/tool-runtime"
@@ -24,19 +24,6 @@ const request = LLM.request({
generation: { maxTokens: 20, temperature: 0 },
})
type AnthropicToolResult = Extract<
AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number],
{ readonly type: "tool_result" }
>
const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): AnthropicToolResult => {
const result = body.messages
.flatMap((message) => (message.role === "user" ? message.content : []))
.find((block): block is AnthropicToolResult => block.type === "tool_result")
expect(result).toBeDefined()
return result!
}
describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
@@ -84,87 +71,6 @@ describe("Anthropic Messages route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `tool_result.content`.
it.effect("lowers image tool-result content as structured image blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image",
model,
messages: [
Message.user("Show me the screenshot."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
],
}),
],
cache: "none",
}),
)
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
])
}),
)
it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
Message.tool({
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/jpeg", data: "/9j/AA==" }],
}),
],
cache: "none",
}),
)
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "/9j/AA==" } },
])
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
}),
],
cache: "none",
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic Messages")
expect(error.message).toContain("audio/mpeg")
}),
)
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -337,29 +243,7 @@ describe("Anthropic Messages route", () => {
),
)
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
}),
)
it.effect("falls back to error type when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
}),
)
it.effect("falls back to a stable default when error payload is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
}),
)
@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import {
@@ -87,7 +87,6 @@ describeRecordedGoldenScenarios([
{ id: "reasoning-continuation", temperature: false },
{ id: "tool-call", temperature: false },
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
@@ -113,10 +112,7 @@ describeRecordedGoldenScenarios([
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
scenarios: [{ id: "tool-loop", temperature: false }],
},
{
name: "Gemini 2.5 Flash",
@@ -26,19 +26,6 @@ const request = LLM.request({
const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
type OpenAIToolOutput = Extract<
OpenAIResponses.OpenAIResponsesBody["input"][number],
{ readonly type: "function_call_output" }
>
const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => {
const output = body.input.find(
(item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output",
)
expect(output).toBeDefined()
return output!
}
describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
@@ -261,84 +248,6 @@ describe("OpenAI Responses route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image",
model,
messages: [
Message.user("Show me the screenshot."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_text", text: "Image read successfully" },
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
])
}),
)
it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
Message.tool({
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
])
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
}),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses")
expect(error.message).toContain("audio/mpeg")
}),
)
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -877,11 +786,7 @@ describe("OpenAI Responses route", () => {
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
)
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }])
}),
)
@@ -895,99 +800,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to error code when message is empty", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
}),
)
// Regression: `response.failed` carries the failure details under
// `response.error`, not at the top level. The previous handler only
// checked top-level `message`/`code` and so always emitted the bare
// "OpenAI Responses response failed" string, hiding the real cause.
it.effect("surfaces response.failed details from response.error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: {
id: "resp_failed_1",
error: { code: "server_error", message: "Upstream model unavailable" },
},
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
}),
)
it.effect("surfaces error event details even when they arrive nested under response.error", () =>
Effect.gen(function* () {
// Some OpenAI-compatible proxies and older SDK versions wrap the
// top-level error fields into a nested `response.error` payload
// when they bubble up an HTTP error as an SSE `error` event. Honour
// both shapes so the user still sees the underlying cause instead
// of the catch-all string.
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "error",
response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
}),
),
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "context_length_exceeded: prompt too long" }])
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
}),
)
it.effect("fails HTTP provider errors before stream parsing", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
-46
View File
@@ -317,47 +317,6 @@ const runImageScenario = (context: GoldenScenarioContext) =>
])
})
// Reproduces a tool-result image round trip: a tool returns image bytes, and
// the next model turn must receive provider-native image content instead of a
// JSON-stringified base64 blob.
const screenshotToolName = "read_screenshot"
const runImageToolResultScenario = (context: GoldenScenarioContext) =>
Effect.gen(function* () {
const image = yield* restroomImage()
const response = yield* generate(
LLM.request({
id: `${context.id}_image_tool_result`,
model: context.model,
system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.",
cache: "none",
generation: generation(context, context.maxTokens ?? 40),
messages: [
Message.user("Use the read_screenshot tool, then reply with the words shown."),
Message.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]),
Message.tool({
id: "call_screenshot_1",
name: screenshotToolName,
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: image },
],
}),
],
tools: [
ToolDefinition.make({
name: screenshotToolName,
description: "Capture a screenshot of the current screen.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}),
],
}),
)
expectFinish(response.events, "stop")
expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
})
const runReasoningScenario = (context: GoldenScenarioContext) =>
runGeneratedConversation(context, [
user("Think briefly, then reply exactly with: Hello!"),
@@ -400,11 +359,6 @@ const goldenScenarios = {
"tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
"tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
"image-tool-result": {
title: "reads image returned from tool result",
tags: ["media", "image", "vision", "tool", "tool-result", "golden"],
run: runImageToolResultScenario,
},
reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario },
"reasoning-continuation": {
title: "continues encrypted reasoning",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.7",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -159,7 +159,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
+1 -1
View File
@@ -1,5 +1,5 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import {
FetchHttpClient,
HttpClient,
+1 -1
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
+2
View File
@@ -42,7 +42,9 @@ import type { ACPConfig } from "./types"
import { ACPRuntime } from "./runtime"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo"
import { Result, Schema } from "effect"
+2 -2
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
@@ -69,7 +69,7 @@ export interface Interface {
whenToUse: string
systemPrompt: string
},
Provider.DefaultModelError
Provider.ModelNotFoundError
>
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
+3 -30
View File
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Cause, Effect } from "effect"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
@@ -80,21 +80,7 @@ const run = Effect.fn("Cli.debug.agent.body")(function* (
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model =
agent.model ??
(yield* provider.defaultModel().pipe(
Effect.matchCauseEffect({
onSuccess: Effect.succeed,
onFailure: (cause) => {
const error = Cause.squash(cause) as Provider.DefaultModelError
if (error instanceof Provider.ModelNotFoundError) {
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
}
if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`)
return fail("No providers found")
},
}),
))
const model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({ ...model, agent })
})
@@ -147,20 +133,7 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel().pipe(
Effect.matchCauseEffect({
onSuccess: Effect.succeed,
onFailure: (cause) => {
const error = Cause.squash(cause) as Provider.DefaultModelError
if (error instanceof Provider.ModelNotFoundError) {
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
}
if (error instanceof Provider.NoModelsError)
return fail(`No models found for provider ${error.providerID}`)
return fail("No providers found")
},
}),
)
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
+1
View File
@@ -12,6 +12,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config"
import { ConfigMCP } from "../../config/mcp"
import { InstanceRef } from "@/effect/instance-ref"
import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "path"
import { Global } from "@opencode-ai/core/global"
@@ -34,10 +34,10 @@ const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
export const HINT_BREAKPOINTS = {
const HINT_BREAKPOINTS = {
send: 50,
newline: 66,
history: 80,
@@ -96,8 +96,6 @@ type RunFooterViewProps = {
onSubagentSelect?: (sessionID: string | undefined) => void
}
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
@@ -27,7 +27,7 @@ function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.footer.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
return undefined
}
@@ -49,7 +49,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
return Boolean(current && next && current === next)
}
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"
+1 -23
View File
@@ -25,7 +25,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -279,7 +279,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const project = useProject()
const exit = useExit()
const promptRef = usePromptRef()
const routes: RouteMap = new Map()
@@ -448,13 +447,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
)
const connected = useConnected()
const currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current()
if (!workspaceID) return
const workspace = project.workspace.get(workspaceID)
if (workspace?.type !== "worktree" || !workspace.directory) return
return workspace
})
const appCommands = createMemo(() =>
[
{
@@ -491,20 +483,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
name: "workspace.copy_path",
title: "Copy worktree path",
category: "Workspace",
enabled: () => currentWorktreeWorkspace() !== undefined,
run: async () => {
const workspace = currentWorktreeWorkspace()
if (!workspace?.directory) return
await Clipboard.copy(workspace.directory)
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
.catch(toast.error)
dialog.clear()
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -5,8 +5,6 @@ import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
@@ -49,7 +49,7 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; ti
return { recent, hasMore: recent.length < workspaces.length }
}
export function warpReminderText(dir: string) {
function warpReminderText(dir: string) {
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
@@ -169,7 +169,7 @@ export async function confirmWorkspaceFileChanges(input: {
return fileChangeChoice === "yes"
}
export function DialogWorkspaceSelect(props: {
function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
@@ -878,8 +878,3 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
)
}
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}
@@ -1438,10 +1438,7 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const agent =
status().type !== "idle"
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current()
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
return {
frames: createFrames({
@@ -62,16 +62,14 @@ export const Definitions = {
diff_close: keybind("escape,q", "Close diff viewer"),
diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
diff_expand: keybind("right", "Expand diff viewer item"),
diff_expand_all: keybind("E", "Expand all diff viewer folders"),
diff_collapse: keybind("left", "Collapse diff viewer item"),
diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
diff_next_file: keybind("n", "Jump to next diff file"),
diff_previous_file: keybind("p", "Jump to previous diff file"),
diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
diff_single_patch: keybind("s", "Toggle single patch view"),
diff_switch_source: keybind("d", "Switch diff viewer source"),
diff_switch_diff: keybind("d", "Switch diff viewer source"),
diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
diff_help: keybind("?", "Show more diff viewer shortcuts"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
@@ -261,16 +259,14 @@ export const CommandMap = {
diff_close: "diff.close",
diff_toggle: "diff.toggle",
diff_expand: "diff.expand",
diff_expand_all: "diff.expand_all",
diff_collapse: "diff.collapse",
diff_switch_focus: "diff.switch_focus",
diff_next_file: "diff.next_file",
diff_previous_file: "diff.previous_file",
diff_toggle_file_tree: "diff.toggle_file_tree",
diff_single_patch: "diff.single_patch",
diff_switch_source: "diff.switch_source",
diff_switch_diff: "diff.switch_diff",
diff_toggle_view: "diff.toggle_view",
diff_help: "diff.help",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
@@ -15,7 +15,7 @@ import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
export function parseModel(model: string) {
function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
@@ -2,18 +2,18 @@ import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type HomeRoute = {
type: "home"
prompt?: PromptInfo
}
export type SessionRoute = {
type SessionRoute = {
type: "session"
sessionID: string
prompt?: PromptInfo
}
export type PluginRoute = {
type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
@@ -44,8 +44,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
},
})
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
@@ -49,7 +49,6 @@ type Theme = TuiThemeCurrent & {
_hasSelectedListItemText: boolean
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@@ -519,7 +518,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
@@ -719,18 +718,16 @@ export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
export function generateSubtleSyntax(theme: Theme) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
if (rule.style.foreground) {
const fg = rule.style.foreground
return {
...rule,
style: {
...rule.style,
...override,
foreground: RGBA.fromInts(
Math.round(fg.r * 255),
Math.round(fg.g * 255),
@@ -5,7 +5,6 @@
export type FileTreeItem = {
readonly file: string
readonly status?: "added" | "deleted" | "modified"
}
export type FileTreeNode = {
@@ -126,21 +125,6 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
}
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (row?.kind !== "directory") return selected
const child = rows[index + 1]
return child && child.depth > row.depth ? child.id : selected
}
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (!row || row.depth === 0) return selected
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[],
selected: number | undefined,
@@ -157,39 +141,6 @@ export function moveFileTreeSelectionToFile(
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
}
export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
return showFileTree && fileCount > 0
}
export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
if (fileIndexes.length === 0) return undefined
const index = current === undefined ? -1 : fileIndexes.indexOf(current)
if (index === -1) return fileIndexes[0]
return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
}
export function allExpandedFileTreeDirectories(tree: FileTree) {
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
}
@@ -222,11 +173,3 @@ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<Fil
else nodes[input.parent]!.children.push(id)
return id
}
function fileTreeParentDirectories(tree: FileTree, id: number) {
const result = new Set<number>()
for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
result.add(parent)
}
return result
}
@@ -1,37 +1,31 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "@/util/locale"
import { tint } from "@tui/context/theme"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
const FILE_TREE_STATUS_WIDTH = 2
const FILE_TREE_WIDTH = 32
const FILE_TREE_HORIZONTAL_PADDING = 2
export type DiffViewerFileTreeTheme = {
readonly background: RGBA
type DiffViewerFileTreeTheme = {
readonly background: ColorInput
readonly backgroundPanel: ColorInput
readonly backgroundElement: ColorInput
readonly primary: ColorInput
readonly secondary: ColorInput
readonly selectedListItemText: ColorInput
readonly text: RGBA
readonly textMuted: RGBA
readonly text: ColorInput
readonly textMuted: ColorInput
readonly error: ColorInput
}
export type DiffViewerFileTreeProps = {
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly theme: DiffViewerFileTreeTheme
readonly focused?: boolean
readonly highlightedNode?: number
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
@@ -49,12 +43,21 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
return (
<Panel border="both" width={props.width}>
<box
width={FILE_TREE_WIDTH}
flexShrink={0}
backgroundColor={props.theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
minHeight={0}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
@@ -67,25 +70,29 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row, index) => {
{(row) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const prefix = () =>
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
Locale.truncate(
row.name,
Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
)
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? props.theme.primary : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
<box flexDirection="row" width="100%">
<text
fg={
highlighted()
? props.theme.background
: row.kind === "directory"
? props.theme.textMuted
: props.theme.text
}
bg={highlighted() ? props.theme.primary : undefined}
wrapMode="none"
flexShrink={0}
>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
@@ -93,24 +100,16 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
fg={
highlighted()
? props.theme.background
: selected()
? props.theme.primary
: reviewed() || row.kind === "directory"
? props.theme.textMuted
: props.theme.text
: row.kind === "directory"
? props.theme.textMuted
: props.theme.text
}
bg={highlighted() ? props.theme.primary : undefined}
wrapMode="none"
>
{name()}
</text>
</box>
<text
fg={highlighted() ? props.theme.background : props.theme.textMuted}
wrapMode="none"
flexShrink={0}
>
{status()}
</text>
</box>
)
}}
@@ -118,7 +117,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match>
</Switch>
</scrollbox>
</Panel>
</box>
)
}
@@ -132,31 +131,3 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
scroll.scrollTo(index - scroll.viewport.height + 1)
}
}
function fileTreeRowPrefix(
rows: readonly FileTreeRow[],
index: number,
row: FileTreeRow,
expandedNodes: ReadonlySet<number> | undefined,
) {
const indentation = Array.from({ length: row.depth }, (_, depth) => {
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
return hasLaterSibling(rows, index, depth) ? "│ " : " "
}).join("")
const topRoot = index === 0 && row.depth === 0
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
return `${indentation}${branch}${marker}`
}
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
}
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
}
@@ -1,103 +0,0 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
}
function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
const [local, boxProps] = splitProps(props, ["axis", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
</PanelGroupContext.Provider>
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
const group = usePanelGroup()
const { theme } = useTheme()
const [local, boxProps] = splitProps(props, ["border"])
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: theme.border,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
)
}
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
}
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
const { theme } = useTheme()
const color = () => props.color ?? theme.border
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
if (axis() === "y") {
return (
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
return (
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "├" : "┤"
if (edge === "edge-in") return "┴"
return "┬"
}
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "┬" : "┴"
if (edge === "edge-in") return "┤"
return "├"
}
@@ -1,44 +1,29 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { useBindings, useCommandShortcut } from "@tui/keymap"
import { useTheme } from "@tui/context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "@tui/ui/dialog-select"
import {
allExpandedFileTreeDirectories,
buildFileTree,
fileTreeFileSelection,
type FileTreeRow,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
moveFileTreeSelectionToFile,
setFileTreeDirectoryExpanded,
showDiffViewerFileTree,
singlePatchFileIndex,
toggleFileTreeDirectory,
} from "./diff-viewer-file-tree-utils"
const ROUTE = "diff"
const MIN_SPLIT_WIDTH = 100
const FILE_TREE_WIDTH = 32
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
const WORKING_TREE_DIFF_CONTEXT_LINES = 12
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
const KV_VIEW = "diff_viewer_view"
type DiffMode = "git" | "last-turn"
type DiffViewerFocus = "patches" | "files"
type DiffView = "split" | "unified"
type DiffFile = {
readonly file: string
@@ -70,22 +55,13 @@ function filetype(input?: string) {
return language
}
function storedView(value: unknown): DiffView | undefined {
if (value === "split" || value === "unified") return value
}
function DiffViewer(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const themeState = useTheme()
const theme = () => props.api.theme.current
const params = () =>
("params" in props.api.route.current ? props.api.route.current.params : undefined) as
| {
mode?: DiffMode
sessionID?: string
messageID?: string
returnRoute?: TuiRouteCurrent
}
| { mode?: DiffMode; sessionID?: string; messageID?: string }
| undefined
const mode = () => params()?.mode ?? "git"
const diffInput = createMemo(() => ({
@@ -104,61 +80,44 @@ function DiffViewer(props: { api: TuiPluginApi }) {
return normalizeDiffs(result.data ?? [])
}
const result = await props.api.client.vcs.diff(
{ mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
{ throwOnError: true },
)
const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
return normalizeDiffs(result.data ?? [])
})
const files = createMemo(() => diff() ?? [])
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
)
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
const [showFileTree, setShowFileTree] = createSignal(true)
const [singlePatch, setSinglePatch] = createSignal(false)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const defaultView = createMemo(() => {
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
const [viewOverride, setViewOverride] = createSignal<"split" | "unified">()
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
const fileTree = createMemo(() => buildFileTree(files()))
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
const nextFileShortcut = useCommandShortcut("diff.next_file")
const previousFileShortcut = useCommandShortcut("diff.previous_file")
const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree")
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
const switchSourceShortcut = useCommandShortcut("diff.switch_source")
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
const helpShortcut = useCommandShortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
onCleanup(() => props.api.ui.dialog.clear())
createEffect(() => {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
setHighlightedFileNode(undefined)
setLastHighlightedFileNode(undefined)
setActivePatchFileIndex(undefined)
setSelectedFileIndex(undefined)
setReviewedFileNames(new Set<string>())
})
const ensureHighlightedFileNode = () => {
@@ -186,137 +145,86 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
if (!scroll) return
scroll.scrollBy(patchNode.y - scroll.viewport.y)
requestAnimationFrame(() => {
if (!scroll) return
const scrollDelta = patchNode.y - scroll.viewport.y
const contentY = scroll.scrollTop + scrollDelta
const offset = contentY === 0 ? 0 : 1
scroll.scrollBy(scrollDelta + offset)
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
})
}
const revealFileTreeFile = (fileIndex: number) => {
const selection = fileTreeFileSelection(fileTree(), fileIndex)
if (!selection) return
const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return
setExpandedFileNodes((expanded) => {
const next = new Set(expanded)
selection.expandedNodes.forEach((node) => next.add(node))
for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
next.add(parent)
}
return next
})
setHighlighted(selection.highlightedNode)
}
const selectPatchFile = (fileIndex: number) => {
revealFileTreeFile(fileIndex)
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
setHighlighted(node.id)
}
const scrollToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
selectPatchFile(fileIndex)
setActivePatchFileIndex(fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
}
const jumpToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
revealFileTreeFile(fileIndex)
scrollToFileIndex(fileIndex)
}
const currentPatchFileIndex = () => {
if (!scroll) return undefined
const viewportContentY = scroll.scrollTop + 1
const entries = patchFileIndexes()
.map((fileIndex) => ({
fileIndex,
node: patchNodeByFileIndex.get(fileIndex),
}))
const entries = files()
.map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
.map((entry) => ({
...entry,
contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y,
}))
.sort((left, right) => left.contentY - right.contentY)
return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
.sort((left, right) => left.node.y - right.node.y)
return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
}
const jumpRelativePatchFile = (offset: number) => {
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
if (singlePatch()) {
if (next === undefined) return
selectPatchFile(next)
scrollSinglePatchToTop()
const current = focus() === "files" ? highlightedFileNode() : undefined
const nextFromSelection =
current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset)
if (nextFromSelection !== undefined) {
jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex)
return
}
scrollToFileIndex(next)
const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
scrollToFileIndex(
fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
)
}
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
const visiblePatchFiles = createMemo(() => {
if (!singlePatch()) {
return patchFileIndexes().flatMap((fileIndex) => {
const file = files()[fileIndex]
return file ? [{ file, fileIndex }] : []
})
}
const fileIndex = singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
const file = fileIndex === undefined ? undefined : files()[fileIndex]
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
})
const ensureHighlightedPatchFile = () => {
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
if (activePatchFileIndex() !== undefined) return
const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
}
const scrollToHighlightedPatchFile = () => {
const fileIndex = activePatchFileIndex()
if (fileIndex === undefined) return
selectPatchFile(fileIndex)
}
const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
setPendingPatchScrollFileIndex(fileIndex)
requestAnimationFrame(() => {
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
requestAnimationFrame(() => {
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode)
setPendingPatchScrollFileIndex(undefined)
})
})
}
const scrollSinglePatchToTop = () => {
requestAnimationFrame(() => {
scroll?.scrollTo(0)
requestAnimationFrame(() => scroll?.scrollTo(0))
})
}
const measurePatchFiller = () => {
requestAnimationFrame(() => {
if (!scroll) return
const entries = visiblePatchFiles()
.map((entry) => patchNodeByFileIndex.get(entry.fileIndex))
.filter((node): node is BoxRenderable => Boolean(node))
if (entries.length === 0) {
setPatchFillerHeight(0)
return
}
const contentHeight = Math.max(
...entries.map((node) => scroll!.scrollTop + node.y - scroll!.viewport.y + node.height),
)
setPatchFillerHeight(Math.max(0, scroll.viewport.height - contentHeight))
})
}
const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
patchNodeByFileIndex.set(fileIndex, element)
measurePatchFiller()
if (pendingPatchScrollFileIndex() !== fileIndex) return
requestAnimationFrame(() => {
scrollPatchNodeToTop(element)
@@ -327,13 +235,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})
}
createEffect(() => {
visiblePatchFiles()
dimensions()
view()
measurePatchFiller()
})
const toggleSelectedFileTreeRow = () => {
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
if (highlighted?.fileIndex !== undefined) {
@@ -343,44 +244,13 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
}
const clickFileTreeRow = (row: FileTreeRow) => {
setFocus("files")
setHighlighted(row.id)
if (row.fileIndex !== undefined) {
jumpToFileIndex(row.fileIndex)
return
}
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id))
}
const toggleSelectedFileReviewed = () => {
const fileIndex =
focus() === "files"
? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
: (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
if (!file) return
setReviewedFileNames((reviewed) => {
const next = new Set(reviewed)
if (next.has(file)) next.delete(file)
else next.add(file)
return next
})
}
const commands = [
{
name: "diff.close",
title: "Close diff viewer",
category: "VCS",
run() {
const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear()
props.api.route.navigate(
returnRoute?.name ?? "home",
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
)
props.api.route.navigate("home")
},
},
{
@@ -456,11 +326,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS",
run: focusRunner({
files() {
const highlighted = highlightedFileNode()
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
return
}
setExpandedFileNodes((expanded) =>
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
)
@@ -468,29 +333,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
patches() {},
}),
},
{
name: "diff.expand_all",
title: "Expand all diff viewer folders",
category: "VCS",
run: focusRunner({
files() {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
},
patches() {},
}),
},
{
name: "diff.collapse",
title: "Collapse diff viewer item",
category: "VCS",
run: focusRunner({
files() {
const highlighted = highlightedFileNode()
const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
return
}
setExpandedFileNodes((expanded) =>
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
)
@@ -514,14 +362,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
jumpRelativePatchFile(-1)
},
},
{
name: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
category: "VCS",
run() {
toggleSelectedFileReviewed()
},
},
{
name: "diff.switch_focus",
title: "Switch diff viewer focus",
@@ -540,10 +380,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle diff viewer file tree",
category: "VCS",
run() {
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
setFileTreeEnabled(next)
props.api.kv.set(KV_SHOW_FILE_TREE, next)
setShowFileTree((value) => {
if (value) setFocus("patches")
return !value
})
},
},
{
@@ -551,29 +391,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle single patch view",
category: "VCS",
run() {
if (!singlePatch()) {
ensureHighlightedPatchFile()
setSinglePatch(true)
props.api.kv.set(KV_SINGLE_PATCH, true)
scrollSinglePatchToTop()
return
}
const fileIndex =
visiblePatchFiles()[0]?.fileIndex ??
singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
if (fileIndex !== undefined) selectPatchFile(fileIndex)
setSinglePatch(false)
props.api.kv.set(KV_SINGLE_PATCH, false)
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
setSinglePatch((value) => {
const next = !value
if (next) ensureHighlightedPatchFile()
else scrollToHighlightedPatchFile()
return next
})
},
},
{
name: "diff.switch_source",
name: "diff.switch_diff",
title: "Switch diff viewer source",
category: "VCS",
run() {
@@ -586,17 +413,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS",
run() {
if (!splitAvailable()) return
const next = view() === "split" ? "unified" : "split"
setViewOverride(next)
props.api.kv.set(KV_VIEW, next)
},
},
{
name: "diff.help",
title: "Show more diff viewer shortcuts",
category: "VCS",
run() {
openHelpDialog()
setViewOverride(view() === "split" ? "unified" : "split")
},
},
]
@@ -617,7 +434,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => (
<DialogSelect
title="Switch source"
title="Switch diff"
skipFilter={true}
renderFilter={false}
current={mode()}
@@ -629,7 +446,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
mode: option.value,
sessionID: params()?.sessionID,
messageID: params()?.messageID,
returnRoute: params()?.returnRoute,
})
},
}))}
@@ -637,11 +453,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
))
}
const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
props.api.ui.dialog.setSize("large")
}
useBindings(() => ({
commands,
bindings: [
@@ -649,7 +460,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
...props.api.tuiConfig.keybinds.gather(
"diff",
commands.map((command) => command.name),
@@ -658,57 +468,67 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}))
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<PanelGroup axis="y" width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<text fg={theme().text}>Diff </text>
<box
position="absolute"
zIndex={2500}
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme().background}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
gap={1}
>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={1}>
<text fg={theme().text}>Diff</text>
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
<box flexGrow={1} />
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Panel>
</box>
</box>
<box flexGrow={1} minHeight={0}>
<Switch>
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>Loading diff...</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No diff!</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().error}>Failed to load diff</text>
</box>
</Match>
<Match when={!diff.loading}>
<PanelGroup axis="x">
<Show when={showFileTree()}>
<DiffViewerFileTree
files={files()}
loading={diff.loading}
error={diff.error}
theme={theme()}
focused={focus() === "files"}
width={FILE_TREE_WIDTH}
highlightedNode={highlightedFileNode()}
selectedFileIndex={selectedFileIndex()}
reviewedFileNames={reviewedFileNames()}
expandedNodes={expandedFileNodes()}
onRowClick={clickFileTreeRow}
/>
</Show>
<Switch>
<Match when={diff.loading}>
<box flexGrow={1} alignItems="center" justifyContent="center">
<text fg={theme().textMuted}>Loading diff...</text>
</box>
</Match>
<Match when={!diff.loading}>
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
<Show when={showFileTree()}>
<DiffViewerFileTree
files={files()}
loading={diff.loading}
error={diff.error}
theme={theme()}
focused={focus() === "files"}
highlightedNode={highlightedFileNode()}
expandedNodes={expandedFileNodes()}
/>
</Show>
<Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
<box
flexGrow={1}
minWidth={0}
backgroundColor={theme().background}
paddingLeft={0}
paddingRight={2}
gap={1}
>
<Switch>
<Match when={diff.error}>
<box paddingTop={1}>
<text fg={theme().error}>Failed to load diff</text>
</box>
</Match>
<Match when={files().length === 0}>
<box paddingTop={1}>
<text fg={theme().textMuted}>No diff to show</text>
</box>
</Match>
<Match when={files().length > 0}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
@@ -717,209 +537,117 @@ function DiffViewer(props: { api: TuiPluginApi }) {
horizontalScrollbarOptions={{ visible: false }}
>
<For each={visiblePatchFiles()}>
{(entry, index) => {
const reviewed = () => reviewedFileNames().has(entry.file.file)
return (
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
{index() !== 0 ? <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> : null}
<box
flexDirection="row"
gap={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={theme().border}
>
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={theme().border}>
<diff
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? theme().textMuted : theme().text}
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
addedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
}
removedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
}
/>
</box>
)}
</Show>
{(entry) => (
<box
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
marginBottom={1}
backgroundColor={theme().backgroundPanel}
>
<box
flexDirection="row"
gap={2}
flexShrink={0}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme().backgroundPanel}
>
<text fg={theme().text}>{entry.file.file}</text>
<text fg={theme().diffAdded}>+{entry.file.additions}</text>
<text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
</box>
)
}}
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<diff
diff={patch()}
view={view()}
filetype={filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme().text}
addedBg={theme().diffAddedBg}
removedBg={theme().diffRemovedBg}
contextBg={theme().diffContextBg}
addedSignColor={theme().diffHighlightAdded}
removedSignColor={theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
lineNumberBg={theme().diffContextBg}
addedLineNumberBg={theme().diffAddedLineNumberBg}
removedLineNumberBg={theme().diffRemovedLineNumberBg}
/>
)}
</Show>
</box>
)}
</For>
<Show when={patchFillerHeight() > 0}>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
</Panel>
</PanelGroup>
</Match>
</Switch>
</box>
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
</text>
)}
</Show>
<Show when={markReviewedShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
</text>
)}
</Show>
</Panel>
</PanelGroup>
</box>
)
}
function DiffViewerHelpDialog() {
const { theme } = useTheme()
const rows = [
{
shortcut: () => "q",
action: "Close viewer",
description: "Quit the diff viewer",
},
{
shortcut: useCommandShortcut("diff.switch_focus"),
action: "Focus file tree",
description: "Move keyboard focus between the file tree and patch pane",
},
{
shortcut: useCommandShortcut("diff.next_file"),
action: "Next file",
description: "Select the next changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.previous_file"),
action: "Previous file",
description: "Select the previous changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.toggle_file_tree"),
action: "Toggle file tree",
description: "Show or hide the file tree sidebar",
},
{
shortcut: useCommandShortcut("diff.single_patch"),
action: "Toggle patches",
description: "Switch between one selected patch and all patches",
},
{
shortcut: useCommandShortcut("diff.switch_source"),
action: "Switch source",
description: "Choose working tree or last-turn changes",
},
{
shortcut: useCommandShortcut("diff.toggle_view"),
action: "Toggle view",
description: "Switch between split and unified diff layout",
},
{
shortcut: useCommandShortcut("diff.expand_all"),
action: "Expand all folders",
description: "Open every folder in the file tree",
},
{
shortcut: useCommandShortcut("diff.mark_reviewed"),
action: "Mark reviewed",
description: "Toggle reviewed state for the selected file",
},
]
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Diff shortcuts
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box flexDirection="row">
<text fg={theme.textMuted} width={5} wrapMode="none">
Key
</text>
<text fg={theme.textMuted} width={22} wrapMode="none">
Action
</text>
<text fg={theme.textMuted}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={theme.text} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={theme.text} width={22} wrapMode="none">
{row.action}
</text>
<text fg={theme.textMuted}>{row.description}</text>
</Match>
</Switch>
</box>
</box>
)}
</For>
</Match>
</Switch>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
</text>
)}
</Show>
<Show when={toggleFileTreeShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
</text>
)}
</Show>
<Show when={singlePatchShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
</text>
)}
</Show>
<Show when={switchDiffShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
</text>
)}
</Show>
<Show when={toggleViewShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
</text>
)}
</Show>
</box>
</box>
)
}
@@ -944,7 +672,6 @@ const tui: TuiPlugin = async (api) => {
api.route.navigate(ROUTE, {
mode: "git",
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
returnRoute: api.route.current,
})
api.ui.dialog.clear()
},
@@ -24,6 +24,7 @@ import type {
SessionMessageCompaction,
SessionMessageModelSwitched,
SessionMessageShell,
SessionMessageSynthetic,
SessionMessageUser,
ToolFileContent,
ToolTextContent,
+2 -2
View File
@@ -11,7 +11,7 @@ import type { TuiConfig } from "./config/tui"
import { useTuiConfig } from "./context/tui-config"
import { TuiKeybind } from "./config/keybind"
export const LEADER_TOKEN = "leader"
const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
@@ -38,7 +38,7 @@ function isVisiblePaletteCommand(command: Command) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
function createOpencodeModeStack(keymap: OpenTuiKeymap) {
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
const offFields = keymap.registerLayerFields({
@@ -20,7 +20,9 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
export function internalTuiPlugins(
flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">,
): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -33,7 +35,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
Notifications,
PluginManager,
WhichKey,
DiffViewer,
...(flags.diffViewer ? [DiffViewer] : []),
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
@@ -5,7 +5,7 @@ import { isRecord } from "@/util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type Slot = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
@@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@@ -53,6 +53,7 @@ import type { SkillTool } from "@/tool/skill"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import type { DialogContext } from "@tui/ui/dialog"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
@@ -1497,7 +1498,7 @@ const PART_MAPPING = {
}
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme } = useTheme()
const { theme, subtleSyntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -1519,8 +1520,6 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
// blocks. Surface the title both while streaming and after settling so the
// collapsed line carries real signal, not just a duration.
const title = createMemo(() => reasoningTitle(content()))
// Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
const toggle = () => {
if (!inMinimal()) return
@@ -1537,9 +1536,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
// the syntax override above removes only the italic attribute from that emphasis token.
syntaxStyle={subtleSyntax()}
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
@@ -1567,7 +1564,7 @@ function CollapsedReasoningText(props: { title: string | null; duration: number
return (
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning }}>
<span style={{ fg: theme.warning, italic: true }}>
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
</span>
</text>

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