Compare commits

..
Author SHA1 Message Date
Aiden Cline 0a480abc15 fix(app): fail closed on v2 channel gates 2026-05-22 18:07:05 -05:00
opencode-agent[bot] 5ee0238c4a chore: update nix node_modules hashes 2026-05-22 21:33:19 +00:00
opencode 871d38cbd7 sync release versions for v1.15.9 2026-05-22 21:18:49 +00:00
opencode-agent[bot] 1a329e4e67 chore: generate 2026-05-22 19:31:59 +00:00
James LongandGitHub bfb2d8dc76 fix(tui): when diff viewer closes always return to last route (#28903) 2026-05-22 15:30:31 -04:00
James LongandGitHub 8f7a6c4a00 fix(tui): refine diff view keyboard shortcuts (#28896) 2026-05-22 14:40:14 -04:00
opencode-agent[bot] 1857c73565 chore: generate 2026-05-22 18:25:01 +00:00
Shoubhit DashandGitHub 5f42351159 fix(provider): type default model failures (#28881) 2026-05-22 23:53:23 +05:30
Shoubhit DashandGitHub 968aaa3cfe fix(pty): expose missing session errors (#28884) 2026-05-22 23:53:03 +05:30
opencode-agent[bot] b8266e5819 chore: generate 2026-05-22 18:22:48 +00:00
James LongandGitHub ba746e36d8 fix(tui): empty states, context, and minor improvements to diff viewer (#28878) 2026-05-22 14:21:22 -04:00
opencode-agent[bot] d5068ba28e chore: generate 2026-05-22 17:50:23 +00:00
Shoubhit DashandGitHub 7265c46af6 fix(skill): type expected skill failures (#28885) 2026-05-22 23:18:52 +05:30
Shoubhit DashandGitHub 536ee857c6 fix(installation): type upgrade failures (#28883) 2026-05-22 23:18:17 +05:30
opencode-agent[bot] dda69d77e8 chore: generate 2026-05-22 17:47:18 +00:00
Shoubhit DashandGitHub 0e14404e5f fix(sync): map workspace warp not found (#28882) 2026-05-22 23:15:53 +05:30
opencode-agent[bot] 05f51bfe45 chore: generate 2026-05-22 17:44:34 +00:00
Shoubhit DashandGitHub aee552c043 fix(repository): type expected reference failures (#28880) 2026-05-22 23:13:14 +05:30
opencode-agent[bot] 4f6eaf859b chore: generate 2026-05-22 16:39:58 +00:00
Kit LangtonandGitHub d0cb58782f fix(llm): surface code, type, and nested fields on provider stream errors (#28757) 2026-05-22 12:37:55 -04:00
opencode-agent[bot] a3430db73a chore: generate 2026-05-22 16:37:46 +00:00
Shoubhit DashandGitHub 3e1972fd92 fix(httpapi): return project not found errors (#28856) 2026-05-22 22:06:20 +05:30
opencode-agent[bot] b368e5adbe chore: generate 2026-05-22 16:31:30 +00:00
James LongandGitHub 69e4f52272 fix(tui): interaction improvements to diff viewer (#28851) 2026-05-22 12:30:10 -04:00
opencode-agent[bot] 8a55920531 chore: generate 2026-05-22 16:25:15 +00:00
Kit LangtonandGitHub 9db90a0b76 fix(llm): emit structured image blocks for tool-result media in Anthropic Messages (#28755) 2026-05-22 12:23:41 -04:00
Kit LangtonandGitHub 700d012025 fix(llm): emit structured input_image content for tool-result media in OpenAI Responses (#28754) 2026-05-22 12:23:23 -04:00
Shoubhit DashandGitHub 59e486a917 fix(tui): restore question prompt key handling (#28835) 2026-05-22 21:47:13 +05:30
Aiden ClineandGitHub 8596967415 ci: "fix: exempt team members from compliance cleanup" (#28865) 2026-05-22 11:14:52 -05:00
opencode-agent[bot] 00038027c8 chore: generate 2026-05-22 15:31:46 +00:00
Shoubhit DashandGitHub 5cf597d583 fix(httpapi): return pty error bodies (#28838) 2026-05-22 21:00:20 +05:30
opencode-agent[bot] d92b8d8009 chore: generate 2026-05-22 13:52:40 +00:00
Dax Raad 854c535536 fix(tui): enable diff viewer by default 2026-05-22 09:50:30 -04:00
OpeOginniandGitHub 3cf955e9ad fix(desktop): correct user code extraction when URL contains colons (#28837) 2026-05-22 21:16:33 +08:00
opencode-agent[bot] 63f3e84792 chore: generate 2026-05-22 12:17:49 +00:00
Shoubhit DashandGitHub 0beb4de3e8 fix(httpapi): return mcp server not found errors (#28817) 2026-05-22 17:46:30 +05:30
Shoubhit DashandGitHub 51da3483a9 feat(tui): copy worktree path from palette (#28823) 2026-05-22 17:28:51 +05:30
opencode-agent[bot] 060fbc9ce7 chore: generate 2026-05-22 11:42:14 +00:00
7a769dab3a fix(mcp): include scope in clientMetadata and add callbackPort option (#28810)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-05-22 17:10:38 +05:30
opencode-agent[bot] 2663ecd39d chore: generate 2026-05-22 11:00:29 +00:00
Shoubhit DashandGitHub 4ce247eaba fix(httpapi): return request not found errors (#28693) 2026-05-22 16:29:12 +05:30
Brendan AllanandGitHub 76d9c2cd76 fix(app): debounce vcs refresh in session page (#28784) 2026-05-22 06:47:05 +00:00
Brendan AllanandGitHub f6101aef8a refactor(app): consolidate sdk and sync contexts (#28782) 2026-05-22 06:40:56 +00:00
Brendan AllanandGitHub f3874ec2f9 refactor(app): move tab navigation to titlebar and conditionally register project shortcuts (#28773) 2026-05-22 13:54:25 +08:00
Brendan AllanandGitHub 6466fcfdea refactor(app): simplify session routing and tab close handling (#28767) 2026-05-22 12:48:09 +08:00
Brendan AllanandGitHub 1f0390cfbb app: wrap provider data in Map to avoid store (#28765) 2026-05-22 12:23:16 +08:00
opencode-agent[bot] 9f06accfb4 chore: generate 2026-05-22 04:04:41 +00:00
7a9724496b fix(vertex): Vertex (Antropic) provider: use .rep.googleapis.com for continental multi-region endpoints (us, eu) (#28347)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-21 23:03:24 -05:00
Aiden ClineandGitHub 3e931152d1 tweak(tui): remove italics from thinking labels (#28737) 2026-05-21 22:22:51 -05:00
Frank ad1d14775d sync 2026-05-21 19:11:33 -04:00
Frank fcf4dff2ce sync 2026-05-21 19:09:26 -04:00
Frank 56714327f4 sync 2026-05-21 19:02:24 -04:00
Frank 21f338652d sync 2026-05-21 18:49:52 -04:00
Frank 8115f0c661 sync 2026-05-21 18:43:48 -04:00
opencode-agent[bot] 87d4cb07b0 chore: generate 2026-05-21 22:36:00 +00:00
James LongandGitHub ee008923f3 feat(tui): design revamp of diff viewer (#28728) 2026-05-21 18:34:32 -04:00
Frank bbbef0da1c sync 2026-05-21 18:27:45 -04:00
Aiden ClineandGitHub 1268f8657e fix: exempt team members from compliance cleanup 2026-05-21 16:15:45 -05:00
Frank 39e7ff932d sync 2026-05-21 17:03:54 -04:00
Kit LangtonandGitHub e92c4fb460 chore: drop dead imports across opencode/core/llm (#28720) 2026-05-21 17:03:12 -04:00
4b496066bf fix(opencode): update spinner color logic (#28032)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-21 16:02:08 -05:00
Frank e63dcd30f3 sync 2026-05-21 16:55:12 -04:00
Kit LangtonandGitHub 2935d1819e refactor(server): rename Fence.waitEffect to Fence.wait (#28717) 2026-05-21 20:48:15 +00:00
Kit LangtonandGitHub 86907e2e4a refactor(server): drop dead Fence.wait + redundant casts (#28710) 2026-05-21 16:35:44 -04:00
Frank 31d2d38d7a sync 2026-05-21 16:20:33 -04:00
Frank d21477d55a sync 2026-05-21 16:11:41 -04:00
Frank 80fa6e6c49 sync 2026-05-21 16:02:56 -04:00
Kit LangtonandGitHub b99787e95b refactor(opencode): fetch remote config with http client (#28661) 2026-05-21 15:53:44 -04:00
Frank 562d299a41 sync 2026-05-21 15:46:54 -04:00
opencode-agent[bot] 9ecb04e35b chore: generate 2026-05-21 19:19:25 +00:00
Kit LangtonandGitHub 231689c767 test(config): port env-var config tests to it.instance (#28706) 2026-05-21 19:17:29 +00:00
Frank d70942079a sync 2026-05-21 15:06:10 -04:00
Frank 0cc55c11ac sync 2026-05-21 14:53:56 -04:00
172 changed files with 4245 additions and 3572 deletions
+22 -19
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -44,6 +44,7 @@
"@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",
@@ -84,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +169,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +193,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.7",
"version": "1.15.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +254,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -308,7 +309,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -322,7 +323,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -352,7 +353,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -368,7 +369,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -381,7 +382,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -399,7 +400,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.7",
"version": "1.15.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -537,7 +538,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -575,7 +576,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -590,7 +591,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -625,7 +626,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -674,7 +675,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2177,7 +2178,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.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
"@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=="],
@@ -5837,6 +5838,8 @@
"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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=",
"aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=",
"aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=",
"x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4="
"x86_64-linux": "sha256-pbVW7cOLT76Q7f++xaYYrwuN7eS6FRen80xoaVog3M4=",
"aarch64-linux": "sha256-nk/q4PezhQSf/UHhJuL/188q6e+Gr+PFgrlhEEG5ouo=",
"aarch64-darwin": "sha256-IfIJwgvsonrfjG+btK/5YedzX+MJtf/5bYuLrAmnHvU=",
"x86_64-darwin": "sha256-Cwd9gEymjFn6XAgkkyX4ZmRTAE1JiurxF4VvVws9C/E="
}
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.7",
"version": "1.15.9",
"description": "",
"type": "module",
"exports": {
@@ -42,10 +42,10 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
"@sentry/solid": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
@@ -54,6 +54,7 @@
"@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",
+9 -14
View File
@@ -48,22 +48,17 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
const Session = lazy(() => import("@/pages/session"))
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
</SessionProviders>
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
{ preload: Session.preload },
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -317,7 +312,7 @@ export function AppInterface(props: {
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
@@ -41,9 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() =>
providers.all().find((x) => x.id === props.provider) ??
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
() => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -526,7 +524,7 @@ export function DialogConnectProvider(props: { provider: string }) {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":")[1]?.trim()
return instructions.split(":").pop()?.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.map((p) => p.id)),
existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
})
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={(x) => x?.id}
key={(p) => p.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()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
+2 -1
View File
@@ -1387,7 +1387,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getFilename(value)
}
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_V2_INPUT =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
return (
<div class="relative size-full flex flex-col gap-0">
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
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()))
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
+89 -18
View File
@@ -23,6 +23,8 @@ 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>
@@ -49,7 +51,8 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_V2_TITLEBAR =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -223,19 +226,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const openNewSession = () => {
if (params.dir) {
navigate(`/${params.dir}/session`)
return
}
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) {
navigate("/")
return
}
if (!project) return "/"
navigate(`/${base64Encode(project.worktree)}/session`)
return `/${base64Encode(project.worktree)}/session`
}
type Tab = { dir: string; sessionId: string; href: string }
@@ -302,14 +299,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
if (!tabsStore.some((tab) => tab.href === href)) return
return href
return tabsStore.find((tab) => tab.href === href)
}
const closeCurrentSessionTab = () => {
const href = currentSessionTab()
if (!href) return false
tabsStoreActions.removeTab(href)
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("/")
return true
}
@@ -319,7 +323,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()) return
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
event.preventDefault()
event.stopPropagation()
@@ -327,6 +331,63 @@ 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,
@@ -391,7 +452,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewSession}
as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")}
/>
}
@@ -404,6 +466,15 @@ 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,6 +232,9 @@ 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,
@@ -249,6 +252,58 @@ 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)
},
}
}
+4 -11
View File
@@ -1,12 +1,4 @@
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, 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"
@@ -37,6 +29,7 @@ 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
@@ -46,7 +39,7 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
@@ -121,7 +114,7 @@ function createGlobalSync() {
return pathQuery.data ?? EMPTY
},
get provider() {
const EMPTY = { all: [], connected: [], default: {} }
const EMPTY = { all: new Map(), connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
return providerQuery.data ?? EMPTY
},
@@ -5,7 +5,6 @@ import type {
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Session,
Todo,
@@ -20,6 +19,7 @@ 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: ProviderListResponse
provider: NormalizedProviderListResponse
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: ProviderListResponse
provider: NormalizedProviderListResponse
}
queryClient: QueryClient
}) {
@@ -220,7 +220,6 @@ export async function bootstrapDirectory(input: {
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false }))
}
if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
@@ -327,7 +326,5 @@ export async function bootstrapDirectory(input: {
description: formatServerError(slowErrs[0], input.translate),
})
}
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
@@ -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 { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -17,6 +17,7 @@ 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
@@ -27,7 +28,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
global: {
provider: ProviderListResponse
provider: NormalizedProviderListResponse
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -190,10 +191,9 @@ export function createChildStoreManager(input: {
return !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: [], connected: [], default: {} }
const EMPTY = { all: new Map(), connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
return { state: "", config: "", worktree: "", directory: "", home: "" }
return pathQuery.data
},
status: "loading" as const,
status: "complete" as const,
agent: [],
command: [],
session: [],
@@ -8,7 +8,6 @@ import type {
Part,
Path,
PermissionRequest,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
@@ -16,6 +15,7 @@ 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: ProviderListResponse
provider: NormalizedProviderListResponse
config: Config
path: Path
session: Session[]
+16 -5
View File
@@ -1,4 +1,5 @@
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)
@@ -17,13 +18,23 @@ export function normalizeAgentList(input: unknown): Agent[] {
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
return {
...input,
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
),
),
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const validModel = (model: ModelKey) => {
const provider = providers.all().find((item) => item.id === model.providerID)
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
+2 -40
View File
@@ -1,49 +1,11 @@
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: Accessor<string> }) => {
init: (props: { directory: string }) => {
const globalSDK = useGlobalSDK()
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)
},
}
return globalSDK.createDirSyncContext(props.directory)
},
})
+5 -55
View File
@@ -1,5 +1,4 @@
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"
@@ -10,26 +9,8 @@ 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>
@@ -127,40 +108,9 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID]
}
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))
export const useSync = () => {
const globalSync = useGlobalSync()
const sdk = useSDK()
return globalSync.createDirSyncContext(sdk.directory)
}
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)
},
})
+22 -5
View File
@@ -1,6 +1,7 @@
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 = [
@@ -29,16 +30,32 @@ export function useProviders() {
return {
all: () => providers().all,
default: () => providers().default,
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
popular: () =>
pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
connected: () => {
const connected = new Set(providers().connected)
return providers().all.filter((p) => connected.has(p.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
},
paid: () => {
const connected = new Set(providers().connected)
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
return [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
},
}
}
+3 -5
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 { SyncProvider, useSync } from "@/context/sync"
import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
@@ -81,10 +81,8 @@ export default function Layout(props: ParentProps) {
return (
<Show when={resolved()} keyed>
{(resolved) => (
<SDKProvider directory={() => resolved}>
<SyncProvider>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
</SyncProvider>
<SDKProvider directory={resolved}>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
+59 -182
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createMemo, 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,20 +18,19 @@ 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"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/core/util/path"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_HOME_DESIGN =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -468,10 +467,6 @@ function LegacyHome() {
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
@@ -480,8 +475,6 @@ 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"
@@ -520,185 +513,69 @@ 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-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>
<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>
<Switch>
<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="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={() => setShowProjectsDropdown(!showProjectsDropdown())}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
</Button>
<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>
<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) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</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="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 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>
</div>
<Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
+21 -18
View File
@@ -89,7 +89,8 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_NEW_DESIGN =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@@ -154,7 +155,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_HOME_DESIGN,
autoselect: !initialDirectory && !USE_NEW_DESIGN,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1028,19 +1029,6 @@ 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"),
@@ -1155,6 +1143,21 @@ 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}`,
@@ -1819,7 +1822,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2303,7 +2306,7 @@ export default function Layout(props: ParentProps) {
<div
class="shrink-0 px-3 py-3"
classList={{
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
@@ -2361,7 +2364,7 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_HOME_DESIGN) {
if (USE_NEW_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() ?? ""}
+9 -6
View File
@@ -18,6 +18,7 @@ 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"
@@ -73,7 +74,8 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const USE_NEW_SESSION_DESIGN =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -262,8 +264,9 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const isV2NewSessionPage = () => !USE_NEW_SESSION_DESIGN || !params.id
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
@@ -477,7 +480,7 @@ export default function Page() {
: skipToken,
}
})
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
// avoids suspense
@@ -1733,12 +1736,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,
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
"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(),
@@ -28,6 +28,9 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_NEW_SESSION_DESIGN =
import.meta.env.VITE_OPENCODE_CHANNEL === "dev" || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -55,7 +58,7 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { sessionKey, tabs, view } = useSessionLayout()
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
@@ -207,7 +210,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop()}>
<Show when={isDesktop() && !(USE_NEW_SESSION_DESIGN && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
+6 -5
View File
@@ -5,12 +5,13 @@ import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
const channel = (() => {
const channel = (command) => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
if (command === "serve") return "dev"
return "prod"
}
/**
* @type {import("vite").PluginOption}
@@ -18,7 +19,7 @@ const channel = (() => {
export default [
{
name: "opencode-desktop:config",
config() {
config(_, env) {
return {
resolve: {
alias: {
@@ -26,7 +27,7 @@ export default [
},
},
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel(env.command)),
},
worker: {
format: "es",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.7",
"version": "1.15.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.7",
"version": "1.15.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -298
View File
@@ -4,304 +4,7 @@
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DISCORD_INCIDENT_WEBHOOK_URL": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_BOT_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_CHANNEL_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Teams": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
"plan200": string
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"sixMonths100Coupon": string
"threeMonths100Coupon": string
"twelveMonths100Coupon": string
"type": "sst.sst.Linkable"
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS10": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS11": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS12": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS13": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS14": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS15": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS16": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS17": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS18": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS19": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS20": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS5": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS8": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS9": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
"ZenDataNew": cloudflare.R2Bucket
}
}
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.7",
"version": "1.15.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -298
View File
@@ -4,304 +4,7 @@
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DISCORD_INCIDENT_WEBHOOK_URL": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_BOT_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_CHANNEL_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Teams": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
"plan200": string
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"sixMonths100Coupon": string
"threeMonths100Coupon": string
"twelveMonths100Coupon": string
"type": "sst.sst.Linkable"
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS10": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS11": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS12": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS13": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS14": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS15": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS16": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS17": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS18": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS19": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS20": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS5": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS8": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS9": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
"ZenDataNew": cloudflare.R2Bucket
}
}
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.7",
"version": "1.15.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -5,6 +5,11 @@ export const Resource = new Proxy(
{},
{
get(_target, prop: string) {
if (`SST_RESOURCE_${prop}` in env) {
// @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]
+1 -298
View File
@@ -4,304 +4,7 @@
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DISCORD_INCIDENT_WEBHOOK_URL": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_BOT_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_CHANNEL_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Teams": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
"plan200": string
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"sixMonths100Coupon": string
"threeMonths100Coupon": string
"twelveMonths100Coupon": string
"type": "sst.sst.Linkable"
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS10": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS11": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS12": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS13": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS14": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS15": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS16": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS17": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS18": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS19": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS20": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS5": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS8": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS9": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
"ZenDataNew": cloudflare.R2Bucket
}
}
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.9",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer, Scope } from "effect"
import { Context, Deferred, Effect, Layer } from "effect"
import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
@@ -25,7 +25,8 @@ function resolveLocation(options: Record<string, any>) {
}
function vertexEndpoint(location: string) {
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
if (location === "global") return "aiplatform.googleapis.com"
return `${location}-aiplatform.googleapis.com`
}
function replaceVertexVars(value: string, project: string | undefined, location: string) {
@@ -131,16 +132,25 @@ 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:
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"),
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`,
}
: {}),
})
}),
"aisdk.language": Effect.fn(function* (evt) {
@@ -1,6 +1,5 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"),
@@ -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, provider } from "./provider-helper"
import { it, model } 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 } from "@opencode-ai/core/plugin/provider/google-vertex"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
@@ -109,6 +109,73 @@ 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,6 +160,32 @@ 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(
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.7",
"version": "1.15.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.9",
"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.7",
"version": "1.15.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -298
View File
@@ -4,304 +4,7 @@
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DISCORD_INCIDENT_WEBHOOK_URL": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_BOT_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_CHANNEL_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Teams": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
"plan200": string
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"sixMonths100Coupon": string
"threeMonths100Coupon": string
"twelveMonths100Coupon": string
"type": "sst.sst.Linkable"
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS10": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS11": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS12": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS13": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS14": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS15": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS16": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS17": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS18": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS19": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS20": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS5": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS8": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS9": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
"ZenDataNew": cloudflare.R2Bucket
}
}
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.7"
version = "1.15.9"
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.7/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/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.7/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/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.7/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/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.7/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.7",
"version": "1.15.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -298
View File
@@ -4,304 +4,7 @@
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DISCORD_INCIDENT_WEBHOOK_URL": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_BOT_TOKEN": {
"type": "sst.sst.Secret"
"value": string
}
"DISCORD_SUPPORT_CHANNEL_ID": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"FEISHU_APP_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"HoneycombWebhookSecret": {
"type": "random.index/randomPassword.RandomPassword"
"value": string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Teams": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
"plan200": string
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"sixMonths100Coupon": string
"threeMonths100Coupon": string
"twelveMonths100Coupon": string
"type": "sst.sst.Linkable"
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS10": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS11": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS12": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS13": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS14": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS15": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS16": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS17": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS18": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS19": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS20": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS5": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS8": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS9": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
"ZenDataNew": cloudflare.R2Bucket
}
}
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.9",
"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.7",
"version": "1.15.9",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
-1
View File
@@ -14,7 +14,6 @@ import {
ToolChoice,
ToolDefinition,
type ContentPart,
ToolCallPart,
ToolResultPart,
} from "./schema"
import { make as makeTool, type ToolSchema } from "./tool"
@@ -14,6 +14,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -96,10 +97,18 @@ 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.String,
content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]),
is_error: Schema.optional(Schema.Boolean),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -197,7 +206,13 @@ const AnthropicEvent = Schema.Struct({
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
// `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) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
@@ -298,6 +313,31 @@ 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)
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
@@ -360,7 +400,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
content.push({
type: "tool_result",
tool_use_id: part.id,
content: ProviderShared.toolResultText(part),
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
})
@@ -667,9 +707,18 @@ 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: event.error?.message ?? "Anthropic Messages stream error" })],
[LLMEvent.providerError({ message: providerErrorMessage(event) })],
]
const step = (state: ParserState, event: AnthropicEvent) => {
+67 -4
View File
@@ -14,6 +14,8 @@ 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"
@@ -55,6 +57,16 @@ 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) }),
@@ -69,7 +81,7 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("function_call_output"),
call_id: Schema.String,
output: Schema.String,
output: OpenAIResponsesFunctionCallOutput,
}),
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
@@ -166,6 +178,17 @@ 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),
@@ -178,12 +201,14 @@ 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>
@@ -250,6 +275,27 @@ 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) }]
@@ -298,7 +344,11 @@ 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: ProviderShared.toolResultText(part) })
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
})
}
}
@@ -596,14 +646,27 @@ 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: event.message ?? event.code ?? "OpenAI Responses response failed" })],
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
@@ -1,7 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth } from "../auth"
import { LLMError, TransportReason, type LLMRequest } from "../../schema"
import { LLMError, TransportReason } 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,4 +1,3 @@
import { Stream } from "effect"
import { LLMClient } from "../../src/route"
import type { Tools } from "../../src/tool"
import type { RunOptions } from "../../src/tool-runtime"
@@ -24,6 +24,19 @@ 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* () {
@@ -71,6 +84,87 @@ 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>(
@@ -243,7 +337,29 @@ describe("Anthropic Messages route", () => {
),
)
expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
// 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" }])
}),
)
@@ -5,7 +5,6 @@ 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,6 +87,7 @@ describeRecordedGoldenScenarios([
{ id: "reasoning-continuation", temperature: false },
{ id: "tool-call", temperature: false },
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
@@ -112,7 +113,10 @@ describeRecordedGoldenScenarios([
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [{ id: "tool-loop", temperature: false }],
scenarios: [
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "Gemini 2.5 Flash",
@@ -26,6 +26,19 @@ 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* () {
@@ -248,6 +261,84 @@ 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>(
@@ -786,7 +877,11 @@ describe("OpenAI Responses route", () => {
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
)
expect(response.events).toEqual([{ type: "provider-error", 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" }])
}),
)
@@ -800,6 +895,99 @@ 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,6 +317,47 @@ 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!"),
@@ -359,6 +400,11 @@ 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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.7",
"version": "1.15.9",
"name": "opencode",
"type": "module",
"license": "MIT",
-2
View File
@@ -42,9 +42,7 @@ 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"
+1 -1
View File
@@ -69,7 +69,7 @@ export interface Interface {
whenToUse: string
systemPrompt: string
},
Provider.ModelNotFoundError
Provider.DefaultModelError
>
}
+30 -3
View File
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
@@ -80,7 +80,21 @@ 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())
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")
},
}),
))
return yield* registry.tools({ ...model, agent })
})
@@ -133,7 +147,20 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
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")
},
}),
)
})
const now = Date.now()
const message: MessageV2.Assistant = {
-1
View File
@@ -12,7 +12,6 @@ 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"
+23 -1
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 } from "@tui/context/project"
import { ProjectProvider, useProject } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -279,6 +279,7 @@ 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()
@@ -447,6 +448,13 @@ 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(() =>
[
{
@@ -483,6 +491,20 @@ 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}`,
@@ -1438,7 +1438,10 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const agent =
status().type !== "idle"
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
return {
frames: createFrames({
@@ -62,14 +62,16 @@ 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_diff: keybind("d", "Switch diff viewer source"),
diff_switch_source: 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"),
@@ -259,14 +261,16 @@ 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_diff: "diff.switch_diff",
diff_switch_source: "diff.switch_source",
diff_toggle_view: "diff.toggle_view",
diff_help: "diff.help",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
@@ -49,6 +49,7 @@ 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
@@ -718,16 +719,18 @@ export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function generateSubtleSyntax(theme: Theme) {
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
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,6 +5,7 @@
export type FileTreeItem = {
readonly file: string
readonly status?: "added" | "deleted" | "modified"
}
export type FileTreeNode = {
@@ -125,6 +126,21 @@ 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,
@@ -141,6 +157,39 @@ 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))
}
@@ -173,3 +222,11 @@ 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,31 +1,37 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
import type { ColorInput, RGBA, 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 } from "./diff-viewer-file-tree-utils"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_WIDTH = 32
const FILE_TREE_HORIZONTAL_PADDING = 2
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeTheme = {
readonly background: ColorInput
readonly background: RGBA
readonly backgroundPanel: ColorInput
readonly backgroundElement: ColorInput
readonly primary: ColorInput
readonly secondary: ColorInput
readonly selectedListItemText: ColorInput
readonly text: ColorInput
readonly textMuted: ColorInput
readonly text: RGBA
readonly textMuted: RGBA
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) {
@@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
return (
<box
width={FILE_TREE_WIDTH}
flexShrink={0}
backgroundColor={props.theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
minHeight={0}
>
<Panel border="both" width={props.width}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
@@ -70,29 +67,25 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row) => {
{(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
const prefix = () =>
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(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 name = () =>
Locale.truncate(
row.name,
Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
)
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<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}
>
<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}>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
@@ -100,16 +93,24 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
fg={
highlighted()
? props.theme.background
: row.kind === "directory"
? props.theme.textMuted
: props.theme.text
: selected()
? props.theme.primary
: reviewed() || 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>
)
}}
@@ -117,7 +118,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match>
</Switch>
</scrollbox>
</box>
</Panel>
)
}
@@ -131,3 +132,31 @@ 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)
}
@@ -0,0 +1,103 @@
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,29 +1,44 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes, type BorderSides, type BoxRenderable, type 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, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, 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,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
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
@@ -55,13 +70,22 @@ 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 }
| {
mode?: DiffMode
sessionID?: string
messageID?: string
returnRoute?: TuiRouteCurrent
}
| undefined
const mode = () => params()?.mode ?? "git"
const diffInput = createMemo(() => ({
@@ -80,44 +104,61 @@ function DiffViewer(props: { api: TuiPluginApi }) {
return normalizeDiffs(result.data ?? [])
}
const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
const result = await props.api.client.vcs.diff(
{ mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
{ throwOnError: true },
)
return normalizeDiffs(result.data ?? [])
})
const files = createMemo(() => diff() ?? [])
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [showFileTree, setShowFileTree] = createSignal(true)
const [singlePatch, setSinglePatch] = createSignal(false)
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 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<"split" | "unified">()
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
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 switchDiffShortcut = useCommandShortcut("diff.switch_diff")
const switchSourceShortcut = useCommandShortcut("diff.switch_source")
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 = () => {
@@ -145,86 +186,137 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
if (!scroll) return
scroll.scrollBy(patchNode.y - scroll.viewport.y)
requestAnimationFrame(() => {
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
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)
})
}
const revealFileTreeFile = (fileIndex: number) => {
const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return
const selection = fileTreeFileSelection(fileTree(), fileIndex)
if (!selection) return
setExpandedFileNodes((expanded) => {
const next = new Set(expanded)
for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
next.add(parent)
}
selection.expandedNodes.forEach((node) => next.add(node))
return next
})
setHighlighted(node.id)
setHighlighted(selection.highlightedNode)
}
const selectPatchFile = (fileIndex: number) => {
revealFileTreeFile(fileIndex)
setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
}
const scrollToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return
setActivePatchFileIndex(fileIndex)
selectPatchFile(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 entries = files()
.map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
const viewportContentY = scroll.scrollTop + 1
const entries = patchFileIndexes()
.map((fileIndex) => ({
fileIndex,
node: patchNodeByFileIndex.get(fileIndex),
}))
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
.sort((left, right) => left.node.y - right.node.y)
return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
.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
}
const jumpRelativePatchFile = (offset: number) => {
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)
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
if (singlePatch()) {
if (next === undefined) return
selectPatchFile(next)
scrollSinglePatchToTop()
return
}
const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
scrollToFileIndex(
fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
)
scrollToFileIndex(next)
}
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
const visiblePatchFiles = createMemo(() => {
if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
if (!singlePatch()) {
return patchFileIndexes().flatMap((fileIndex) => {
const file = files()[fileIndex]
return file ? [{ file, fileIndex }] : []
})
}
const fileIndex = singlePatchFileIndex(
selectedFileIndex(),
activePatchFileIndex(),
currentPatchFileIndex(),
firstPatchFileIndex(),
)
const file = fileIndex === undefined ? undefined : files()[fileIndex]
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
})
const ensureHighlightedPatchFile = () => {
if (activePatchFileIndex() !== undefined) return
const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
if (fileIndex === undefined) return
selectPatchFile(fileIndex)
}
const scrollToHighlightedPatchFile = () => {
const fileIndex = activePatchFileIndex()
if (fileIndex === undefined) return
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)
@@ -235,6 +327,13 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})
}
createEffect(() => {
visiblePatchFiles()
dimensions()
view()
measurePatchFiller()
})
const toggleSelectedFileTreeRow = () => {
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
if (highlighted?.fileIndex !== undefined) {
@@ -244,13 +343,44 @@ 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() {
props.api.route.navigate("home")
const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear()
props.api.route.navigate(
returnRoute?.name ?? "home",
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
)
},
},
{
@@ -326,6 +456,11 @@ 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),
)
@@ -333,12 +468,29 @@ 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),
)
@@ -362,6 +514,14 @@ 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",
@@ -380,10 +540,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle diff viewer file tree",
category: "VCS",
run() {
setShowFileTree((value) => {
if (value) setFocus("patches")
return !value
})
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
setFileTreeEnabled(next)
props.api.kv.set(KV_SHOW_FILE_TREE, next)
},
},
{
@@ -391,16 +551,29 @@ function DiffViewer(props: { api: TuiPluginApi }) {
title: "Toggle single patch view",
category: "VCS",
run() {
setSinglePatch((value) => {
const next = !value
if (next) ensureHighlightedPatchFile()
else scrollToHighlightedPatchFile()
return next
})
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)
},
},
{
name: "diff.switch_diff",
name: "diff.switch_source",
title: "Switch diff viewer source",
category: "VCS",
run() {
@@ -413,7 +586,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS",
run() {
if (!splitAvailable()) return
setViewOverride(view() === "split" ? "unified" : "split")
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()
},
},
]
@@ -434,7 +617,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => (
<DialogSelect
title="Switch diff"
title="Switch source"
skipFilter={true}
renderFilter={false}
current={mode()}
@@ -446,6 +629,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
mode: option.value,
sessionID: params()?.sessionID,
messageID: params()?.messageID,
returnRoute: params()?.returnRoute,
})
},
}))}
@@ -453,6 +637,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
))
}
const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
props.api.ui.dialog.setSize("large")
}
useBindings(() => ({
commands,
bindings: [
@@ -460,6 +649,7 @@ 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),
@@ -468,67 +658,57 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}))
return (
<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>
<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>
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
</box>
</box>
<box flexGrow={1} />
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Panel>
<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>
<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>
<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}>
<Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
@@ -537,117 +717,209 @@ function DiffViewer(props: { api: TuiPluginApi }) {
horizontalScrollbarOptions={{ visible: false }}
>
<For each={visiblePatchFiles()}>
{(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>
{(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>
</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>
</Match>
</Switch>
</box>
</box>
</Match>
</Switch>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
</Panel>
</PanelGroup>
</Match>
</Switch>
</box>
<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>
<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>
</box>
)}
</For>
</box>
)
}
@@ -672,6 +944,7 @@ 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,7 +24,6 @@ import type {
SessionMessageCompaction,
SessionMessageModelSwitched,
SessionMessageShell,
SessionMessageSynthetic,
SessionMessageUser,
ToolFileContent,
ToolTextContent,
@@ -20,9 +20,7 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function internalTuiPlugins(
flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">,
): InternalTuiPlugin[] {
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -35,7 +33,7 @@ export function internalTuiPlugins(
Notifications,
PluginManager,
WhichKey,
...(flags.diffViewer ? [DiffViewer] : []),
DiffViewer,
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
@@ -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 { selectedForeground, useTheme } from "@tui/context/theme"
import { generateSubtleSyntax, 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,7 +53,6 @@ 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"
@@ -1498,7 +1497,7 @@ const PART_MAPPING = {
}
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const { theme } = 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.
@@ -1520,6 +1519,8 @@ 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
@@ -1536,7 +1537,9 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
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.
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
@@ -1564,7 +1567,7 @@ function CollapsedReasoningText(props: { title: string | null; duration: number
return (
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning, italic: true }}>
<span style={{ fg: theme.warning }}>
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
</span>
</text>
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
@@ -7,13 +7,16 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTuiConfig } from "../../context/tui-config"
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -119,8 +122,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
pick(opt.label)
}
onMount(() => {
const popMode = modeStack.push(QUESTION_MODE)
onCleanup(popMode)
})
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
mode: QUESTION_MODE,
enabled: store.editing && !confirm(),
commands: [
{
@@ -201,7 +209,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const max = Math.min(total, 9)
return {
mode: OPENCODE_BASE_MODE,
mode: QUESTION_MODE,
enabled: !store.editing,
commands: [
{
+76 -39
View File
@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
const log = Log.create({ service: "config" })
@@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
return copy
}
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return
async function substituteWellKnownRemoteConfig(input: {
value: unknown
dir: string
source: string
env: Record<string, string>
}) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
const url = await ConfigVariable.substitute({
text: input.value.url,
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
})
const headers = isRecord(input.value.headers)
? Object.fromEntries(
@@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
}),
]),
),
@@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
return { url, headers }
}
const WellKnownConfig = Schema.Struct({
config: Schema.optional(Schema.Json),
remote_config: Schema.optional(Schema.Json),
})
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
@@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
type State = {
config: Info
directories: string[]
deps: Fiber.Fiber<void, never>[]
deps: Fiber.Fiber<void>[]
consoleState: ConsoleState
}
@@ -372,17 +386,38 @@ export const layer = Layer.effect(
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const http = yield* HttpClient.HttpClient
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
url: string,
headers: Record<string, string> | undefined,
schema: S,
) {
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
.execute(
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
)
.pipe(
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
)
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
env?: Record<string, string>,
) {
const source = "path" in options ? options.path : options.source
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
"path" in options
? { text, type: "path", path: options.path, env }
: { text, type: "virtual", ...options, env },
),
)
const parsed = ConfigParse.jsonc(expanded, source)
@@ -398,14 +433,14 @@ export const layer = Layer.effect(
return data
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
return yield* loadConfig(text, { path: filepath }, env)
})
const loadGlobal = Effect.fnUntraced(function* () {
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
let result: Info = {}
// Seed the default global config with the schema for editor completion, but avoid writing when the user
// explicitly routes config through env-provided paths or content.
@@ -417,9 +452,9 @@ export const layer = Layer.effect(
.pipe(Effect.catch(() => Effect.void))
}
}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
@@ -477,6 +512,7 @@ export const layer = Layer.effect(
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const authEnv: Record<string, string> = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
@@ -516,56 +552,56 @@ export const layer = Layer.effect(
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as {
config?: Record<string, unknown>
remote_config?: unknown
}
authEnv[value.key] = value.token
const wellknownURL = `${url}/.well-known/opencode`
log.debug("fetching remote config", { url: wellknownURL })
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
value: wellknown.remote_config,
dir: url,
source: `${url}/.well-known/opencode`,
source: wellknownURL,
env: authEnv,
}),
)
const fetchedConfig = remote
? ((yield* Effect.promise(async () => {
? yield* Effect.gen(function* () {
log.debug("fetching remote config", { url: remote.url })
const response = await fetch(remote.url, { headers: remote.headers })
if (!response.ok)
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
const data = await response.json()
return isRecord(data) && isRecord(data.config) ? data.config : data
})) as Record<string, unknown>)
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
if (isRecord(data) && isRecord(data.config)) return data.config
if (isRecord(data)) return data
return yield* Effect.die(
new Error(`failed to decode remote config from ${remote.url}: expected object`),
)
})
: {}
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
const source = wellknownURL
const next = yield* loadConfig(
JSON.stringify(remoteConfig),
{
dir: path.dirname(source),
source,
},
authEnv,
)
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file), "local")
yield* merge(file, yield* loadFile(file, authEnv), "local")
}
}
@@ -579,14 +615,14 @@ export const layer = Layer.effect(
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
const deps: Fiber.Fiber<void>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
yield* merge(source, yield* loadFile(source, authEnv))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export * as Config from "./config"
-1
View File
@@ -5,7 +5,6 @@ import os from "os"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
import { warn } from "console"
const log = Log.create({ service: "config" })
+4
View File
@@ -26,6 +26,10 @@ export const OAuth = Schema.Struct({
description: "OAuth client secret (if required by the authorization server)",
}),
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
callbackPort: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))).annotate({
description:
"Port for the local OAuth callback server (default: 19876). Shorthand for redirectUri when only the port needs changing. Ignored if redirectUri is set.",
}),
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
+3 -2
View File
@@ -19,6 +19,7 @@ type ParseSource =
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
function source(input: ParseSource) {
@@ -33,7 +34,7 @@ function dir(input: ParseSource) {
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
return (input.env?.[varName] ?? process.env[varName]) || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
@@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
@@ -15,7 +15,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
autoShare: bool("OPENCODE_AUTO_SHARE"),
pure: bool("OPENCODE_PURE"),
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
diffViewer: bool("OPENCODE_DIFF_VIEWER"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
+33 -21
View File
@@ -67,7 +67,11 @@ export function isLocal() {
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
stderr: Schema.String,
}) {}
}) {
override get message() {
return this.stderr
}
}
// Response schemas for external version APIs
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
@@ -139,23 +143,32 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const result = yield* appProcess.run(
ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
}),
)
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
}, Effect.orDie)
const upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => {
if (method === "choco") return "not running from an elevated command shell"
if (result) return `Upgrade failed for ${method} (exit code ${result.code}).`
return `Upgrade failed for ${method}.`
}
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const result = yield* appProcess.run(
ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
}),
)
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })),
)
const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
@@ -299,11 +312,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` })
}
if (!upgradeResult || upgradeResult.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
}
log.info("upgraded", {
method: m,
+36 -26
View File
@@ -16,11 +16,10 @@ import { Config } from "@/config/config"
import { ConfigMCP } from "../config/mcp"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
@@ -68,6 +67,10 @@ export const Failed = NamedError.create("MCPFailed", {
name: Schema.String,
})
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
name: Schema.String,
}) {}
type MCPClient = Client
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
@@ -243,8 +246,8 @@ export interface Interface {
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void>
readonly disconnect: (name: string) => Effect.Effect<void>
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
readonly getPrompt: (
clientName: string,
name: string,
@@ -254,11 +257,13 @@ export interface Interface {
clientName: string,
resourceUri: string,
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
readonly startAuth: (
mcpName: string,
) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError>
readonly authenticate: (mcpName: string) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
}
@@ -319,6 +324,7 @@ export const layer = Layer.effect(
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
callbackPort: oauthConfig?.callbackPort,
redirectUri: oauthConfig?.redirectUri,
},
{
@@ -642,15 +648,12 @@ export const layer = Layer.effect(
})
const connect = Effect.fn("MCP.connect")(function* (name: string) {
const mcp = yield* getMcpConfig(name)
if (!mcp) {
log.error("MCP config not found or invalid", { name })
return
}
const mcp = yield* requireMcpConfig(name)
yield* createAndStore(name, { ...mcp, enabled: true })
})
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
yield* requireMcpConfig(name)
const s = yield* InstanceState.get(state)
yield* closeClient(s, name)
delete s.clients[name]
@@ -759,9 +762,14 @@ export const layer = Layer.effect(
return mcpConfig
})
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
if (!mcpConfig) return yield* new NotFoundError({ name: mcpName })
return mcpConfig
})
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
const mcpConfig = yield* requireMcpConfig(mcpName)
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
const url = remoteURL(mcpName, mcpConfig.url)
@@ -770,8 +778,13 @@ export const layer = Layer.effect(
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
// Resolve effective redirect URI: explicit redirectUri > callbackPort shorthand > default
const effectiveRedirectUri =
oauthConfig?.redirectUri ??
(oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined)
// Start the callback server with custom redirectUri if configured
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri))
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
@@ -785,7 +798,7 @@ export const layer = Layer.effect(
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
redirectUri: effectiveRedirectUri,
},
{
onRedirect: async (url) => {
@@ -820,11 +833,9 @@ export const layer = Layer.effect(
const result = yield* startAuth(mcpName)
if (!result.authorizationUrl) {
const client = "client" in result ? result.client : undefined
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) {
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
return { status: "failed", error: "MCP config not found after auth" } as Status
}
const mcpConfig = yield* requireMcpConfig(mcpName).pipe(
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
)
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
if (!client || !listed) {
@@ -875,6 +886,7 @@ export const layer = Layer.effect(
})
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
yield* requireMcpConfig(mcpName)
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
@@ -893,8 +905,7 @@ export const layer = Layer.effect(
yield* auth.clearCodeVerifier(mcpName)
pendingOAuthTransports.delete(mcpName)
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
const mcpConfig = yield* requireMcpConfig(mcpName)
return yield* createAndStore(mcpName, mcpConfig)
})
@@ -907,8 +918,7 @@ export const layer = Layer.effect(
})
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return false
const mcpConfig = yield* requireMcpConfig(mcpName)
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
})
+4 -1
View File
@@ -18,6 +18,7 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
callbackPort?: number
redirectUri?: string
}
@@ -38,7 +39,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
if (this.config.redirectUri) {
return this.config.redirectUri
}
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT
return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`
}
get clientMetadata(): OAuthClientMetadata {
@@ -49,6 +51,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
...(this.config.scope ? { scope: this.config.scope } : {}),
}
}
+6 -2
View File
@@ -100,6 +100,10 @@ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("Permiss
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: PermissionID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Schema.Struct({
@@ -117,7 +121,7 @@ export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface {
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
@@ -209,7 +213,7 @@ export const layer = Layer.effect(
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* bus.publish(Event.Replied, {
-1
View File
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
+9 -3
View File
@@ -101,6 +101,10 @@ export const UpdatePayload = Schema.Struct({
}).annotate({ identifier: "ProjectUpdateInput" })
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ProjectID,
}) {}
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
@@ -116,7 +120,7 @@ export interface Interface {
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
@@ -372,7 +376,9 @@ export const layer: Layer.Layer<
const base64 = Buffer.from(buffer).toString("base64")
const mime = AppFileSystem.mimeType(shortest)
const url = `data:${mime};base64,${base64}`
yield* update({ projectID: input.id, icon: { url } })
yield* update({ projectID: input.id, icon: { url } }).pipe(
Effect.catchTag("Project.NotFoundError", () => Effect.void),
)
})
const list = Effect.fn("Project.list")(function* () {
@@ -400,7 +406,7 @@ export const layer: Layer.Layer<
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
const data = fromRow(result)
yield* emitUpdated(data)
return data
+44 -15
View File
@@ -11,6 +11,9 @@ const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000
type DiffOptions = {
readonly context?: number
}
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
@@ -91,11 +94,17 @@ const splitGitPatch = (patch: Git.Patch) => {
return chunks.slice(0, -1)
}
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
const batchPatches = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string,
list: Git.Item[],
options?: DiffOptions,
) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: PATCH_CONTEXT_LINES,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
@@ -116,11 +125,18 @@ const nativePatch = Effect.fnUntraced(function* (
cwd: string,
ref: string | undefined,
item: Git.Item,
options?: DiffOptions,
) {
const result =
item.code === "??" || !ref
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
? yield* git.patchUntracked(cwd, item.file, {
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
: yield* git.patch(cwd, ref, item.file, {
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
@@ -140,13 +156,14 @@ const patchForItem = Effect.fnUntraced(function* (
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
options?: DiffOptions,
) {
if (capped) return emptyPatch(item.file)
const batched = batch.patches.get(item.file)
if (batched !== undefined) return batched
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
return yield* nativePatch(git, cwd, ref, item)
return yield* nativePatch(git, cwd, ref, item, options)
})
const files = Effect.fnUntraced(function* (
@@ -156,6 +173,7 @@ const files = Effect.fnUntraced(function* (
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
options?: DiffOptions,
) {
const next: FileDiff[] = []
let total = 0
@@ -163,7 +181,7 @@ const files = Effect.fnUntraced(function* (
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped, options)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
@@ -184,7 +202,12 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const diffAgainstRef = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string,
options?: DiffOptions,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
@@ -197,13 +220,19 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list),
yield* batchPatches(git, cwd, ref, list, options),
options,
)
})
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
return yield* diffAgainstRef(git, cwd, ref)
const track = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
options?: DiffOptions,
) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch(), options)
return yield* diffAgainstRef(git, cwd, ref, options)
})
export const Mode = Schema.Literals(["git", "branch"])
@@ -264,7 +293,7 @@ export interface Interface {
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
}
@@ -352,19 +381,19 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
}),
)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* diffAgainstRef(git, ctx.directory, ref)
return yield* diffAgainstRef(git, ctx.directory, ref, options)
}),
diffRaw: Effect.fn("Vcs.diffRaw")(function* () {
const ctx = yield* InstanceState.context
+40 -4
View File
@@ -85,6 +85,13 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
if (!project) return
if (location !== "eu" && location !== "us") return
// Continental multi-regions require Regional Endpoint Platform domains.
return `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`
}
type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
@@ -507,11 +514,13 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
const autoload = Boolean(project)
if (!autoload) return { autoload: false }
const baseURL = googleVertexAnthropicBaseURL(project, location)
return {
autoload: true,
options: {
project,
location,
...(baseURL && { baseURL }),
},
async getModel(sdk: any, modelID) {
const id = String(modelID).trim()
@@ -985,7 +994,22 @@ export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderIni
}
}
export type Error = ModelNotFoundError | InitError
export class NoProvidersError extends Schema.TaggedErrorClass<NoProvidersError>()("ProviderNoProvidersError", {}) {
static isInstance(input: unknown): input is NoProvidersError {
return input instanceof NoProvidersError
}
}
export class NoModelsError extends Schema.TaggedErrorClass<NoModelsError>()("ProviderNoModelsError", {
providerID: ProviderID,
}) {
static isInstance(input: unknown): input is NoModelsError {
return input instanceof NoModelsError
}
}
export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModelsError
export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
@@ -997,7 +1021,7 @@ export interface Interface {
query: string[],
) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError>
}
interface State {
@@ -1516,6 +1540,18 @@ export const layer = Layer.effect(
const provider = s.providers[model.providerID]
const options = { ...provider.options }
if (
model.providerID === "google-vertex" &&
model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
!options.baseURL
) {
const baseURL = googleVertexAnthropicBaseURL(
typeof options.project === "string" ? options.project : undefined,
typeof options.location === "string" ? options.location : undefined,
)
if (baseURL) options.baseURL = baseURL
}
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
@@ -1800,9 +1836,9 @@ export const layer = Layer.effect(
}
const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
if (!provider) return yield* new NoProvidersError()
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
if (!model) return yield* new NoModelsError({ providerID: provider.id })
return {
providerID: provider.id,
modelID: model.id,
+33 -25
View File
@@ -87,6 +87,10 @@ export const UpdateInput = Schema.Struct({
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
@@ -96,17 +100,20 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
readonly remove: (id: PtyID) => Effect.Effect<void>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
@@ -150,10 +157,15 @@ export const layer = Layer.effect(
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = (yield* InstanceState.get(state)).sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
const session = yield* requireSession(id)
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
@@ -166,8 +178,7 @@ export const layer = Layer.effect(
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
return s.sessions.get(id)?.info
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
@@ -262,9 +273,7 @@ export const layer = Layer.effect(
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
const session = yield* requireSession(id)
if (input.title) {
session.info.title = input.title
}
@@ -276,28 +285,27 @@ export const layer = Layer.effect(
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) {
ws.close()
return
}
const session = yield* requireSession(id).pipe(
Effect.tapError(() =>
Effect.sync(() => {
ws.close()
}),
),
)
log.info("client connected to session", { id })
const sub = sock(ws)
+11 -4
View File
@@ -98,6 +98,10 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
requestID: QuestionID,
}) {}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
@@ -115,8 +119,11 @@ export interface Interface {
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly reply: (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) => Effect.Effect<void, NotFoundError>
readonly reject: (requestID: QuestionID) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
@@ -180,7 +187,7 @@ export const layer = Layer.effect(
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
return yield* new NotFoundError({ requestID: input.requestID })
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
@@ -197,7 +204,7 @@ export const layer = Layer.effect(
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
return yield* new NotFoundError({ requestID })
}
pending.delete(requestID)
log.info("rejected", { requestID })
@@ -7,8 +7,11 @@ import {
repositoryCachePath,
sameRepositoryReference,
parseRepositoryReference,
parseRemoteRepositoryReference,
validateRepositoryBranch,
isRemoteRepositoryReference,
InvalidRepositoryBranchError,
InvalidRepositoryReferenceError,
UnsupportedLocalRepositoryError,
type RemoteReference,
} from "@/util/repository"
@@ -138,23 +141,26 @@ export function isError(error: unknown): error is Error {
}
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
const reference = parseRepositoryReference(repository)
if (!reference) {
try {
return parseRemoteRepositoryReference(repository)
} catch (error) {
if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) {
return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message })
}
return yield* new InvalidRepositoryError({
repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
message: errorMessage(error),
})
}
if (!isRemoteRepositoryReference(reference)) {
return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" })
}
return reference
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
try {
validateRepositoryBranch(branch)
} catch (error) {
if (error instanceof InvalidRepositoryBranchError) {
return yield* new InvalidBranchError({ branch: error.branch, message: error.message })
}
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
}
})
@@ -122,6 +122,59 @@ export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>(
{ httpApiStatus: 409 },
) {}
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
"QuestionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
"PermissionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
"McpServerNotFoundError",
{
name: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>()(
"PtyNotFoundError",
{
ptyID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class PtyForbiddenError extends Schema.TaggedErrorClass<PtyForbiddenError>()(
"PtyForbiddenError",
{
message: Schema.String,
},
{ httpApiStatus: 403 },
) {}
export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
"ProjectNotFoundError",
{
projectID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
{
name: Schema.Literal("NotFoundError"),
@@ -26,6 +26,7 @@ const PathInfo = Schema.Struct({
export const VcsDiffQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
mode: Vcs.Mode,
context: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
})
export class ApiVcsApplyError extends Schema.ErrorClass<ApiVcsApplyError>("VcsApplyError")(

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