Compare commits

..

20 Commits

Author SHA1 Message Date
Aiden Cline 439a5a5281 fix(provider): improve prompt caching 2026-07-08 19:23:09 -05:00
opencode-agent[bot] 0abbcddac2 chore: add missing team members (#35980)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-09 09:53:05 +10:00
Luke Parker dd25d143c5 fix(app): reserve review pane minimum instead of capping chat width at 45% (#35078) 2026-07-09 09:47:46 +10:00
Luke Parker d8a57ee15c fix(app): unmount hidden session panes (#35628) 2026-07-09 09:44:02 +10:00
Luke Parker aa52d30d7f feat(app): persist review state per session (#35488) 2026-07-08 23:43:28 +00:00
Luke Parker 83d633fd13 fix(app): route legacy drafts to session page (#35808) 2026-07-09 07:38:56 +08:00
Luke Parker 824455e2f1 fix(app): load capped review patches (#35633) 2026-07-09 09:09:32 +10:00
Luke Parker 62de2b3695 feat(app): add inline file browser tabs (#35829) 2026-07-09 09:09:21 +10:00
opencode-agent[bot] 7550aa4b89 chore: update nix node_modules hashes 2026-07-08 22:51:45 +00:00
opencode-agent[bot] c33a2011c4 fix(app): prevent command palette first-open flash (#35858) 2026-07-09 06:51:04 +08:00
David Hill fff6764ed4 fix(console): update square avatar previews (#35975) 2026-07-08 22:48:11 +00:00
opencode-agent[bot] 9644a008a4 chore: generate 2026-07-08 22:31:09 +00:00
Aiden Cline ccb6b7c3ea fix: improve xai cache hit rate (#35970) 2026-07-08 17:29:36 -05:00
opencode-agent[bot] f95f877e5f fix(core): watch only git projects (#35957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-08 20:58:41 +00:00
Frank fe96d4aebd zen: grok 4.5 2026-07-08 15:52:56 -04:00
Frank 3a95d56144 Merge branch 'dev' of https://github.com/anomalyco/opencode into dev 2026-07-08 15:30:52 -04:00
Frank ecd8be7593 zen: grok 4.5 2026-07-08 15:30:49 -04:00
opencode-agent[bot] a72c3880bb chore: generate 2026-07-08 16:30:17 +00:00
Adam ccd8676bee feat(stats): add model comparison home 2026-07-08 11:28:28 -05:00
Adam 5a533e366f feat(stats): add model comparison pages 2026-07-08 11:28:27 -05:00
88 changed files with 5766 additions and 776 deletions
+4
View File
@@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@@ -7,11 +8,14 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
+45 -22
View File
@@ -293,14 +293,14 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -308,7 +308,7 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -575,14 +575,14 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
@@ -1063,19 +1063,24 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"ai-gateway-provider@3.1.2": "patches/ai-gateway-provider@3.1.2.patch",
"@ai-sdk/openai-compatible@2.0.37": "patches/@ai-sdk%2Fopenai-compatible@2.0.37.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/cohere@3.0.27": "patches/@ai-sdk%2Fcohere@3.0.27.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1173,7 +1178,7 @@
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
@@ -1195,7 +1200,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -1211,7 +1216,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -5641,7 +5646,11 @@
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
"@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5679,7 +5688,9 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5693,7 +5704,11 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@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=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -6141,8 +6156,14 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -6363,8 +6384,6 @@
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"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=="],
@@ -6577,6 +6596,8 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6939,6 +6960,12 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/xai/@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=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -7083,12 +7110,6 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7353,6 +7374,10 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -7421,8 +7446,6 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
"x86_64-linux": "sha256-K0V7FyvgujbeAf0Ip4SCJxcy0GpSt/H+iEwTVhvwBRQ=",
"aarch64-linux": "sha256-lz2GhziKY9zL4vXb2/CmlXKrmVYPF5hPFD1ZpfDOqgM=",
"aarch64-darwin": "sha256-vfxsqozBq40tc1v6w8xvl1HIAif8gRn6oIYrLt6fZ1c=",
"x86_64-darwin": "sha256-X7GAwO+8A6vwqObIHFtloDDJZO8ZUTqmplFDg1zfots="
}
}
+7 -2
View File
@@ -147,7 +147,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
@@ -155,6 +155,11 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
"ai-gateway-provider@3.1.2": "patches/ai-gateway-provider@3.1.2.patch",
"@ai-sdk/cohere@3.0.27": "patches/@ai-sdk%2Fcohere@3.0.27.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/openai-compatible@2.0.37": "patches/@ai-sdk%2Fopenai-compatible@2.0.37.patch"
}
}
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -0,0 +1,146 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}
@@ -0,0 +1,152 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -9,12 +9,19 @@ const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
@@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? branchDiffs
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
}),
)
})
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
@@ -96,7 +114,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
@@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
@@ -201,12 +243,21 @@ function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function fileDiff(file: string, additions: number) {
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
+10
View File
@@ -22,6 +22,7 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
}
@@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
+10 -1
View File
@@ -12,6 +12,7 @@ import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -174,6 +175,7 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>)
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -182,7 +184,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
@@ -20,14 +20,11 @@ import {
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
const DialogCommandPaletteV2 = lazy(() =>
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
)
type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
@@ -201,6 +201,7 @@ export default function FileTree(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
onFileDoubleClick={props.onFileDoubleClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
onDblClick={() => props.onFileDoubleClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const settings = useSettings()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5 min-w-0">
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
</span>
</Show>
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
{getFilename(props.path)}
</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
+8 -4
View File
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
const search = (query: string, dirs: "true" | "false") =>
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs })
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
(error) => {
if (options?.signal?.aborted) throw error
return []
},
)
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) => search(query, "false"),
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
search(query, "false", options),
searchFilesAndDirectories: (query: string) => search(query, "true"),
}
},
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import {
SESSION_OPEN_FILE_TAB,
closeSessionTab,
openSessionTab,
previewSessionTab,
type SessionTabState,
} from "./layout-tabs"
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
tabs: { all, active },
preview,
})
describe("previewSessionTab", () => {
test("appends the Open File placeholder", () => {
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
)
})
test("replaces the current preview in place", () => {
expect(
previewSessionTab(
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://a.ts",
),
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
})
test("activates a durable tab without duplicating it", () => {
expect(
previewSessionTab(
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
})
test("replaces a restored Open File placeholder", () => {
expect(
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
})
})
describe("openSessionTab", () => {
test("pins the current preview", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
state(["file://a.ts"], "file://a.ts"),
)
})
test("replaces a preview with a directly opened file", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
state(["file://b.ts"], "file://b.ts"),
)
})
test("keeps the preview when switching to Review", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
state(["file://a.ts"], "review", "file://a.ts"),
)
})
test("replaces a restored Open File placeholder with a direct open", () => {
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
)
})
})
describe("closeSessionTab", () => {
test("clears preview metadata and selects the left neighbor", () => {
expect(
closeSessionTab(
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
})
})
+103
View File
@@ -0,0 +1,103 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export type SessionTabs = {
active?: string
all: string[]
}
export type SessionTabState = {
tabs: SessionTabs
preview?: string
}
const sessionTabPreview = (current: SessionTabState) =>
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
preview: tab,
}
}
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
if (tab === "review") {
return {
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
preview,
}
}
if (tab === "context") {
return {
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
preview,
}
}
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab } }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
}
}
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
if (tab === "review") {
if (current.tabs.active !== tab) return current
return {
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
preview: current.preview,
}
}
const all = current.tabs.all.filter((item) => item !== tab)
const preview = current.preview === tab ? undefined : current.preview
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
const index = current.tabs.all.indexOf(tab)
return {
tabs: {
all,
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
},
preview,
}
}
+82 -39
View File
@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -55,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
@@ -79,6 +77,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@@ -87,14 +86,6 @@ export type LayoutRoute =
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
@@ -308,6 +299,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
sessionTabPreview: {} as Record<string, string | undefined>,
})
const MAX_SESSION_KEYS = 50
@@ -366,6 +358,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
scroll.drop(drop)
dropSessionState(drop)
setEphemeral(
"sessionTabPreview",
produce((draft) => {
for (const key of drop) delete draft[key]
}),
)
for (const key of drop) {
usage.used.delete(key)
@@ -791,6 +789,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -874,6 +880,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
@@ -945,10 +977,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
batch(() => {
setStore("sessionTabs", session, next.tabs)
setEphemeral("sessionTabPreview", session, next.preview)
})
}
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
@@ -961,40 +1000,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
batch(() => {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
const preview = ephemeral.sessionTabPreview[session]
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
})
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
apply(
session,
openSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
previewTab(tab: string) {
const session = key()
apply(
session,
previewSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
})
apply(
session,
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
)
},
move(tab: string, to: number) {
const session = key()
+125 -36
View File
@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -11,6 +11,7 @@ import {
createMemo,
createEffect,
createComputed,
createSignal,
on,
onMount,
type ParentProps,
@@ -69,12 +70,18 @@ import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
SESSION_PANEL_WIDTH_MIN,
sessionPanelWidthMax,
} from "@/pages/session/session-panel-width"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
@@ -100,7 +107,6 @@ type VcsMode = "git" | "branch"
const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
})
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
@@ -347,6 +353,8 @@ export default function Page() {
const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
@@ -440,9 +448,39 @@ export default function Page() {
newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
)
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
let panelRow: HTMLDivElement | undefined
const [panelRowWidth, setPanelRowWidth] = createSignal<number>()
createResizeObserver(
() => panelRow,
({ width }) => setPanelRowWidth(width),
)
const splitReview = createMemo(
() => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split",
)
// The observer reports the content-box width, which already excludes the row
// padding; only the flex gap between the panels remains to subtract.
const sessionPanelAvailable = createMemo(() => {
const width = panelRowWidth()
if (width === undefined) return undefined
return width - (settings.general.newLayoutDesigns() ? 8 : 0)
})
const sessionPanelMax = createMemo(() => {
const available = sessionPanelAvailable()
if (available === undefined) return 1000
return sessionPanelWidthMax({ available, split: splitReview() })
})
// Clamp at render time so window or sidebar resizes squeeze the chat panel
// instead of the review pane, without overwriting the persisted width.
const sessionPanelResizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
available: sessionPanelAvailable(),
split: splitReview(),
}),
)
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
@@ -606,7 +644,8 @@ export default function Page() {
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
const mode = reviewMode()
if (mode === "git" || mode === "branch") return mode
})
const vcsKey = createMemo(
() =>
@@ -633,17 +672,63 @@ export default function Page() {
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
const selected = reviewFile()
if (selected && diffs.some((diff) => diff.file === selected)) return selected
return diffs[0]?.file
}
const reviewCount = () => reviewDiffs().length
const hasReview = () => reviewCount() > 0
const reviewReady = () => {
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
return true
}
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
const mode = vcsMode()
if (!mode) return
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
const directory = reviewDiffDirectory(root, file)
const source = reviewDiffs().find((diff) => diff.file === file)
const valid = (diff: VcsFileDiff | undefined) => {
if (!diff || !source) return
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
if (reviewDiffNeedsLoad(diff)) return
return diff
}
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
sdk()
.client.vcs.diff({ mode, directory: scope, context })
.then((result) => result.data ?? []),
})
.then((diffs) => diffs.find((diff) => diff.file === file))
if (directory !== root) {
try {
const scoped = valid(await request(directory))
if (scoped) return scoped
} catch (error) {
console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error })
}
}
try {
const bounded = valid(await request(root, 3))
if (bounded) return bounded
} catch (error) {
console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error })
}
}
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -961,12 +1046,15 @@ export default function Page() {
}
createEffect(() => {
if (!layout.ready()) return
if (sync().status !== "complete") return
if (!sync().project) return
const list = changesOptions()
if (list.includes(store.changes)) return
const mode = reviewMode()
if (list.includes(mode)) return
const next = list[0]
if (!next) return
setStore("changes", next)
view().review.setMode(next)
})
createEffect(
@@ -986,7 +1074,6 @@ export default function Page() {
const [tree, setTree] = createStore({
reviewScroll: undefined as HTMLDivElement | undefined,
pendingDiff: undefined as string | undefined,
activeDiff: undefined as string | undefined,
})
createEffect(
@@ -996,7 +1083,6 @@ export default function Page() {
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
},
{ defer: true },
@@ -1019,6 +1105,7 @@ export default function Page() {
setActiveMessage,
focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
const openReviewFile = createOpenReviewFile({
@@ -1043,9 +1130,9 @@ export default function Page() {
return (
<Select
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
@@ -1062,11 +1149,11 @@ export default function Page() {
<SelectV2
appearance="inline"
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
/>
)
}
@@ -1094,18 +1181,18 @@ export default function Page() {
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges")
if (reviewMode() === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (reviewMode() === "git" || reviewMode() === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
if (reviewMode() === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
@@ -1118,10 +1205,10 @@ export default function Page() {
}
const reviewEmptyV2 = () => {
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (store.changes === "turn" && nogit()) {
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
@@ -1143,7 +1230,7 @@ export default function Page() {
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
focusedFile={activeReviewFile()}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
@@ -1174,8 +1261,12 @@ export default function Page() {
},
diffs: reviewDiffs,
diffsReady: reviewReady,
get diffVersion() {
return vcsQuery.dataUpdatedAt
},
loadDiff: loadReviewDiff,
get activeFile() {
return tree.activeDiff
return activeReviewFile()
},
onSelectFile: focusReviewDiff,
get diffStyle() {
@@ -1288,7 +1379,8 @@ export default function Page() {
const focusReviewDiff = (path: string) => {
openReviewPanel()
view().review.openPath(path)
setTree({ activeDiff: path, pendingDiff: path })
view().review.setFile(path)
setTree("pendingDiff", path)
}
createEffect(() => {
@@ -2092,6 +2184,7 @@ export default function Page() {
<SessionRouteFrame>
<SessionHeader />
<div
ref={panelRow}
class="flex-1 min-h-0 flex flex-col md:flex-row"
classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(),
@@ -2130,9 +2223,9 @@ export default function Page() {
"-right-1": settings.general.newLayoutDesigns(),
}}
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
size={sessionPanelResizedWidth()}
min={SESSION_PANEL_WIDTH_MIN}
max={sessionPanelMax()}
onResize={(width) => {
size.touch()
layout.session.resize(width)
@@ -2142,7 +2235,7 @@ export default function Page() {
</Show>
</div>
<Show when={!newSessionDesign()}>
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
@@ -2152,7 +2245,7 @@ export default function Page() {
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
@@ -2161,13 +2254,8 @@ export default function Page() {
<Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop()}>
<div
classList={{
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
}}
>
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<div class="min-h-0 flex-1">
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
@@ -2177,7 +2265,8 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
activeDiff={tree.activeDiff}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
+13 -3
View File
@@ -172,6 +172,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
}
export function FileTabContent(props: { tab: string }) {
return (
<Tabs.Content value={props.tab}>
<SessionFileView tab={props.tab} />
</Tabs.Content>
)
}
export function SessionFileView(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@@ -439,8 +447,8 @@ export function FileTabContent(props: { tab: string }) {
</div>
)
return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
@@ -450,6 +458,8 @@ export function FileTabContent(props: { tab: string }) {
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</Tabs.Content>
</div>
)
return content()
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
createSessionTabs,
@@ -165,4 +166,49 @@ describe("createSessionTabs", () => {
dispose()
})
})
test("exposes the Open File tab without treating it as a file tab", () => {
createRoot((dispose) => {
const [state] = createStore({
active: SESSION_OPEN_FILE_TAB as string | undefined,
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
normalizeTab: (tab) => tab,
fileBrowser: () => true,
})
expect(result.openFileOpen()).toBe(true)
expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB])
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB)
dispose()
})
})
test("hides the Open File placeholder when the file browser is unavailable", () => {
createRoot((dispose) => {
const [state] = createStore({
active: SESSION_OPEN_FILE_TAB as string | undefined,
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
normalizeTab: (tab) => tab,
fileBrowser: () => false,
})
expect(result.openFileOpen()).toBe(false)
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe("file://src/a.ts")
dispose()
})
})
})
+19 -1
View File
@@ -2,6 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same"
import { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
const emptyTabs: string[] = []
@@ -16,6 +19,7 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@@ -27,8 +31,14 @@ export function shouldShowFileTree(input: { visible: boolean; opened: boolean })
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
const openFileOpen = createMemo(
() =>
fileBrowser() &&
(input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)),
)
const panelTabs = createMemo(
() => {
const seen = new Set<string>()
return input
@@ -36,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
@@ -45,9 +56,13 @@ export const createSessionTabs = (input: TabsInput) => {
emptyTabs,
{ equals: same },
)
const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
equals: same,
})
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
@@ -65,12 +80,15 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (!openedTabs().includes(active)) return
return active
})
return {
contextOpen,
openFileOpen,
panelTabs,
openedTabs,
activeTab,
activeFileTab,
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import {
clampSessionPanelWidth,
REVIEW_PANE_WIDTH_MIN,
REVIEW_PANE_WIDTH_MIN_SPLIT,
SESSION_PANEL_WIDTH_MIN,
sessionPanelWidthMax,
} from "./session-panel-width"
describe("sessionPanelWidthMax", () => {
test("reserves the unified review pane minimum", () => {
expect(sessionPanelWidthMax({ available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN)
})
test("reserves a larger minimum for split diffs", () => {
expect(sessionPanelWidthMax({ available: 1700, split: true })).toBe(1700 - REVIEW_PANE_WIDTH_MIN_SPLIT)
expect(REVIEW_PANE_WIDTH_MIN_SPLIT).toBeGreaterThan(REVIEW_PANE_WIDTH_MIN)
})
test("lets the chat panel take everything beyond the review pane minimum", () => {
// Regression: the old cap was 45% of the window, forcing the review pane
// to at least 55% of the window regardless of content.
const available = 3440
expect(sessionPanelWidthMax({ available, split: false })).toBeGreaterThan(available * 0.45)
})
test("never drops below the chat panel minimum on small windows", () => {
expect(sessionPanelWidthMax({ available: 600, split: true })).toBe(SESSION_PANEL_WIDTH_MIN)
expect(sessionPanelWidthMax({ available: 0, split: false })).toBe(SESSION_PANEL_WIDTH_MIN)
})
})
describe("clampSessionPanelWidth", () => {
test("keeps widths already within the limit", () => {
expect(clampSessionPanelWidth({ width: 800, available: 1700, split: false })).toBe(800)
})
test("forces the width down when the window shrinks", () => {
expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN)
expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: true })).toBe(
1700 - REVIEW_PANE_WIDTH_MIN_SPLIT,
)
})
test("holds the chat panel minimum when there is no room for both", () => {
expect(clampSessionPanelWidth({ width: 1600, available: 700, split: true })).toBe(SESSION_PANEL_WIDTH_MIN)
})
test("skips clamping before the layout is measured", () => {
expect(clampSessionPanelWidth({ width: 1600, available: undefined, split: false })).toBe(1600)
})
})
@@ -0,0 +1,19 @@
// The review pane has no width of its own: it takes whatever the chat panel
// leaves behind. Instead of capping the chat panel at a fraction of the window
// (which forces the review pane to grow with the monitor), reserve a fixed
// minimum for the review pane and let the chat panel take everything else.
export const SESSION_PANEL_WIDTH_MIN = 450
export const REVIEW_PANE_WIDTH_MIN = 480
export const REVIEW_PANE_WIDTH_MIN_SPLIT = 800
export function sessionPanelWidthMax(input: { available: number; split: boolean }) {
const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN
return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - pane)
}
// `available` is undefined until the layout row is first measured; render the
// stored width untouched until then to avoid a first-frame snap.
export function clampSessionPanelWidth(input: { width: number; available: number | undefined; split: boolean }) {
if (input.available === undefined) return input.width
return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split }))
}
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
@@ -23,10 +24,10 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
@@ -35,6 +36,7 @@ import {
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
@@ -51,6 +53,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
@@ -59,7 +62,6 @@ export function SessionSidePanel(props: {
}) {
const layout = useLayout()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -79,7 +81,6 @@ export function SessionSidePanel(props: {
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const rendered = createMemo<boolean>((previous) => previous || open(), false)
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
@@ -155,15 +156,13 @@ export function SessionSidePanel(props: {
normalizeTab,
review: reviewTab,
hasReview: props.canReview,
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const reviewContentRendered = createMemo<boolean>(
(previous) => previous || (reviewOpen() && activeTab() === "review"),
false,
)
const fileTreeTab = () => layout.fileTree.tab()
@@ -180,6 +179,33 @@ export function SessionSidePanel(props: {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
return activeFileTab()
})
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -241,173 +267,220 @@ export function SessionSidePanel(props: {
}}
style={{ width: panelWidth() }}
>
<Show when={rendered()}>
<Show when={open()}>
<div
class="size-full flex"
classList={{
"border-l border-border-weaker-base": !settings.general.newLayoutDesigns(),
}}
>
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
classList={{
"pointer-events-none": !reviewOpen(),
}}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
aria-hidden={activeTab() !== "review"}
inert={activeTab() !== "review"}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
classList={{ hidden: activeTab() !== "review" }}
>
{props.reviewPanel()}
</div>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
<Show when={reviewOpen()}>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</div>
</Show>
<Show when={shown()}>
<Show when={fileOpen()}>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
@@ -435,45 +508,49 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show when={fileTreeTab() === "changes"}>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
</Show>
<Show when={fileTreeTab() === "all"}>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Match>
</Switch>
</Tabs.Content>
</Show>
</Tabs>
</div>
<Show when={fileOpen()}>
@@ -25,6 +25,7 @@ export type SessionCommandContext = {
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -83,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
import { filterReviewFiles, reviewDiffDirectory, reviewDiffKinds, reviewDiffNeedsLoad } from "./review-diff-kinds"
describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => {
@@ -28,3 +28,43 @@ describe("filterReviewFiles", () => {
expect(filterReviewFiles(files, "")).toEqual(files)
})
})
describe("reviewDiffNeedsLoad", () => {
test("loads changed files whose aggregate patch has no hunks", () => {
expect(
reviewDiffNeedsLoad({
file: "src/a.ts",
additions: 1,
deletions: 0,
patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts",
}),
).toBe(true)
})
test("keeps complete patches and empty changes", () => {
expect(
reviewDiffNeedsLoad({
file: "src/a.ts",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+value",
}),
).toBe(false)
expect(reviewDiffNeedsLoad({ file: "empty.txt", additions: 0, deletions: 0 })).toBe(false)
})
})
describe("reviewDiffDirectory", () => {
test("scopes nested files to their parent directory", () => {
expect(reviewDiffDirectory("/repo", "src/lib/a.ts")).toBe("/repo/src/lib")
expect(reviewDiffDirectory("C:\\repo", "src/lib/a.ts")).toBe("C:\\repo\\src\\lib")
})
test("does not rescope root files", () => {
expect(reviewDiffDirectory("/repo/", "README.md")).toBe("/repo")
expect(reviewDiffDirectory("/", "README.md")).toBe("/")
expect(reviewDiffDirectory("C:\\", "README.md")).toBe("C:\\")
expect(reviewDiffDirectory("/", "src/a.ts")).toBe("/src")
expect(reviewDiffDirectory("C:\\", "src/a.ts")).toBe("C:\\src")
})
})
@@ -12,6 +12,24 @@ export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): val
return typeof value.file === "string"
}
export function reviewDiffNeedsLoad(diff: RenderDiff) {
if (diff.additions === 0 && diff.deletions === 0) return false
return !diff.patch || !/^@@ /m.test(diff.patch)
}
export function reviewRootDirectory(root: string) {
return root === "/" || /^[A-Za-z]:[/\\]?$/.test(root) ? root : root.replace(/[/\\]+$/, "")
}
export function reviewDiffDirectory(root: string, file: string) {
const path = normalizePath(file)
const index = path.lastIndexOf("/")
const separator = root.includes("\\") ? "\\" : "/"
const base = reviewRootDirectory(root)
if (index < 0) return base
return `${base.endsWith(separator) ? base : base + separator}${path.slice(0, index).replaceAll("/", separator)}`
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
@@ -1,4 +1,4 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
@@ -25,6 +25,7 @@ import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
reviewDiffNeedsLoad,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
@@ -37,6 +38,8 @@ export type ReviewPanelV2Props = {
empty?: JSX.Element
diffs: () => ReviewDiff[]
diffsReady: () => boolean
diffVersion?: number
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
activeFile?: string
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
@@ -74,7 +77,26 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
if (active && files.includes(active)) return active
return files[0]
})
const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const sourceActiveItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const detailSource = createMemo(() => {
const diff = sourceActiveItem()
const load = props.loadDiff
if (!diff || !load || !reviewDiffNeedsLoad(diff)) return
return { diff, load, version: props.diffVersion }
})
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
const value = await load(diff.file, version)
if (value?.file !== diff.file) return
return { source: diff, version, value }
})
const activeItem = createMemo(() => {
const source = sourceActiveItem()
if (loadedDiff.state !== "ready") return source
const loaded = loadedDiff()
if (loaded && loaded.source === source && loaded.version === props.diffVersion) return loaded.value
return source
})
const readFile = async (path: string) =>
sdk()
@@ -0,0 +1,193 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import {
SessionFilePanelV2,
SessionFilePanelV2Empty,
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { displayName } from "@/pages/layout/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { pathKey } from "@/utils/path-key"
const emptyFiles: string[] = []
export type SessionFileBrowserState = {
sidebarOpened: () => boolean
sidebarWidth: () => number
resizeSidebar: (width: number) => void
toggleSidebar: () => void
}
export function SessionFileBrowserTab(props: {
tab: string
placeholder: boolean
active?: string
kinds: ReadonlyMap<string, Kind>
state: SessionFileBrowserState
onSelect: (path: string) => void
onSelectPermanent: (path: string) => void
filterRef?: (element: HTMLInputElement) => void
}) {
const file = useFile()
const language = useLanguage()
const layout = useLayout()
const sdk = useSDK()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
const value = query()
return {
queryKey: ["session-open-file", workspaceKey(), value] as const,
enabled: value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
}
})
const files = createMemo(() => {
if (!query() || search.isPending) return emptyFiles
return [...new Set(search.data ?? emptyFiles)]
})
const highlighted = createMemo(() => {
const values = files()
if (values.length === 0) return undefined
const explicit = explicitHighlight()
if (explicit && values.includes(explicit)) return explicit
return values[0]
})
const loading = createMemo(() => query().length > 0 && search.isPending)
const project = createMemo(() => {
const directory = pathKey(sdk().directory)
return layout.projects
.list()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
})
const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (event.key === "Escape" && query()) {
event.preventDefault()
setFilter("")
return
}
if (!query()) return
applyFileListKeyDown(event, files(), highlighted(), {
onHighlight: setExplicitHighlight,
onSelect: props.onSelectPermanent,
})
}
return (
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!props.state.sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={query()}
fallback={
<FileTree
path=""
class="pt-1"
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
>
<Show
when={!loading()}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
</Show>
</Show>
</SessionReviewV2Sidebar>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
</Tabs.Content>
)
}
@@ -43,7 +43,11 @@ export function SessionFileListV2(props: {
active?: string
highlighted?: string
kinds?: ReadonlyMap<string, Kind>
id?: string
role?: "listbox"
optionID?: (path: string) => string
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
@@ -88,6 +92,8 @@ export function SessionFileListV2(props: {
return (
<div
ref={setRoot}
id={props.id}
role={props.role}
data-component="file-tree-v2"
data-total-rows={props.files.length}
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
@@ -116,6 +122,9 @@ export function SessionFileListV2(props: {
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
@@ -124,6 +133,7 @@ export function SessionFileListV2(props: {
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+3 -3
View File
@@ -65,14 +65,14 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -80,7 +80,7 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
+1 -1
View File
@@ -106,7 +106,7 @@ const layer = Layer.effect(
const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
if (location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER)) {
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
@@ -168,16 +168,12 @@ describeWatcher("Watcher", () => {
),
)
it.live("watches non-git roots", () =>
it.live("skips non-git roots", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(directory, "plain.txt")
yield* ready(directory)
expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
file,
event: "add",
})
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
}),
),
)
@@ -190,7 +186,10 @@ describeWatcher("Watcher", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
yield* ready(tmp.path).pipe(
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
Effect.scoped,
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events),
@@ -0,0 +1,91 @@
import { createCerebras } from "@ai-sdk/cerebras"
import { createDeepInfra } from "@ai-sdk/deepinfra"
import { createMistral } from "@ai-sdk/mistral"
import { createOpenAI } from "@ai-sdk/openai"
import { describe, expect, test } from "bun:test"
test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "mistral-large-latest",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { mistral: { promptCacheKey: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
test("OpenAI Responses sends promptCacheKey as prompt_cache_key", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "gpt-5",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createOpenAI({ apiKey: "test", fetch: mockFetch }).responses("gpt-5")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { openai: { promptCacheKey: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
describe("OpenAI-compatible provider cache keys", () => {
for (const provider of [
{ name: "Cerebras", create: createCerebras, namespace: "cerebras" },
{ name: "DeepInfra", create: createDeepInfra, namespace: "deepinfra" },
]) {
test(`${provider.name} passes prompt_cache_key through`, async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "test-model",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = provider.create({ apiKey: "test", fetch: mockFetch })("test-model")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { [provider.namespace]: { prompt_cache_key: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
}
})
@@ -0,0 +1,69 @@
import { createCohere } from "@ai-sdk/cohere"
import { createGroq } from "@ai-sdk/groq"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { describe, expect, test } from "bun:test"
const prompt = [{ role: "user" as const, content: [{ type: "text" as const, text: "Hello" }] }]
describe("provider cache usage", () => {
test("Cohere reports cached input tokens", async () => {
const model = createCohere({
apiKey: "test",
fetch: mockFetch({
generation_id: "response-1",
message: { role: "assistant", content: [{ type: "text", text: "Hello" }] },
finish_reason: "COMPLETE",
usage: {
billed_units: { input_tokens: 500, output_tokens: 1 },
tokens: { input_tokens: 500, output_tokens: 1, cached_tokens: 400 },
},
}),
})("command-r")
const result = await model.doGenerate({ prompt })
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
})
test("Groq reports cached input tokens", async () => {
const model = createGroq({
apiKey: "test",
fetch: mockFetch({
id: "response-1",
created: 0,
model: "openai/gpt-oss-20b",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: {
prompt_tokens: 500,
completion_tokens: 1,
total_tokens: 501,
prompt_tokens_details: { cached_tokens: 400 },
},
}),
})("openai/gpt-oss-20b")
const result = await model.doGenerate({ prompt })
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
})
test("Together AI reports flat cached input tokens", async () => {
const model = createTogetherAI({
apiKey: "test",
fetch: mockFetch({
id: "response-1",
created: 0,
model: "moonshotai/Kimi-K2.6",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 500, completion_tokens: 1, total_tokens: 501, cached_tokens: 400 },
}),
})("moonshotai/Kimi-K2.6")
const result = await model.doGenerate({ prompt })
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
})
})
function mockFetch(response: unknown) {
return Object.assign(async () => Response.json(response), { preconnect: fetch.preconnect })
}
@@ -0,0 +1,32 @@
import { createXai } from "@ai-sdk/xai"
import { expect, test } from "bun:test"
test("xAI Responses sends promptCacheKey as prompt_cache_key", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "grok-4",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({
apiKey: "test",
fetch: mockFetch,
}).responses("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { promptCacheKey: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
@@ -436,21 +436,19 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
const inputTokens = usage.inputTokens === undefined ? undefined : usage.inputTokens + cacheTotal
return new Usage({
inputTokens: usage.inputTokens,
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
@@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 12,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
},
],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({
inputTokens: 10,
nonCachedInputTokens: 5,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
outputTokens: 2,
totalTokens: 12,
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+2 -2
View File
@@ -66,14 +66,14 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
+65 -26
View File
@@ -46,6 +46,28 @@ function sdkKey(npm: string): string | undefined {
return "vertex"
case "@ai-sdk/google":
return "google"
case "@ai-sdk/alibaba":
return "alibaba"
case "@ai-sdk/cerebras":
return "cerebras"
case "@ai-sdk/cohere":
return "cohere"
case "@ai-sdk/deepinfra":
return "deepinfra"
case "@ai-sdk/groq":
return "groq"
case "@ai-sdk/mistral":
return "mistral"
case "@ai-sdk/perplexity":
return "perplexity"
case "@ai-sdk/togetherai":
return "togetherai"
case "@ai-sdk/vercel":
return "vercel"
case "@ai-sdk/xai":
return "xai"
case "venice-ai-sdk-provider":
return "venice"
case "@ai-sdk/gateway":
return "gateway"
case "@openrouter/ai-sdk-provider":
@@ -430,6 +452,9 @@ function mapProviderOptions(
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
const usesAnthropicAutomaticCaching =
options.cacheControl !== undefined &&
(model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic")
if (
(model.providerID === "anthropic" ||
model.providerID === "google-vertex-anthropic" ||
@@ -439,7 +464,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm !== "@ai-sdk/gateway"
model.api.npm !== "@ai-sdk/gateway" &&
!usesAnthropicAutomaticCaching
) {
msgs = applyCaching(msgs, model)
}
@@ -1077,7 +1103,7 @@ export function options(input: {
sessionID: string
providerOptions?: Record<string, any>
}): Record<string, any> {
const result: Record<string, any> = {}
const result: Record<string, any> = cachingOptions(input)
if (
input.model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
@@ -1098,7 +1124,6 @@ export function options(input: {
if (input.model.api.npm === "@ai-sdk/azure") {
result["store"] = false
result["promptCacheKey"] = input.sessionID
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") {
@@ -1127,10 +1152,6 @@ export function options(input: {
}
}
if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) {
result["promptCacheKey"] = input.sessionID
}
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
if (input.model.capabilities.reasoning) {
result["thinkingConfig"] = {
@@ -1206,37 +1227,55 @@ export function options(input: {
result["textVerbosity"] = "low"
}
if (input.model.providerID.startsWith("opencode")) {
if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) {
result["promptCacheKey"] = input.sessionID
result["include"] = INCLUDE_ENCRYPTED_REASONING
result["reasoningSummary"] = "auto"
}
}
if (input.model.providerID === "venice") {
result["promptCacheKey"] = input.sessionID
}
if (input.model.providerID === "openrouter") {
result["prompt_cache_key"] = input.sessionID
}
if (input.model.api.npm === "@ai-sdk/gateway") {
result["gateway"] = {
caching: "auto",
}
}
return result
}
export function smallOptions(model: Provider.Model) {
function cachingOptions(input: {
model: Provider.Model
sessionID: string
providerOptions?: Record<string, any>
}): Record<string, any> {
if (input.providerOptions?.setCacheKey === false) {
if (input.model.api.npm === "@ai-sdk/gateway") return { gateway: { caching: "auto" } }
return {}
}
if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") {
return { prompt_cache_key: input.sessionID }
}
if (
input.model.api.npm === "@ai-sdk/openai" ||
(input.model.providerID === "openai" && input.model.api.npm !== "@ai-sdk/openai-compatible") ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/xai" ||
input.model.api.npm === "@ai-sdk/mistral" ||
input.model.api.npm === "venice-ai-sdk-provider" ||
input.providerOptions?.setCacheKey === true
) {
return { promptCacheKey: input.sessionID }
}
if (input.model.api.npm === "@ai-sdk/gateway") return { gateway: { caching: "auto" } }
return {}
}
export function smallOptions(model: Provider.Model, sessionID?: string, providerOptions?: Record<string, any>) {
const small = Object.values(model.variants ?? {})[0] ?? {}
const caching = sessionID ? cachingOptions({ model, sessionID, providerOptions }) : {}
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
) {
const base = { store: false }
const base = { ...caching, store: false }
return mergeDeep(base, small)
}
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
@@ -1246,11 +1285,11 @@ export function smallOptions(model: Provider.Model) {
}
if (model.providerID === "venice") {
if (Object.keys(small).length > 0) return small
return { veniceParameters: { disableThinking: true } }
if (Object.keys(small).length > 0) return mergeDeep(caching, small)
return mergeDeep(caching, { veniceParameters: { disableThinking: true } })
}
return small
return mergeDeep(caching, small)
}
// Maps model ID prefix to provider slug used in providerOptions.
+1 -1
View File
@@ -82,7 +82,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
? ProviderTransform.smallOptions(input.model, input.sessionID, input.provider.options)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
+2 -1
View File
@@ -387,6 +387,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
? input.model.cost.experimentalOver200K
: input.model.cost)
const totalNanoAiu = input.metadata?.["copilot"]?.["totalNanoAiu"]
const cacheReadCost = input.model.api.npm === "@ai-sdk/cerebras" ? costInfo?.input : costInfo?.cache?.read
return {
cost:
typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0
@@ -395,7 +396,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
new Decimal(0)
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(cacheReadCost ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now:
// charge reasoning tokens at the same rate as output tokens
@@ -16,7 +16,7 @@ import type * as Provider from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
type Captured = { url: string; outerBody: unknown }
type Captured = { url: string; headers: Headers; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
const realFetch = globalThis.fetch
@@ -32,7 +32,7 @@ beforeEach(() => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
const bodyText = typeof init?.body === "string" ? init.body : ""
captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
captured = { url, headers: new Headers(init?.headers), outerBody: bodyText ? JSON.parse(bodyText) : null }
return new Response(
JSON.stringify({
id: "chatcmpl-test",
@@ -129,4 +129,19 @@ describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
})
expect(upstream?.reasoning_effort).toBeUndefined()
})
test("uses current Cloudflare cache headers", async () => {
const aigateway = createAiGateway({
accountId: "test",
gateway: "test",
apiKey: "test",
options: { cacheTtl: 300, skipCache: true },
})
await generateText({ model: aigateway(createUnified()("openai/gpt-5.4")), prompt: "hi" })
expect(captured?.headers.get("cf-aig-cache-ttl")).toBe("300")
expect(captured?.headers.get("cf-aig-skip-cache")).toBe("true")
expect(captured?.headers.get("cf-cache-ttl")).toBeNull()
expect(captured?.headers.get("cf-skip-cache")).toBeNull()
})
})
@@ -73,7 +73,7 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.promptCacheKey).toBeUndefined()
})
test("should set promptCacheKey for openai provider regardless of setCacheKey", () => {
test("should set promptCacheKey for openai provider by default", () => {
const openaiModel = {
...mockModel,
providerID: "openai",
@@ -87,6 +87,82 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.promptCacheKey).toBe(sessionID)
})
test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => {
const result = ProviderTransform.options({
model: {
...mockModel,
providerID: "custom-openai",
api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" },
},
sessionID,
providerOptions: {},
})
expect(result.promptCacheKey).toBe(sessionID)
})
test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => {
const result = ProviderTransform.options({
model: {
...mockModel,
providerID: "openai",
api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" },
},
sessionID,
providerOptions: {},
})
expect(result.promptCacheKey).toBeUndefined()
})
test("should not set promptCacheKey for openai when explicitly disabled", () => {
const openaiModel = {
...mockModel,
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
}
const result = ProviderTransform.options({
model: openaiModel,
sessionID,
providerOptions: { setCacheKey: false },
})
expect(result.promptCacheKey).toBeUndefined()
})
test("should set promptCacheKey for the xAI SDK by default regardless of provider ID", () => {
const xaiModel = {
...mockModel,
providerID: "custom-xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({ model: xaiModel, sessionID, providerOptions: {} })
expect(result.promptCacheKey).toBe(sessionID)
})
test("should not set promptCacheKey for the xAI SDK when explicitly disabled", () => {
const xaiModel = {
...mockModel,
providerID: "xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({
model: xaiModel,
sessionID,
providerOptions: { setCacheKey: false },
})
expect(result.promptCacheKey).toBeUndefined()
})
test("should set store=false for openai provider", () => {
const openaiModel = {
...mockModel,
@@ -121,6 +197,55 @@ describe("ProviderTransform.options - setCacheKey", () => {
providerOptions: {},
})
expect(result.store).toBe(false)
expect(result.promptCacheKey).toBe(sessionID)
})
test("should disable the Azure cache key without disabling store=false", () => {
const result = ProviderTransform.options({
model: {
...mockModel,
providerID: "azure",
api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" },
},
sessionID,
providerOptions: { setCacheKey: false },
})
expect(result.store).toBe(false)
expect(result.promptCacheKey).toBeUndefined()
})
for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) {
test(`should set the snake-case cache key for ${npm}`, () => {
const result = ProviderTransform.options({
model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } },
sessionID,
providerOptions: {},
})
expect(result.prompt_cache_key).toBe(sessionID)
expect(result.promptCacheKey).toBeUndefined()
})
}
test("should set promptCacheKey for the Mistral SDK", () => {
const result = ProviderTransform.options({
model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } },
sessionID,
providerOptions: {},
})
expect(result.promptCacheKey).toBe(sessionID)
})
test("should not send an undocumented OpenRouter prompt_cache_key", () => {
const result = ProviderTransform.options({
model: {
...mockModel,
providerID: "openrouter",
api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" },
},
sessionID,
providerOptions: {},
})
expect(result.prompt_cache_key).toBeUndefined()
})
})
@@ -613,6 +738,17 @@ describe("ProviderTransform.providerOptions", () => {
})
})
test("uses canonical sdk key for custom xAI models", () => {
const model = createModel({
providerID: "my-xai",
api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" },
})
expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({
xai: { promptCacheKey: "session" },
})
})
test("forces reasoning for custom OpenAI package models with explicit effort", () => {
const model = createModel({
providerID: "meta",
@@ -2874,6 +3010,20 @@ describe("ProviderTransform.message - cache control on gateway", () => {
})
})
test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => {
const model = createModel({
providerID: "anthropic",
api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
})
const msgs = [
{ role: "system", content: "You are a helpful assistant" },
{ role: "user", content: "Hello" },
] as any[]
const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[]
expect(result.every((message) => message.providerOptions === undefined)).toBe(true)
})
test("google-vertex-anthropic applies cache control", () => {
const model = createModel({
providerID: "google-vertex-anthropic",
@@ -4636,6 +4786,13 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options)
})
}
test("includes the OpenAI cache key in small requests", () => {
expect(ProviderTransform.smallOptions(createModel("gpt-5-chat-latest"), "session-123")).toEqual({
store: false,
promptCacheKey: "session-123",
})
})
})
test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => {
@@ -1564,6 +1564,25 @@ describe("SessionNs.getUsage", () => {
expect(result.tokens.cache.read).toBe(200)
})
test("charges Cerebras cache reads at the normal input rate", () => {
const result = SessionNs.getUsage({
model: createModel({
context: 100_000,
output: 32_000,
npm: "@ai-sdk/cerebras",
cost: { input: 10, output: 0, cache: { read: 0, write: 0 } },
}),
usage: usage({
inputTokens: 1_000_000,
outputTokens: 0,
totalTokens: 1_000_000,
cacheReadInputTokens: 200_000,
}),
})
expect(result.cost).toBe(10)
})
test("handles anthropic cache write metadata", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
@@ -0,0 +1,43 @@
import { Show, type JSX, type ParentProps } from "solid-js"
import "./session-review-v2.css"
export function SessionFilePanelV2(props: {
sidebar?: JSX.Element
toolbar: boolean
toolbarStart?: JSX.Element
toolbarEnd?: JSX.Element
children?: JSX.Element
}) {
return (
<div data-component="session-review-v2">
<div data-slot="session-review-v2-body">
{props.sidebar}
<div data-slot="session-review-v2-preview">
<Show when={props.toolbar}>
<div data-slot="session-review-v2-toolbar">
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
{props.toolbarStart}
</div>
<Show when={props.toolbarEnd}>
{(toolbar) => (
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
{toolbar()}
</div>
)}
</Show>
</div>
</Show>
{props.children}
</div>
</div>
</div>
)
}
export function SessionFilePanelV2Title(props: ParentProps) {
return <div data-slot="session-review-v2-toolbar-title">{props.children}</div>
}
export function SessionFilePanelV2Empty(props: ParentProps) {
return <div data-slot="session-review-v2-empty">{props.children}</div>
}
@@ -12,7 +12,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
import { getWorkerPool } from "../../pierre/worker"
import "./session-review-v2.css"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200
@@ -45,6 +45,11 @@ export type SessionReviewV2SidebarProps = {
filter: string
onFilterChange: (value: string) => void
onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
filterAutofocus?: boolean
filterRef?: (element: HTMLInputElement) => void
filterControls?: string
filterActiveDescendant?: string
filterExpanded?: boolean
width?: number
onWidthChange?: (width: number) => void
minWidth?: number
@@ -69,55 +74,62 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
return (
<div data-component="session-review-v2-sidebar-root">
<aside
data-slot="session-review-v2-sidebar"
data-resizing={resizing() ? "" : undefined}
aria-hidden={!props.open}
inert={!props.open}
style={{ width: props.open ? `${width()}px` : "0px" }}
>
<div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
{props.stats}
</div>
<div data-slot="session-review-v2-sidebar-filter">
<TextInputV2
type="search"
value={props.filter}
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onKeyDown={props.onFilterKeyDown}
showClearButton={props.filter.length > 0}
clearLabel={i18n.t("ui.list.clearFilter")}
onClearClick={() => props.onFilterChange("")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
leadingIcon={
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
}
/>
</div>
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
<Show when={props.open}>
<aside
data-slot="session-review-v2-sidebar"
data-resizing={resizing() ? "" : undefined}
style={{ width: `${width()}px` }}
>
{props.children}
</ScrollView>
</aside>
<div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
{props.stats}
</div>
<div data-slot="session-review-v2-sidebar-filter">
<TextInputV2
type="search"
value={props.filter}
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onKeyDown={props.onFilterKeyDown}
autofocus={props.filterAutofocus}
ref={props.filterRef}
role={props.filterControls ? "combobox" : undefined}
aria-autocomplete={props.filterControls ? "list" : undefined}
aria-controls={props.filterControls}
aria-activedescendant={props.filterActiveDescendant}
aria-expanded={props.filterControls ? props.filterExpanded : undefined}
showClearButton={props.filter.length > 0}
clearLabel={i18n.t("ui.list.clearFilter")}
onClearClick={() => props.onFilterChange("")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
leadingIcon={
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
}
/>
</div>
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
>
{props.children}
</ScrollView>
</aside>
</Show>
<Show when={props.open && props.onWidthChange}>
<div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>
<ResizeHandle
@@ -185,130 +197,125 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
cycle(event.key === "<" ? prev() : next())
})
return (
<div data-component="session-review-v2">
<div data-slot="session-review-v2-body">
{props.sidebar}
<div data-slot="session-review-v2-preview">
<Show when={props.hasDiffs} fallback={props.empty}>
<div data-slot="session-review-v2-toolbar">
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
{props.sidebarToggle}
<Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}>
<div data-slot="session-review-v2-toolbar-title">{title()}</div>
</Show>
{stats()}
<Show when={canCycle()}>
<span data-slot="session-review-v2-file-position">
{fileIndex() + 1}/{props.files.length}
</span>
</Show>
</div>
</Show>
<div data-slot="session-review-v2-toolbar-group">
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={["<"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-left"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => {
const file = prev()
if (!file) return
props.onSelectFile(file)
}}
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[">"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-right"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => {
const file = next()
if (!file) return
props.onSelectFile(file)
}}
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
/>
</TooltipV2>
</div>
</div>
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
<SegmentedControlV2
value={props.expandMode}
onChange={(value) => {
if (value !== "expand" && value !== "collapse") return
props.onExpandModeChange(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
<Show when={props.onDiffStyleChange}>
<SegmentedControlV2
value={props.diffStyle}
onChange={(value) => {
if (value !== "unified" && value !== "split") return
props.onDiffStyleChange?.(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
</Show>
</div>
</div>
<Show when={props.activeFile} fallback={<div data-slot="session-review-v2-empty">{props.empty}</div>}>
{props.preview}
</Show>
const toolbarStart = () => (
<>
{props.sidebarToggle}
<Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}>
<div data-slot="session-review-v2-toolbar-title">{title()}</div>
</Show>
{stats()}
<Show when={canCycle()}>
<span data-slot="session-review-v2-file-position">
{fileIndex() + 1}/{props.files.length}
</span>
</Show>
</div>
</Show>
<div class="flex items-center">
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={["<"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-left"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => cycle(prev())}
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[">"]} variant="neutral" />
</>
}
>
<IconButton
icon="arrow-right"
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
onClick={() => cycle(next())}
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
/>
</TooltipV2>
</div>
</div>
</>
)
const toolbarEnd = () => (
<>
<SegmentedControlV2
value={props.expandMode}
onChange={(value) => {
if (value !== "expand" && value !== "collapse") return
props.onExpandModeChange(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
<Show when={props.onDiffStyleChange}>
<SegmentedControlV2
value={props.diffStyle}
onChange={(value) => {
if (value !== "unified" && value !== "split") return
props.onDiffStyleChange?.(value)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
</TooltipV2>
</SegmentedControlV2>
</Show>
</>
)
return (
<SessionFilePanelV2
sidebar={props.sidebar}
toolbar={props.hasDiffs}
toolbarStart={toolbarStart()}
toolbarEnd={toolbarEnd()}
>
<Show when={props.hasDiffs} fallback={props.empty}>
<Show when={props.activeFile} fallback={<SessionFilePanelV2Empty>{props.empty}</SessionFilePanelV2Empty>}>
{props.preview}
</Show>
</Show>
</SessionFilePanelV2>
)
}
@@ -33,6 +33,12 @@ import {
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
ComparisonCardsSection,
modelRefFromCatalog,
uniqueComparisonPairs,
type ComparisonModelRef,
} from "../compare-cards"
import {
applyThemePreference,
Footer,
@@ -189,6 +195,11 @@ export default function StatsModel() {
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
<ModelPeersSection data={stats() ?? null} />
<ComparisonCardsSection
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
title="Compare This Model"
description="Other models to compare with this one."
/>
</>
</Show>
</Show>
@@ -197,6 +208,7 @@ export default function StatsModel() {
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={modelFooterLinks()}
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
/>
</div>
</main>
@@ -1172,6 +1184,55 @@ function ModelEmptyState(props: { title: string; description: string; compact?:
)
}
function modelComparisonPairs(
catalog: ModelCatalog | undefined,
catalogEntry: ModelCatalogEntry | null,
data: StatsModelData | null,
) {
const current = modelComparisonRef(catalogEntry, data)
if (!current) return []
const peerPairs = (data?.peers ?? [])
.filter((peer) => peer.model !== data?.model)
.slice(0, 3)
.map((peer) => ({
first: current,
second: {
name: peer.model,
lab: peer.provider,
slug: peer.slug,
labName: peer.author,
metric: `#${peer.rank} / ${formatTokens(peer.tokens)}`,
},
detail: "Usage peer",
}))
const catalogPairs = (
catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : []
)
.filter((model) => model.id !== catalogEntry?.id)
.slice(0, 3)
.map((model) => ({
first: current,
second: modelRefFromCatalog(model),
detail: "Same lab pair",
}))
return uniqueComparisonPairs([...peerPairs, ...catalogPairs])
}
function modelComparisonRef(
catalogEntry: ModelCatalogEntry | null,
data: StatsModelData | null,
): ComparisonModelRef | undefined {
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
if (!data) return undefined
return {
name: data.model,
lab: data.provider,
slug: data.slug,
labName: data.author,
metric: `#${data.rank}`,
}
}
function getProviderIconId(author: string) {
if (author === "MiniMax") return "minimax"
if (author === "Moonshot") return "moonshotai"
@@ -28,6 +28,7 @@ import {
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
import {
applyThemePreference,
Footer,
@@ -149,6 +150,11 @@ export default function StatsLab() {
labs={catalog()?.labs ?? []}
market={homeStats()?.market["2M"] ?? []}
/>
<ComparisonCardsSection
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
title={`${data().name} Model Comparisons`}
description="Model pairs from this lab."
/>
</>
)}
</Show>
@@ -158,6 +164,7 @@ export default function StatsLab() {
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={labFooterLinks()}
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
/>
</div>
</main>
@@ -732,6 +739,31 @@ function LabEmptyState(props: { title: string; description: string }) {
)
}
function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) {
const usageRefs = usage.slice(0, 4).map((model) => ({
name: model.model,
lab: model.provider,
slug: model.slug,
labName: model.author,
metric: formatTokens(model.tokens),
}))
const refs = usageRefs.length > 1 ? usageRefs : lab.models.slice(0, 4).map(modelRefFromCatalog)
return uniqueComparisonPairs(
(
[
[0, 1, "Most-used lab pair"],
[0, 2, "Lab alternative"],
[1, 2, "Adjacent lab pair"],
[2, 3, "Same lab pair"],
] as const
).flatMap(([firstIndex, secondIndex, detail]) => {
const first = refs[firstIndex]
const second = refs[secondIndex]
return first && second ? [{ first, second, detail }] : []
}),
)
}
type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number }
function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
@@ -0,0 +1,88 @@
import { For, Show } from "solid-js"
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
export type ComparisonModelRef = {
name: string
lab: string
slug: string
labName?: string
metric?: string
}
export type ComparisonPair = {
first: ComparisonModelRef
second: ComparisonModelRef
detail: string
}
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
return {
name: entry.name,
lab: entry.lab,
slug: entry.slug,
labName: formatCatalogLabName(entry.lab),
}
}
export function comparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) {
return `${import.meta.env.BASE_URL}compare/${catalogSlug(first.lab)}/${catalogSlug(first.slug)}/${catalogSlug(
second.lab,
)}/${catalogSlug(second.slug)}`
}
export function uniqueComparisonPairs(pairs: ComparisonPair[]) {
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
(result, pair) => {
const key = [modelKey(pair.first), modelKey(pair.second)].toSorted().join("|")
if (result.keys.has(key) || modelKey(pair.first) === modelKey(pair.second)) return result
result.keys.add(key)
result.pairs.push(pair)
return result
},
{ keys: new Set(), pairs: [] },
).pairs
}
export function ComparisonCardsSection(props: {
pairs: ComparisonPair[]
title?: string
description?: string
compact?: boolean
}) {
return (
<Show when={props.pairs.length > 0}>
<section id="model-comparison" data-section="model-panel" data-variant={props.compact ? "compact" : undefined}>
<p data-slot="section-title">
<strong>{props.title ?? "Model Comparisons"}.</strong>{" "}
<span>{props.description ?? "Compare usage, cost, limits, and features."}</span>
</p>
<div data-component="comparison-card-grid">
<For each={props.pairs}>
{(pair) => (
<a data-component="comparison-card" href={comparisonHref(pair.first, pair.second)}>
<span>{pair.detail}</span>
<strong>
{pair.first.name} <em>vs</em> {pair.second.name}
</strong>
<p>
<b>{pair.first.labName ?? formatCatalogLabName(pair.first.lab)}</b>
<i />
<b>{pair.second.labName ?? formatCatalogLabName(pair.second.lab)}</b>
</p>
<Show when={pair.first.metric || pair.second.metric}>
<small>
{pair.first.metric ?? "Listed"} / {pair.second.metric ?? "Listed"}
</small>
</Show>
</a>
)}
</For>
</div>
</section>
</Show>
)
}
function modelKey(model: ComparisonModelRef) {
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
}
@@ -0,0 +1,83 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import { comparisonHref, modelRefFromCatalog } from "./compare-cards"
import type { ModelCatalogEntry } from "./model-catalog"
export function ComparisonSelector(props: {
models: ModelCatalogEntry[]
firstId?: string
secondId?: string
label?: string
}) {
const [firstId, setFirstId] = createSignal(props.firstId ?? props.models[0]?.id ?? "")
const [secondId, setSecondId] = createSignal(differentModelId(props.models, firstId(), props.secondId))
const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model])))
const href = createMemo(() => {
const first = modelById().get(firstId())
const second = modelById().get(secondId())
if (!first || !second || first.id === second.id) return undefined
return comparisonHref(modelRefFromCatalog(first), modelRefFromCatalog(second))
})
createEffect(() => {
if (firstId() && modelById().has(firstId())) return
const next = props.firstId && modelById().has(props.firstId) ? props.firstId : props.models[0]?.id
if (next) setFirstId(next)
})
createEffect(() => {
if (secondId() && secondId() !== firstId() && modelById().has(secondId())) return
setSecondId(differentModelId(props.models, firstId(), props.secondId))
})
return (
<form
data-component="comparison-selector"
aria-label={props.label ?? "Model comparison selector"}
onSubmit={(event) => {
event.preventDefault()
const url = href()
if (!url || typeof window === "undefined") return
window.location.href = url
}}
>
<label>
<span>First model</span>
<select value={firstId()} onInput={(event) => setFirstId(event.currentTarget.value)} required>
<option value="" disabled>
Select model
</option>
<For each={props.models}>
{(model) => (
<option value={model.id}>
{model.name} ({model.lab})
</option>
)}
</For>
</select>
</label>
<label>
<span>Second model</span>
<select value={secondId()} onInput={(event) => setSecondId(event.currentTarget.value)} required>
<option value="" disabled>
Select model
</option>
<For each={props.models}>
{(model) => (
<option value={model.id} disabled={model.id === firstId()}>
{model.name} ({model.lab})
</option>
)}
</For>
</select>
</label>
<button type="submit" disabled={!href()}>
Compare models
</button>
</form>
)
}
function differentModelId(models: ModelCatalogEntry[], firstId: string, preferredId: string | undefined) {
if (preferredId && preferredId !== firstId && models.some((model) => model.id === preferredId)) return preferredId
return models.find((model) => model.id !== firstId)?.id ?? ""
}
@@ -0,0 +1,611 @@
import "../../../../index.css"
import { Link, Meta, Title } from "@solidjs/meta"
import { getStatsModelComparisonData, type StatsModelComparisonEntry } from "@opencode-ai/stats-core/domain/home"
import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import {
ComparisonCardsSection,
modelRefFromCatalog,
uniqueComparisonPairs,
type ComparisonModelRef,
type ComparisonPair,
} from "../../../../compare-cards"
import { ComparisonSelector } from "../../../../compare-selector"
import {
catalogSlug,
findModelCatalogEntry,
formatCatalogLabName,
getModelCatalog,
type ModelCatalog,
type ModelCatalogEntry,
} from "../../../../model-catalog"
import {
applyThemePreference,
Footer,
getGitHubStars,
Header,
isThemePreference,
themeStorageKey,
type HeaderLink,
type ThemePreference,
} from "../../../../stats-shell"
const compareFallbackUrl = "https://stats.opencode.ai"
const compareHeaderLinks: readonly HeaderLink[] = [
{ href: "#overview", label: "Overview" },
{ href: "#comparison", label: "Comparison" },
{ href: "#compare-tool", label: "Compare" },
{ href: "#model-comparison", label: "Related" },
]
const compareFooterLinks: readonly HeaderLink[] = [
{ href: import.meta.env.BASE_URL, label: "Data Home" },
{ href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" },
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
]
type ComparisonModel = {
name: string
lab: string
labName: string
slug: string
catalog: ModelCatalogEntry | null
stats: StatsModelComparisonEntry | null
}
type ComparisonDirection = "higher" | "lower"
type ComparisonCell = { value: string; detail?: string; score?: number }
type ComparisonRow = {
label: string
description: string
direction: ComparisonDirection
cells: [ComparisonCell, ComparisonCell]
}
const getComparisonData = query(
async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => {
"use server"
return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel))
},
"getStatsModelComparisonData",
)
export default function ModelComparePair() {
const event = getRequestEvent()
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
const params = useParams()
const firstLabParam = createMemo(() => params.firstLab ?? "")
const firstModelParam = createMemo(() => params.firstModel ?? "")
const secondLabParam = createMemo(() => params.secondLab ?? "")
const secondModelParam = createMemo(() => params.secondModel ?? "")
const catalog = createAsync(() => getModelCatalog())
const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam()))
const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam()))
const stats = createAsync(() => {
if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined)
return Promise.resolve(undefined)
return getComparisonData(
firstCatalog()?.lab ?? firstLabParam(),
firstCatalog()?.slug ?? firstModelParam(),
secondCatalog()?.lab ?? secondLabParam(),
secondCatalog()?.slug ?? secondModelParam(),
)
})
const githubStars = createAsync(() => getGitHubStars())
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
const models = createMemo(
() =>
[
buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null),
buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null),
] as const,
)
const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`)
const description = createMemo(
() =>
`Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`,
)
const canonicalPath = createMemo(
() =>
`${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug(
models()[1].lab,
)}/${catalogSlug(models()[1].slug)}`,
)
const canonicalUrl = createMemo(() =>
new URL(
canonicalPath(),
event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href),
).toString(),
)
const rows = createMemo(() => buildComparisonRows(models()[0], models()[1]))
const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1]))
const selectorModels = createMemo(() =>
uniqueCatalogModels([
comparisonCatalogEntry(models()[0]),
comparisonCatalogEntry(models()[1]),
...(catalog()?.models ?? []),
]),
)
const structuredData = createMemo(() =>
JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
name: title(),
description: description(),
url: canonicalUrl(),
about: models().map((model) => ({
"@type": "SoftwareApplication",
name: model.name,
applicationCategory: "AI model",
provider: model.labName,
})),
}),
)
const updateThemePreference = (preference: ThemePreference) => {
applyThemePreference(preference)
setThemePreference(preference)
if (typeof window === "undefined") return
window.localStorage.setItem(themeStorageKey, preference)
}
onMount(() => {
if (typeof window === "undefined") return
const preference = window.localStorage.getItem(themeStorageKey)
const nextPreference = isThemePreference(preference) ? preference : "system"
applyThemePreference(nextPreference)
setThemePreference(nextPreference)
})
return (
<main data-page="stats" data-theme={themePreference()}>
<Title>{title()}</Title>
<Meta name="description" content={description()} />
<Link rel="canonical" href={canonicalUrl()} />
<Meta property="og:type" content="website" />
<Meta property="og:site_name" content="OpenCode" />
<Meta property="og:title" content={title()} />
<Meta property="og:description" content={description()} />
<Meta property="og:url" content={canonicalUrl()} />
<Meta name="twitter:card" content="summary" />
<Meta name="twitter:title" content={title()} />
<Meta name="twitter:description" content={description()} />
<script type="application/ld+json">{structuredData()}</script>
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
<div data-component="container">
<div data-component="content">
<ComparisonHero models={models()} />
<section id="comparison" data-section="model-panel">
<p data-slot="section-title">
<strong>Comparison Table.</strong> <span>Compare usage, cost, limits, and features.</span>
</p>
<Show
when={stats() !== undefined}
fallback={
<div data-component="empty-state" data-compact="true">
<strong>Loading comparison</strong>
<p>Loading stats for both models.</p>
</div>
}
>
<ComparisonTable models={models()} rows={rows()} />
</Show>
</section>
<section id="compare-tool" data-section="model-panel" data-variant="compact">
<p data-slot="section-title">
<strong>Compare Another Pair.</strong> <span>Choose two models to compare.</span>
</p>
<Show
when={selectorModels().length > 1}
fallback={
<div data-component="empty-state" data-compact="true">
<strong>No models found</strong>
<p>The model list could not be loaded.</p>
</div>
}
>
<ComparisonSelector
models={selectorModels()}
firstId={comparisonCatalogEntry(models()[0]).id}
secondId={comparisonCatalogEntry(models()[1]).id}
/>
</Show>
</section>
<ComparisonCardsSection
pairs={relatedPairs()}
title="Related Model Comparisons"
description="Other model pairs to check."
/>
</div>
<Footer
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={compareFooterLinks}
bridge={{ href: "#comparison", label: "COMPARE TABLE" }}
/>
</div>
</main>
)
}
function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) {
return (
<section id="overview" data-section="model-hero">
<a data-slot="model-back-link" href={`${import.meta.env.BASE_URL}compare`}>
Compare
</a>
<div data-slot="model-hero-copy">
<h1>
{props.models[0].name} vs {props.models[1].name}
</h1>
<p>Compare usage, cost, limits, and features for these two models.</p>
</div>
<div data-slot="model-hero-pattern" aria-hidden="true" />
</section>
)
}
function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) {
return (
<div data-component="comparison-table-wrap">
<table data-component="comparison-table">
<caption>
{props.models[0].name} compared with {props.models[1].name}
</caption>
<thead>
<tr>
<th scope="col">Metric</th>
<For each={props.models}>{(model) => <th scope="col">{model.name}</th>}</For>
</tr>
</thead>
<tbody>
<For each={props.rows}>
{(row) => {
const best = () => bestCellIndex(row)
return (
<tr>
<th scope="row">
<strong>{row.label}</strong>
<span>{row.description}</span>
</th>
<For each={row.cells}>
{(cell, index) => (
<td data-best={best() === index() ? "true" : undefined}>
<strong>{cell.value}</strong>
<Show when={cell.detail}>{(detail) => <span>{detail()}</span>}</Show>
</td>
)}
</For>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
)
}
function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) {
if (!catalog) return undefined
return findModelCatalogEntry(catalog, model, lab) ?? null
}
function buildComparisonModel(
labParam: string,
modelParam: string,
catalog: ModelCatalogEntry | null,
stats: StatsModelComparisonEntry | null,
): ComparisonModel {
return {
name: catalog?.name ?? stats?.model ?? formatParamName(modelParam),
lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam),
labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam),
slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam),
catalog,
stats,
}
}
function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry {
if (model.catalog) return model.catalog
return {
id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`,
lab: catalogSlug(model.lab),
slug: catalogSlug(model.slug),
name: model.name,
modalities: { input: [], output: [] },
openWeights: false,
reasoning: false,
toolCall: false,
attachment: false,
temperature: false,
weights: [],
benchmarks: [],
}
}
function uniqueCatalogModels(models: ModelCatalogEntry[]) {
return Object.values(
models.reduce<Record<string, ModelCatalogEntry>>((result, model) => {
result[model.id] = result[model.id] ?? model
return result
}, {}),
)
}
function buildComparisonRows(first: ComparisonModel, second: ComparisonModel): ComparisonRow[] {
return [
comparisonRow(
"Recent Rank",
"Lower is better.",
{
value: first.stats?.rank == null ? "No usage" : `#${first.stats.rank}`,
score: first.stats?.rank ?? undefined,
},
{
value: second.stats?.rank == null ? "No usage" : `#${second.stats.rank}`,
score: second.stats?.rank ?? undefined,
},
"lower",
),
comparisonRow(
"Token Share",
"Share of recent OpenCode usage.",
{ value: first.stats ? formatPercent(first.stats.tokenShare) : "No usage", score: first.stats?.tokenShare },
{ value: second.stats ? formatPercent(second.stats.tokenShare) : "No usage", score: second.stats?.tokenShare },
"higher",
),
comparisonRow(
"Tokens",
"Recent token volume.",
{ value: first.stats ? formatTokens(first.stats.totals.tokens) : "No usage", score: first.stats?.totals.tokens },
{
value: second.stats ? formatTokens(second.stats.totals.tokens) : "No usage",
score: second.stats?.totals.tokens,
},
"higher",
),
comparisonRow(
"Sessions",
"Recent session count.",
{
value: first.stats ? formatInteger(first.stats.totals.sessions) : "No usage",
score: first.stats?.totals.sessions,
},
{
value: second.stats ? formatInteger(second.stats.totals.sessions) : "No usage",
score: second.stats?.totals.sessions,
},
"higher",
),
comparisonRow(
"Cost / 1M Tokens",
"Lower is better.",
{
value: first.stats ? formatMoney(first.stats.totals.costPerMillion) : "No usage",
score: positiveScore(first.stats?.totals.costPerMillion),
},
{
value: second.stats ? formatMoney(second.stats.totals.costPerMillion) : "No usage",
score: positiveScore(second.stats?.totals.costPerMillion),
},
"lower",
),
comparisonRow(
"Cost / Session",
"Lower is better.",
{
value: first.stats ? formatSessionCost(first.stats.totals.costPerSession) : "No usage",
score: positiveScore(first.stats?.totals.costPerSession),
},
{
value: second.stats ? formatSessionCost(second.stats.totals.costPerSession) : "No usage",
score: positiveScore(second.stats?.totals.costPerSession),
},
"lower",
),
comparisonRow(
"Cache Ratio",
"Higher is better.",
{
value: first.stats ? formatPercent(first.stats.totals.cacheRatio) : "No usage",
score: first.stats?.totals.cacheRatio,
},
{
value: second.stats ? formatPercent(second.stats.totals.cacheRatio) : "No usage",
score: second.stats?.totals.cacheRatio,
},
"higher",
),
comparisonRow(
"Context Window",
"Higher limit is better.",
{
value: formatCatalogLimit(first.catalog?.limit?.context),
score: first.catalog?.limit?.context,
},
{
value: formatCatalogLimit(second.catalog?.limit?.context),
score: second.catalog?.limit?.context,
},
"higher",
),
comparisonRow(
"Output Limit",
"Higher limit is better.",
{
value: formatCatalogLimit(first.catalog?.limit?.output),
score: first.catalog?.limit?.output,
},
{
value: formatCatalogLimit(second.catalog?.limit?.output),
score: second.catalog?.limit?.output,
},
"higher",
),
comparisonRow(
"Release Date",
"Newer release is highlighted.",
{
value: formatCatalogDate(first.catalog?.releaseDate),
score: catalogDateScore(first.catalog?.releaseDate),
},
{
value: formatCatalogDate(second.catalog?.releaseDate),
score: catalogDateScore(second.catalog?.releaseDate),
},
"higher",
),
comparisonRow(
"Reasoning",
"Supports reasoning.",
booleanCell(first.catalog?.reasoning),
booleanCell(second.catalog?.reasoning),
"higher",
),
comparisonRow(
"Tool Calling",
"Supports tool calls.",
booleanCell(first.catalog?.toolCall),
booleanCell(second.catalog?.toolCall),
"higher",
),
comparisonRow(
"Attachments",
"Supports attachments.",
booleanCell(first.catalog?.attachment),
booleanCell(second.catalog?.attachment),
"higher",
),
comparisonRow(
"Open Weights",
"Open weights available.",
booleanCell(first.catalog?.openWeights),
booleanCell(second.catalog?.openWeights),
"higher",
),
]
}
function comparisonRow(
label: string,
description: string,
first: ComparisonCell,
second: ComparisonCell,
direction: ComparisonDirection,
): ComparisonRow {
return { label, description, direction, cells: [first, second] }
}
function bestCellIndex(row: ComparisonRow) {
const [first, second] = row.cells.map((cell) => cell.score)
if (first === undefined || second === undefined || first === second) return undefined
if (row.direction === "higher") return first > second ? 0 : 1
return first < second ? 0 : 1
}
function buildRelatedPairs(
catalog: ModelCatalog | undefined,
first: ComparisonModel,
second: ComparisonModel,
): ComparisonPair[] {
const current = [comparisonRef(first), comparisonRef(second)] as const
const alternatives = (catalog?.models ?? [])
.filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id)
.slice(0, 4)
.map(modelRefFromCatalog)
return uniqueComparisonPairs([
...alternatives.slice(0, 3).flatMap((model, index) => [
{ first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
{ first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
]),
]).slice(0, 6)
}
function comparisonRef(model: ComparisonModel): ComparisonModelRef {
return {
name: model.name,
lab: model.lab,
slug: model.slug,
labName: model.labName,
metric: model.stats ? `#${model.stats.rank}` : "Catalog",
}
}
function positiveScore(value: number | undefined) {
return value && value > 0 ? value : undefined
}
function booleanCell(value: boolean | undefined): ComparisonCell {
if (value === undefined) return { value: "Unknown" }
return { value: value ? "Yes" : "No", score: value ? 1 : 0 }
}
function catalogDateScore(value: string | undefined) {
if (!value) return undefined
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return undefined
return Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1)
}
function formatParamName(value: string) {
return value
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase())
.trim()
}
function formatCatalogLimit(value: number | undefined) {
return value === undefined ? "Unknown" : formatTokens(value)
}
function formatCatalogDate(value: string | undefined) {
if (!value) return "Unknown"
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return value
const year = Number(match[1])
const month = match[2] ? Number(match[2]) - 1 : 0
const day = match[3] ? Number(match[3]) : 1
return new Intl.DateTimeFormat("en", {
month: match[2] ? "short" : undefined,
day: match[3] ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(year, month, day)))
}
function formatTokens(value: number) {
if (value >= 1_000_000_000_000)
return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
return String(Math.round(value))
}
function formatInteger(value: number) {
return new Intl.NumberFormat("en").format(value)
}
function formatPercent(value: number) {
return `${trimNumber(value, value >= 10 ? 1 : 2)}%`
}
function formatMoney(value: number) {
if (value >= 1) return `$${trimNumber(value, 2)}`
if (value > 0) return `$${value.toFixed(4)}`
return "$0"
}
function formatSessionCost(value: number) {
if (value >= 1) return `$${trimNumber(value, 2)}`
if (value >= 0.01) return `$${value.toFixed(2)}`
if (value > 0) return `$${value.toFixed(4)}`
return "$0"
}
function trimNumber(value: number, digits: number) {
return Number(value.toFixed(digits)).toLocaleString("en")
}
@@ -0,0 +1,642 @@
import "../index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { createAsync } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { LocaleLinks } from "../../component/locale-links"
import { useI18n } from "../../context/i18n"
import { useLanguage } from "../../context/language"
import { localizedUrl } from "../../lib/language"
import { comparisonHref, modelRefFromCatalog, type ComparisonModelRef } from "../compare-cards"
import { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
applyThemePreference,
Footer,
getGitHubStars,
Header,
isThemePreference,
themeStorageKey,
type HeaderLink,
type ThemePreference,
} from "../stats-shell"
const comparePath = "/data/compare"
const compareTitle = "AI Model Comparison"
const compareDescription =
"Compare AI models on key metrics including benchmarks, price, context length, and other model features."
const statsUnfurlPath = "banner.png"
const uptimeBars = Array.from({ length: 10 }, (_, index) => index)
const heroLabs = [
{ lab: "deepseek", label: "DeepSeek" },
{ lab: "openai", label: "OpenAI" },
{ lab: "anthropic", label: "Anthropic" },
] as const
const categoryTemplates = [
{
title: "Flagship models",
description: "The latest top-tier model from major labs.",
kind: "flagship",
},
{
title: "Most affordable",
description: "Compact, lower-cost picks for high-throughput use.",
kind: "affordable",
},
{
title: "Best for code",
description: "Frequently chosen for programming tasks.",
kind: "code",
},
{
title: "Image generation",
description: "Compare top image models on cost and quality.",
kind: "image",
},
] as const
type CompareCategory = {
title: string
description: string
first: ComparisonModelRef
second: ComparisonModelRef
avatars: ComparisonModelRef[]
}
type CompareSlot = "first" | "second"
export default function ModelCompareIndex() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
setStatsPageCacheHeaders(event?.response.headers)
const catalog = createAsync(() => getModelCatalog())
const githubStars = createAsync(() => getGitHubStars())
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath))
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120))
const categories = createMemo(() => buildComparisonCategories(featuredModels()))
const compareHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
{ href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") },
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") },
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") },
])
const compareFooterLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
{ href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") },
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") },
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") },
])
const updateThemePreference = (preference: ThemePreference) => {
applyThemePreference(preference)
setThemePreference(preference)
if (typeof window === "undefined") return
window.localStorage.setItem(themeStorageKey, preference)
}
onMount(() => {
if (typeof window === "undefined") return
const preference = window.localStorage.getItem(themeStorageKey)
const nextPreference = isThemePreference(preference) ? preference : "system"
applyThemePreference(nextPreference)
setThemePreference(nextPreference)
})
return (
<main data-page="stats" data-theme={themePreference()}>
<Title>{compareTitle}</Title>
<Meta name="description" content={compareDescription} />
<LocaleLinks path={comparePath} />
<Meta property="og:type" content="website" />
<Meta property="og:site_name" content="OpenCode" />
<Meta property="og:title" content={compareTitle} />
<Meta property="og:description" content={compareDescription} />
<Meta property="og:url" content={compareUrl()} />
<Meta property="og:image" content={statsUnfurlUrl} />
<Meta property="og:image:type" content="image/png" />
<Meta property="og:image:width" content="1200" />
<Meta property="og:image:height" content="630" />
<Meta property="og:image:alt" content={i18n.t("app.unfurlAlt")} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={compareTitle} />
<Meta name="twitter:description" content={compareDescription} />
<Meta name="twitter:image" content={statsUnfurlUrl} />
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
<div data-component="container">
<div data-component="content">
<section id="compare-tool" data-section="compare-home-hero">
<nav data-component="compare-home-breadcrumb" aria-label="Breadcrumb">
<a data-slot="compare-home-crumb" href={language.route(import.meta.env.BASE_URL)}>
Data
</a>
<span data-slot="compare-home-separator">/</span>
<span data-slot="compare-home-crumb" data-current="true">
Compare
</span>
</nav>
<div data-slot="compare-home-hero-grid">
<h1 aria-label="Compare AI models">
<span>Compare</span>
<HeroModelStack />
<span>AI models</span>
</h1>
<p>{compareDescription}</p>
</div>
<div data-slot="compare-home-pattern" aria-hidden="true" />
</section>
<section data-section="compare-home-selector" aria-label="Choose models to compare">
<Show
when={featuredModels().length > 1}
fallback={
<div data-component="empty-state" data-compact="true">
<strong>No models found</strong>
<p>The model list could not be loaded.</p>
</div>
}
>
<CompareHomeSelector models={featuredModels()} />
</Show>
</section>
<Show when={categories().length > 0}>
<section id="model-comparison" data-section="compare-home-related">
<p data-slot="section-title">
<strong>Related comparisons.</strong> <span>Other model pairs to check.</span>
</p>
<div data-component="compare-home-card-grid">
<For each={categories()}>{(category) => <CompareHomeCard category={category} />}</For>
</div>
</section>
</Show>
</div>
<Footer
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={compareFooterLinks()}
bridge={null}
/>
</div>
</main>
)
}
function CompareHomeSelector(props: { models: ModelCatalogEntry[] }) {
const [firstId, setFirstId] = createSignal("")
const [secondId, setSecondId] = createSignal("")
const [activeSlot, setActiveSlot] = createSignal<CompareSlot>()
const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model])))
const first = createMemo(() => modelById().get(firstId()))
const second = createMemo(() => modelById().get(secondId()))
const activeSelected = createMemo(() => (activeSlot() === "first" ? first() : second()))
const activeBlockedId = createMemo(() => (activeSlot() === "first" ? secondId() : firstId()))
const href = createMemo(() => {
const firstModel = first()
const secondModel = second()
if (!firstModel || !secondModel || firstModel.id === secondModel.id) return undefined
return comparisonHref(modelRefFromCatalog(firstModel), modelRefFromCatalog(secondModel))
})
createEffect(() => {
if (!secondId() || secondId() !== firstId()) return
setSecondId("")
})
createEffect(() => {
const url = href()
if (!url || typeof window === "undefined") return
window.location.href = url
})
return (
<form
data-component="compare-home-selector"
aria-label="Model comparison selector"
onSubmit={(event) => event.preventDefault()}
>
<CompareHomeSelect
selected={first()}
label="First model"
expanded={activeSlot() === "first"}
onOpen={() => setActiveSlot("first")}
/>
<CompareHomeSelect
selected={second()}
label="Second model"
expanded={activeSlot() === "second"}
onOpen={() => setActiveSlot("second")}
/>
<Show when={activeSlot()}>
{(slot) => (
<CompareModelSelectModal
models={props.models}
selected={activeSelected()}
blockedId={activeBlockedId()}
label={slot() === "first" ? "Choose first model" : "Choose second model"}
onClose={() => setActiveSlot(undefined)}
onSelect={(model) => {
if (slot() === "first") setFirstId(model.id)
if (slot() === "second") setSecondId(model.id)
setActiveSlot(undefined)
}}
/>
)}
</Show>
</form>
)
}
function CompareHomeSelect(props: {
selected: ModelCatalogEntry | undefined
label: string
expanded: boolean
onOpen: () => void
}) {
return (
<button
type="button"
data-slot="compare-home-select-panel"
data-selected={props.selected ? "true" : undefined}
aria-label={props.label}
aria-haspopup="dialog"
aria-expanded={props.expanded}
onClick={props.onOpen}
>
<span data-slot="compare-home-select-copy">
<span data-slot="compare-home-plus" aria-hidden="true">
+
</span>
<Show when={props.selected}>{(model) => <ModelAvatar model={model()} size="tiny" />}</Show>
<span data-slot="compare-home-select-name">{props.selected?.name ?? "Select a model"}</span>
</span>
</button>
)
}
function CompareModelSelectModal(props: {
models: ModelCatalogEntry[]
selected: ModelCatalogEntry | undefined
blockedId: string
label: string
onClose: () => void
onSelect: (model: ModelCatalogEntry) => void
}) {
let searchInput: HTMLInputElement | undefined
const [search, setSearch] = createSignal("")
const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "")
const availableModels = createMemo(() => props.models.filter((model) => model.id !== props.blockedId))
const filteredModels = createMemo(() => {
const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return availableModels()
return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term)))
})
const preview = createMemo(
() => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0],
)
createEffect(() => {
const selected = props.selected
const fallback = selected && selected.id !== props.blockedId ? selected.id : filteredModels()[0]?.id
if (!fallback) return
if (availableModels().some((model) => model.id === previewId())) return
setPreviewId(fallback)
})
createEffect(() => {
const models = filteredModels()
if (models.some((model) => model.id === previewId())) return
setPreviewId(models[0]?.id ?? "")
})
createEffect(() => {
if (typeof window === "undefined") return
window.requestAnimationFrame(() => searchInput?.focus())
})
return (
<div
data-component="compare-model-modal-scrim"
role="presentation"
onClick={props.onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") return
props.onClose()
}}
>
<div
data-component="compare-model-modal"
role="dialog"
aria-modal="true"
aria-label={props.label}
onClick={(event) => event.stopPropagation()}
>
<div data-slot="compare-model-modal-list">
<label data-slot="compare-model-modal-search">
<span data-slot="compare-model-modal-search-icon" aria-hidden="true" />
<input
ref={searchInput}
value={search()}
placeholder="Search models"
aria-label="Search models"
onInput={(event) => setSearch(event.currentTarget.value)}
/>
</label>
<div data-slot="compare-model-modal-results">
<Show
when={filteredModels().length > 0}
fallback={
<div data-slot="compare-model-modal-empty">
<strong>No models found</strong>
<span>Try another search.</span>
</div>
}
>
<For each={filteredModels()}>
{(model) => (
<button
type="button"
data-slot="compare-model-modal-row"
data-active={preview()?.id === model.id ? "true" : undefined}
onPointerEnter={() => setPreviewId(model.id)}
onFocus={() => setPreviewId(model.id)}
onClick={() => props.onSelect(model)}
>
<span data-slot="compare-model-modal-row-main">
<ModelAvatar model={model} size="tiny" />
<span>{model.name}</span>
</span>
<Show when={isFreeModel(model)}>
<span data-slot="compare-model-modal-badge">Free</span>
</Show>
</button>
)}
</For>
</Show>
</div>
</div>
<div data-slot="compare-model-modal-divider" aria-hidden="true" />
<Show when={preview()}>{(model) => <CompareModelDetail model={model()} />}</Show>
</div>
</div>
)
}
function CompareModelDetail(props: { model: ModelCatalogEntry }) {
return (
<aside data-slot="compare-model-modal-detail">
<header data-slot="compare-model-modal-detail-header">
<ModelAvatar model={props.model} size="small" />
<strong>{props.model.name}</strong>
</header>
<div data-slot="compare-model-modal-description">
<p>
{props.model.description ??
`${props.model.name} is an AI model from ${formatCatalogLabName(props.model.lab)}.`}
</p>
<span aria-hidden="true" />
</div>
<dl data-slot="compare-model-modal-facts">
<CompareModelFact label="Release" value={formatCatalogDate(props.model.releaseDate)} />
<CompareModelFact label="Context" value={formatCatalogLimit(props.model.limit?.context)} />
<CompareModelFact label="Input" value={formatCatalogUnitPrice(props.model.cost?.input)} />
<CompareModelFact label="Output" value={formatCatalogUnitPrice(props.model.cost?.output)} />
<div data-slot="compare-model-modal-fact">
<dt>Uptime</dt>
<dd data-slot="compare-model-modal-uptime">
<For each={uptimeBars}>{(bar) => <span data-active={bar < 7 ? "true" : undefined} />}</For>
</dd>
</div>
<div data-slot="compare-model-modal-fact">
<dt>URL</dt>
<dd>
<a href={modelHref(props.model)}>{formatModelUrl(props.model)}</a>
</dd>
</div>
</dl>
</aside>
)
}
function CompareModelFact(props: { label: string; value: string }) {
return (
<div data-slot="compare-model-modal-fact">
<dt>{props.label}</dt>
<dd>{props.value}</dd>
</div>
)
}
function HeroModelStack() {
return (
<span data-slot="compare-home-avatar-stack" aria-hidden="true">
<For each={heroLabs}>
{(lab) => (
<span data-slot="compare-home-avatar-frame">
<LabLogo lab={lab.lab} label={lab.label} size="large" />
</span>
)}
</For>
</span>
)
}
function CompareHomeCard(props: { category: CompareCategory }) {
return (
<a
data-component="compare-home-card"
href={comparisonHref(props.category.first, props.category.second)}
aria-label={`${props.category.title}: ${props.category.first.name} vs ${props.category.second.name}`}
>
<span data-slot="compare-home-card-head">
<span>
<strong>{props.category.title}</strong>
<em>{props.category.description}</em>
</span>
<b aria-hidden="true" />
</span>
<span data-slot="compare-home-card-divider" aria-hidden="true" />
<span data-slot="compare-home-card-models">
<span>{props.category.first.name}</span>
<i aria-hidden="true">·</i>
<span>{props.category.second.name}</span>
</span>
<span data-slot="compare-home-card-avatars" aria-hidden="true">
<For each={props.category.avatars}>
{(model) => <LabLogo lab={model.lab} label={model.name} size="small" />}
</For>
</span>
</a>
)
}
function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) {
return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} />
}
function LabLogo(props: { lab: string; label: string; size: "large" | "small" | "tiny" }) {
const iconId = () => getProviderIconId(props.lab)
return (
<span data-slot="compare-home-avatar" data-lab={iconId()} data-size={props.size} aria-label={props.label}>
<ProviderIcon aria-hidden="true" id={iconId()} />
</span>
)
}
function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory[] {
return categoryTemplates.reduce<{ keys: Set<string>; categories: CompareCategory[] }>(
(result, template, index) => {
const candidates = categoryCandidates(template.kind, models)
const pair = categoryPair(candidates, models, index, result.keys)
if (!pair) return result
result.keys.add(comparisonKey(pair.first, pair.second))
const first = modelRefFromCatalog(pair.first)
const second = modelRefFromCatalog(pair.second)
result.categories.push({
title: template.title,
description: template.description,
first,
second,
avatars: [first, second],
})
return result
},
{ keys: new Set(), categories: [] },
).categories
}
function categoryPair(
candidates: ModelCatalogEntry[],
fallback: ModelCatalogEntry[],
offset: number,
usedKeys: Set<string>,
) {
const pool = uniqueModels([...candidates, ...fallback.slice(offset), ...fallback])
return pool
.flatMap((first, firstIndex) =>
pool.slice(firstIndex + 1).map((second) => ({
first,
second,
key: comparisonKey(first, second),
})),
)
.find((pair) => !usedKeys.has(pair.key))
}
function categoryCandidates(kind: (typeof categoryTemplates)[number]["kind"], models: ModelCatalogEntry[]) {
if (kind === "affordable")
return models
.filter((model) => model.cost)
.toSorted(
(a, b) => modelCost(a) - modelCost(b) || displayDateTime(b.releaseDate) - displayDateTime(a.releaseDate),
)
if (kind === "code") return models.filter((model) => model.toolCall || model.reasoning).toSorted(recentModelSort)
if (kind === "image")
return models
.filter((model) => model.modalities.output.some((modality) => modality.toLowerCase().includes("image")))
.toSorted(recentModelSort)
return models.toSorted(recentModelSort)
}
function uniqueModels(models: (ModelCatalogEntry | undefined)[]) {
return models.reduce<{ keys: Set<string>; models: ModelCatalogEntry[] }>(
(result, model) => {
if (!model || result.keys.has(model.id)) return result
result.keys.add(model.id)
result.models.push(model)
return result
},
{ keys: new Set(), models: [] },
).models
}
function recentModelSort(a: ModelCatalogEntry, b: ModelCatalogEntry) {
return displayDateTime(b.releaseDate) - displayDateTime(a.releaseDate) || a.name.localeCompare(b.name)
}
function comparisonKey(first: ModelCatalogEntry, second: ModelCatalogEntry) {
return [first.id, second.id].toSorted().join("|")
}
function modelCost(model: ModelCatalogEntry) {
return (model.cost?.input ?? Number.POSITIVE_INFINITY) + (model.cost?.output ?? Number.POSITIVE_INFINITY)
}
function modelSearchText(model: ModelCatalogEntry) {
return `${model.name} ${formatCatalogLabName(model.lab)} ${model.id}`.toLowerCase()
}
function isFreeModel(model: ModelCatalogEntry) {
return model.cost?.input === 0 && model.cost.output === 0
}
function modelHref(model: ModelCatalogEntry) {
return `${import.meta.env.BASE_URL}${model.lab}/${model.slug}`
}
function formatModelUrl(model: ModelCatalogEntry) {
return `.../${model.slug}`
}
function formatCatalogLimit(value: number | undefined) {
return value === undefined ? "Unknown" : formatTokens(value)
}
function formatCatalogUnitPrice(value: number | undefined) {
if (value === undefined) return "Unknown"
return `${formatModelPrice(value)} / 1M`
}
function formatModelPrice(value: number) {
if (value > 0 && value < 0.01) return `$${value.toFixed(4)}`
return formatMoney(value)
}
function formatMoney(value: number) {
if (value >= 1_000_000) return `$${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
if (value >= 1_000) return `$${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
return `$${value.toFixed(value >= 10 ? 0 : 2)}`
}
function formatCatalogDate(value: string | undefined) {
if (!value) return "Unknown"
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return value
return new Intl.DateTimeFormat("en", {
month: match[2] ? "short" : undefined,
day: match[3] ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1)))
}
function formatTokens(value: number) {
if (value >= 1_000_000_000_000)
return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T`
if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B`
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
return String(Math.round(value))
}
function trimNumber(value: number, digits: number) {
return Number(value.toFixed(digits)).toLocaleString("en")
}
function displayDateTime(value: string | undefined) {
if (!value) return 0
const date = new Date(value)
if (!Number.isNaN(date.getTime())) return date.getTime()
const year = Number(value.match(/\d{4}/)?.[0] ?? 0)
return Number.isFinite(year) ? year : 0
}
function getProviderIconId(provider: string) {
const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
if (id === "moonshot") return "moonshotai"
if (id === "zhipu") return "zhipuai"
return id
}
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -34,6 +34,7 @@ import { localizedUrl } from "../lib/language"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import { SectionHeading } from "./section-heading"
import { setStatsPageCacheHeaders } from "./stats-cache"
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
import {
applyThemePreference,
Footer,
@@ -48,6 +49,12 @@ import {
const products = ["All Users", "Zen", "Go"] as const
const tokenProducts = ["Zen", "Go"] as const
const ranges = ["1D", "1W", "2W", "1M", "2M"] as const
const comparisonPairIndexes = [
[0, 1, "Top two by recent usage"],
[0, 2, "Leader vs challenger"],
[1, 2, "Adjacent leaderboard pair"],
[2, 3, "Top model alternative"],
] as const
const statsUnfurlPath = "banner.jpg"
const usageColors = [
"#ed6aff",
@@ -183,11 +190,20 @@ export default function StatsHome() {
<CacheRatioSection data={stats().cacheRatio} />
<MarketShareSection data={stats().market} />
<GeoBreakdownSection data={stats().country} />
<ComparisonCardsSection
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
title="Model Comparisons"
description="Popular model pairs from the leaderboard."
/>
</>
)}
</Show>
</div>
<Footer themePreference={themePreference()} onThemePreferenceChange={updateThemePreference} />
<Footer
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
/>
</div>
</main>
)
@@ -1969,6 +1985,26 @@ function formatSessionCost(value: number) {
return `$${value.toFixed(4)}`
}
function homeComparisonPairs(leaderboard: LeaderboardEntry[]) {
return uniqueComparisonPairs(
comparisonPairIndexes.flatMap(([firstIndex, secondIndex, detail]) => {
const first = leaderboard[firstIndex]
const second = leaderboard[secondIndex]
return first && second ? [{ first: leaderboardRef(first), second: leaderboardRef(second), detail }] : []
}),
)
}
function leaderboardRef(entry: LeaderboardEntry): ComparisonModelRef {
return {
name: entry.model,
lab: entry.provider,
slug: modelSlug(entry.model),
labName: entry.author,
metric: `#${entry.rank} / ${formatBillions(entry.tokens)}`,
}
}
function modelSlug(value: string) {
return value
.trim()
@@ -216,6 +216,7 @@ export function Footer(props: {
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
links?: readonly HeaderLink[]
bridge?: HeaderLink | null
}) {
const i18n = useI18n()
const language = useLanguage()
@@ -241,10 +242,14 @@ export function Footer(props: {
{ href: githubLink.href, label: i18n.t("header.github") },
{ href: "https://www.youtube.com/@anomaly-co", label: i18n.t("footer.youtube") },
]
const bridge = () =>
props.bridge === undefined
? { href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown").toUpperCase() }
: props.bridge
return (
<footer data-component="footer">
<SectionBridge label={i18n.t("nav.geoBreakdown").toUpperCase()} href="#geo-breakdown" />
<Show when={bridge()}>{(link) => <SectionBridge label={link().label} href={link().href} />}</Show>
<div data-slot="footer-grid">
<a data-slot="footer-mark" href="https://opencode.ai" aria-label={i18n.t("footer.homeAria")}>
<OpenCodeMark />
+57 -1
View File
@@ -1,8 +1,9 @@
import { Client } from "@planetscale/database"
import { Effect } from "effect"
import { Resource } from "sst/resource"
import { DatabaseError } from "../database"
import type { GeoStatMetric } from "./geo"
import type { ModelStatMetric } from "./model"
import { ModelStatRepo, type ModelStatMetric } from "./model"
import type { ProviderStatMetric } from "./provider"
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
@@ -81,6 +82,23 @@ export type StatsLabData = {
usage: ModelUsagePoint[]
models: LabUsageModelEntry[]
}
export type StatsModelComparisonEntry = {
updatedAt: string | null
model: string
slug: string
provider: string
author: string
rank: number | null
previousRank: number | null
totalModels: number
tokenShare: number
tokenChange: number
totals: StatsModelData["totals"]
}
export type StatsModelComparisonData = {
updatedAt: string | null
models: [StatsModelComparisonEntry | null, StatsModelComparisonEntry | null]
}
export type StatsHomeData = {
updatedAt: string | null
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
@@ -271,6 +289,27 @@ function dateValue(value: unknown) {
return value instanceof Date ? value : new Date(stringValue(value))
}
export const getStatsModelComparisonData: (
firstProvider: string,
firstModel: string,
secondProvider: string,
secondModel: string,
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelComparison.getData")(
function* (firstProvider, firstModel, secondProvider, secondModel) {
const modelStats = yield* ModelStatRepo
const rows = yield* modelStats.listDaily()
const first = toComparisonEntry(buildStatsModelData(firstModel, rows, [], firstProvider))
const second = toComparisonEntry(buildStatsModelData(secondModel, rows, [], secondProvider))
const latest = [first?.updatedAt, second?.updatedAt]
.flatMap((value) => (value ? [dateTime(value)] : []))
.toSorted((a, b) => b - a)[0]
return {
updatedAt: latest === undefined ? null : new Date(latest).toISOString(),
models: [first, second],
}
},
)
function buildStatsHomeData(
modelRows: ModelStatMetric[],
providerRows: ProviderStatMetric[],
@@ -448,6 +487,23 @@ function buildStatsLabData(providerParam: string, modelRows: ModelStatMetric[]):
}
}
function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEntry | null {
if (!data) return null
return {
updatedAt: data.updatedAt,
model: data.model,
slug: data.slug,
provider: data.provider,
author: data.author,
rank: data.rank,
previousRank: data.previousRank,
totalModels: data.totalModels,
tokenShare: data.tokenShare,
tokenChange: data.tokenChange,
totals: data.totals,
}
}
function emptyStatsHomeData(): StatsHomeData {
return {
updatedAt: null,
+65 -12
View File
@@ -40,6 +40,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { formatDuration } from "../../util/format"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@@ -55,7 +56,6 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { PromptInterruptHint, PromptRetryStatus } from "./retry-status"
registerOpencodeSpinner()
@@ -159,10 +159,6 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const retry = createMemo(() => {
const value = status()
if (value.type === "retry") return value
})
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@@ -1516,22 +1512,79 @@ export function Prompt(props: PromptProps) {
flexDirection="row"
gap={1}
flexGrow={1}
flexShrink={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={1} flexDirection="row" gap={1}>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={1}>
<Show when={retry()} keyed>
{(retry) => <PromptRetryStatus status={retry} theme={theme} dialog={dialog} />}
</Show>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<PromptInterruptHint armed={store.interrupt > 0} theme={theme} />
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={workspace.notice()}>
@@ -1,54 +0,0 @@
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import type { Theme } from "../../theme"
import { formatDuration } from "../../util/format"
import { DialogAlert } from "../../ui/dialog-alert"
import type { useDialog } from "../../ui/dialog"
type RetryStatus = Extract<SessionStatus, { type: "retry" }>
export function PromptRetryStatus(props: { status: RetryStatus; theme: Theme; dialog: ReturnType<typeof useDialog> }) {
const message = createMemo(() => {
if (props.status.message.includes("exceeded your current quota") && props.status.message.includes("gemini"))
return "gemini is way too hot right now"
if (props.status.message.length > 80) return props.status.message.slice(0, 80) + "..."
return props.status.message
})
const isTruncated = createMemo(() => props.status.message.length > 120)
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
if (props.status.next) setSeconds(Math.round((props.status.next - Date.now()) / 1000))
}, 1000)
onCleanup(() => clearInterval(timer))
})
const retryText = createMemo(() => {
const duration = formatDuration(seconds())
return `${message()}${isTruncated() ? " (click to expand)" : ""} [retrying ${duration ? `in ${duration} ` : ""}attempt #${props.status.attempt}]`
})
return (
<box
flexShrink={1}
onMouseUp={() => {
if (isTruncated()) void DialogAlert.show(props.dialog, "Retry Error", props.status.message)
}}
>
<text fg={props.theme.error} wrapMode="none" truncate>
{retryText()}
</text>
</box>
)
}
export function PromptInterruptHint(props: { armed: boolean; theme: Theme }) {
return (
<text fg={props.armed ? props.theme.primary : props.theme.text} wrapMode="none" flexShrink={0}>
esc{" "}
<span style={{ fg: props.armed ? props.theme.primary : props.theme.textMuted }}>
{props.armed ? "again to interrupt" : "interrupt"}
</span>
</text>
)
}
@@ -1,49 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { PromptInterruptHint, PromptRetryStatus } from "../../src/component/prompt/retry-status"
import { RGBA } from "@opentui/core"
import type { Theme } from "../../src/theme"
function RetryRow() {
const theme = {
error: RGBA.fromInts(255, 0, 0, 255),
primary: RGBA.fromInts(255, 255, 255, 255),
text: RGBA.fromInts(255, 255, 255, 255),
textMuted: RGBA.fromInts(128, 128, 128, 255),
} as Theme
return (
<box width="100%" flexDirection="row" gap={1}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} justifyContent="space-between">
<box flexShrink={1} flexDirection="row" gap={1}>
<box marginLeft={1} flexShrink={0}>
<text>[⋯]</text>
</box>
<box flexDirection="row" gap={1} flexShrink={1}>
<PromptRetryStatus
status={{ type: "retry", message: "Too Many Requests", attempt: 10, next: Date.now() + 824_000 }}
theme={theme}
dialog={{} as never}
/>
</box>
</box>
</box>
<PromptInterruptHint armed={false} theme={theme} />
</box>
)
}
test("retry status keeps the interrupt hint on one line", async () => {
const app = await testRender(() => <RetryRow />, { width: 50, height: 4 })
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Too Many Reque")
expect(frame).toContain("esc interrupt")
expect(frame.split("\n").filter((line) => line.trim()).length).toBe(1)
} finally {
app.renderer.destroy()
}
})
+3
View File
@@ -99,6 +99,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -146,6 +147,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -95,6 +95,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -95,6 +95,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -95,6 +95,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -95,6 +95,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
@@ -95,6 +95,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ OpenCode Zen работает как любой другой провайдер
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -97,6 +97,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -144,6 +145,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -95,6 +95,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+3
View File
@@ -104,6 +104,7 @@ You can also access our models through the following API endpoints.
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -153,6 +154,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
@@ -95,6 +95,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -142,6 +143,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
@@ -99,6 +99,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -147,6 +148,8 @@ https://opencode.ai/zen/v1/models
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
+126
View File
@@ -0,0 +1,126 @@
diff --git a/dist/index.js b/dist/index.js
index ce8d4ae802f5909937840b856866c2d735800212..d448e921d51cb0a4f752cdad09080b21d4d8e8a9 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -139,11 +139,12 @@ function convertCohereUsage(tokens) {
}
const inputTokens = tokens.input_tokens;
const outputTokens = tokens.output_tokens;
+ const cacheReadTokens = tokens.cached_tokens ?? 0;
return {
inputTokens: {
total: inputTokens,
- noCache: inputTokens,
- cacheRead: void 0,
+ noCache: Math.max(0, inputTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || void 0,
cacheWrite: void 0
},
outputTokens: {
@@ -672,7 +673,8 @@ var cohereChatResponseSchema = import_v43.z.object({
}),
tokens: import_v43.z.object({
input_tokens: import_v43.z.number(),
- output_tokens: import_v43.z.number()
+ output_tokens: import_v43.z.number(),
+ cached_tokens: import_v43.z.number().optional()
})
})
});
@@ -732,7 +734,8 @@ var cohereChatChunkSchema = import_v43.z.discriminatedUnion("type", [
usage: import_v43.z.object({
tokens: import_v43.z.object({
input_tokens: import_v43.z.number(),
- output_tokens: import_v43.z.number()
+ output_tokens: import_v43.z.number(),
+ cached_tokens: import_v43.z.number().optional()
})
})
})
diff --git a/dist/index.mjs b/dist/index.mjs
index 44f26c27511f9089cdcc9456590dee100b070b0a..d3f585b58208430404135060af5774a7ecfa65d0 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -126,11 +126,12 @@ function convertCohereUsage(tokens) {
}
const inputTokens = tokens.input_tokens;
const outputTokens = tokens.output_tokens;
+ const cacheReadTokens = tokens.cached_tokens ?? 0;
return {
inputTokens: {
total: inputTokens,
- noCache: inputTokens,
- cacheRead: void 0,
+ noCache: Math.max(0, inputTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || void 0,
cacheWrite: void 0
},
outputTokens: {
@@ -661,7 +662,8 @@ var cohereChatResponseSchema = z3.object({
}),
tokens: z3.object({
input_tokens: z3.number(),
- output_tokens: z3.number()
+ output_tokens: z3.number(),
+ cached_tokens: z3.number().optional()
})
})
});
@@ -721,7 +723,8 @@ var cohereChatChunkSchema = z3.discriminatedUnion("type", [
usage: z3.object({
tokens: z3.object({
input_tokens: z3.number(),
- output_tokens: z3.number()
+ output_tokens: z3.number(),
+ cached_tokens: z3.number().optional()
})
})
})
diff --git a/src/cohere-chat-language-model.ts b/src/cohere-chat-language-model.ts
index e4971ccf255ffff77c38fd35caccf84a032ad150..93fea74a08c0f9bcc6c63689fbc9e72669cdeba1 100644
--- a/src/cohere-chat-language-model.ts
+++ b/src/cohere-chat-language-model.ts
@@ -495,6 +495,7 @@ const cohereChatResponseSchema = z.object({
tokens: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
+ cached_tokens: z.number().optional(),
}),
}),
});
@@ -558,6 +559,7 @@ const cohereChatChunkSchema = z.discriminatedUnion('type', [
tokens: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
+ cached_tokens: z.number().optional(),
}),
}),
}),
diff --git a/src/convert-cohere-usage.ts b/src/convert-cohere-usage.ts
index 0f83a63ab85ab2c2ae136edd509e1f738553041f..3913f8d221c8980916769a9890b56030f69a6070 100644
--- a/src/convert-cohere-usage.ts
+++ b/src/convert-cohere-usage.ts
@@ -3,6 +3,7 @@ import { LanguageModelV3Usage } from '@ai-sdk/provider';
export type CohereUsageTokens = {
input_tokens: number;
output_tokens: number;
+ cached_tokens?: number;
};
export function convertCohereUsage(
@@ -27,12 +28,13 @@ export function convertCohereUsage(
const inputTokens = tokens.input_tokens;
const outputTokens = tokens.output_tokens;
+ const cacheReadTokens = tokens.cached_tokens ?? 0;
return {
inputTokens: {
total: inputTokens,
- noCache: inputTokens,
- cacheRead: undefined,
+ noCache: Math.max(0, inputTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || undefined,
cacheWrite: undefined,
},
outputTokens: {
+67
View File
@@ -0,0 +1,67 @@
diff --git a/dist/index.js b/dist/index.js
index 45a104f2e0775761858eac2a82ced64bceba1f5e..36861246fa3aea50f82a233aec8c8edaef0ff6e3 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -57,13 +57,14 @@ function convertGroqUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const reasoningTokens = (_d = (_c = usage.completion_tokens_details) == null ? void 0 : _c.reasoning_tokens) != null ? _d : void 0;
+ const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? void 0;
const textTokens = reasoningTokens != null ? completionTokens - reasoningTokens : completionTokens;
return {
inputTokens: {
total: promptTokens,
- noCache: promptTokens,
- cacheRead: void 0,
+ noCache: Math.max(0, promptTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || void 0,
cacheWrite: void 0
},
outputTokens: {
diff --git a/dist/index.mjs b/dist/index.mjs
index c644c32235d8fa88c51c0fc6958feb1da4877c96..6aa35dd173cd67dbdd2f6f3a5fe2d901cbaafae8 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -44,13 +44,14 @@ function convertGroqUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const reasoningTokens = (_d = (_c = usage.completion_tokens_details) == null ? void 0 : _c.reasoning_tokens) != null ? _d : void 0;
+ const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? void 0;
const textTokens = reasoningTokens != null ? completionTokens - reasoningTokens : completionTokens;
return {
inputTokens: {
total: promptTokens,
- noCache: promptTokens,
- cacheRead: void 0,
+ noCache: Math.max(0, promptTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || void 0,
cacheWrite: void 0
},
outputTokens: {
diff --git a/src/convert-groq-usage.ts b/src/convert-groq-usage.ts
index a16809223b068bc1d682ef9156ccc82ce89ace5c..732b14508609106bd5946ba9f9323c7863ee2da5 100644
--- a/src/convert-groq-usage.ts
+++ b/src/convert-groq-usage.ts
@@ -40,6 +40,7 @@ export function convertGroqUsage(
const promptTokens = usage.prompt_tokens ?? 0;
const completionTokens = usage.completion_tokens ?? 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? undefined;
const textTokens =
@@ -50,8 +51,8 @@ export function convertGroqUsage(
return {
inputTokens: {
total: promptTokens,
- noCache: promptTokens,
- cacheRead: undefined,
+ noCache: Math.max(0, promptTokens - cacheReadTokens),
+ cacheRead: cacheReadTokens || undefined,
cacheWrite: undefined,
},
outputTokens: {
+84
View File
@@ -0,0 +1,84 @@
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 1ca9113bed2728a616db773a8e08d8d6957447d7..15408ec429dc210b5fa43589d81b69c93bf27b2d 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
none: "none";
high: "high";
}>>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
diff --git a/dist/index.js b/dist/index.js
index 45735e524aaff54ea058c99c729c5ffd3c507058..6aca5f6f13da0054ede31c1f1a692e4eaed37d34 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -268,7 +268,8 @@ var mistralLanguageModelOptions = import_v4.z.object({
* - `'high'`: Enable reasoning
* - `'none'`: Disable reasoning
*/
- reasoningEffort: import_v4.z.enum(["high", "none"]).optional()
+ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(),
+ promptCacheKey: import_v4.z.string().optional()
});
// src/mistral-error.ts
@@ -413,6 +414,7 @@ var MistralChatLanguageModel = class {
top_p: topP,
random_seed: seed,
reasoning_effort: options.reasoningEffort,
+ prompt_cache_key: options.promptCacheKey,
// response format:
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
type: "json_schema",
diff --git a/dist/index.mjs b/dist/index.mjs
index 4c22df1cd78a1ba81309c8a86ceecefef4ba4aea..30cd3b1f503860109b7fa2107cd1eb17b70c96be 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -256,7 +256,8 @@ var mistralLanguageModelOptions = z.object({
* - `'high'`: Enable reasoning
* - `'none'`: Disable reasoning
*/
- reasoningEffort: z.enum(["high", "none"]).optional()
+ reasoningEffort: z.enum(["high", "none"]).optional(),
+ promptCacheKey: z.string().optional()
});
// src/mistral-error.ts
@@ -403,6 +404,7 @@ var MistralChatLanguageModel = class {
top_p: topP,
random_seed: seed,
reasoning_effort: options.reasoningEffort,
+ prompt_cache_key: options.promptCacheKey,
// response format:
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
type: "json_schema",
diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts
index 480c472d534bedbe8897979673453bd1c29a70b7..e46496da94f7d4af9822897202ca6baae67dae3a 100644
--- a/src/mistral-chat-language-model.ts
+++ b/src/mistral-chat-language-model.ts
@@ -129,6 +129,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
top_p: topP,
random_seed: seed,
reasoning_effort: options.reasoningEffort,
+ prompt_cache_key: options.promptCacheKey,
// response format:
response_format:
diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts
index 80fff45fba2c378fa06962f071946bcd2b882a0b..b4fdfa51f3bf11a4220e010c8aca92482cb0c3db 100644
--- a/src/mistral-chat-options.ts
+++ b/src/mistral-chat-options.ts
@@ -62,6 +62,11 @@ export const mistralLanguageModelOptions = z.object({
* - `'none'`: Disable reasoning
*/
reasoningEffort: z.enum(['high', 'none']).optional(),
+
+ /**
+ * A stable identifier used to route requests with shared prompt prefixes.
+ */
+ promptCacheKey: z.string().optional(),
});
export type MistralLanguageModelOptions = z.infer<
@@ -0,0 +1,101 @@
diff --git a/dist/index.js b/dist/index.js
index d73573b013e61903991cad2074187a3ade8921ab..ca7923e551f8f0cb4c27e0afd5f6cb3bc3b6c1c4 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -73,7 +73,7 @@ function convertOpenAICompatibleChatUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens ?? 0;
const reasoningTokens = (_f = (_e = usage.completion_tokens_details) == null ? void 0 : _e.reasoning_tokens) != null ? _f : 0;
return {
inputTokens: {
@@ -894,6 +894,7 @@ var openaiCompatibleTokenUsageSchema = import_v43.z.looseObject({
prompt_tokens: import_v43.z.number().nullish(),
completion_tokens: import_v43.z.number().nullish(),
total_tokens: import_v43.z.number().nullish(),
+ cached_tokens: import_v43.z.number().nullish(),
prompt_tokens_details: import_v43.z.object({
cached_tokens: import_v43.z.number().nullish()
}).nullish(),
diff --git a/dist/index.mjs b/dist/index.mjs
index 237c9e209da9da2d4d557eb309e1adc2157c1426..fb45541149086633e27f02c89b742208222bb185 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -53,7 +53,7 @@ function convertOpenAICompatibleChatUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens ?? 0;
const reasoningTokens = (_f = (_e = usage.completion_tokens_details) == null ? void 0 : _e.reasoning_tokens) != null ? _f : 0;
return {
inputTokens: {
@@ -881,6 +881,7 @@ var openaiCompatibleTokenUsageSchema = z3.looseObject({
prompt_tokens: z3.number().nullish(),
completion_tokens: z3.number().nullish(),
total_tokens: z3.number().nullish(),
+ cached_tokens: z3.number().nullish(),
prompt_tokens_details: z3.object({
cached_tokens: z3.number().nullish()
}).nullish(),
diff --git a/dist/internal/index.js b/dist/internal/index.js
index 0858632db778d30b303fbc9d5d46a29582d6e5b5..bb414ac25b77cb1a492cf6ad64f24decae38dd31 100644
--- a/dist/internal/index.js
+++ b/dist/internal/index.js
@@ -249,7 +249,7 @@ function convertOpenAICompatibleChatUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens ?? 0;
const reasoningTokens = (_f = (_e = usage.completion_tokens_details) == null ? void 0 : _e.reasoning_tokens) != null ? _f : 0;
return {
inputTokens: {
diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs
index 116b2deee86bf07cf981a64c000c7a4f3e42b52e..de08e6248318a0e48866ba41e9788485d56dd5cb 100644
--- a/dist/internal/index.mjs
+++ b/dist/internal/index.mjs
@@ -224,7 +224,7 @@ function convertOpenAICompatibleChatUsage(usage) {
}
const promptTokens = (_a = usage.prompt_tokens) != null ? _a : 0;
const completionTokens = (_b = usage.completion_tokens) != null ? _b : 0;
- const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? void 0 : _c.cached_tokens) != null ? _d : 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens ?? 0;
const reasoningTokens = (_f = (_e = usage.completion_tokens_details) == null ? void 0 : _e.reasoning_tokens) != null ? _f : 0;
return {
inputTokens: {
diff --git a/src/chat/convert-openai-compatible-chat-usage.ts b/src/chat/convert-openai-compatible-chat-usage.ts
index 7982309cd99afbf7eb84a733c37446ac3cb9b626..454f14fe45af3f7475ba0371173c3342f242ccd9 100644
--- a/src/chat/convert-openai-compatible-chat-usage.ts
+++ b/src/chat/convert-openai-compatible-chat-usage.ts
@@ -5,6 +5,7 @@ export function convertOpenAICompatibleChatUsage(
| {
prompt_tokens?: number | null;
completion_tokens?: number | null;
+ cached_tokens?: number | null;
prompt_tokens_details?: {
cached_tokens?: number | null;
} | null;
@@ -34,7 +35,7 @@ export function convertOpenAICompatibleChatUsage(
const promptTokens = usage.prompt_tokens ?? 0;
const completionTokens = usage.completion_tokens ?? 0;
- const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens ?? 0;
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? 0;
diff --git a/src/chat/openai-compatible-chat-language-model.ts b/src/chat/openai-compatible-chat-language-model.ts
index 9a803cb1173e1454facb73ae430ede0ea0a3d2de..fd2c8e78ad850e11492b6be51922a382664739bf 100644
--- a/src/chat/openai-compatible-chat-language-model.ts
+++ b/src/chat/openai-compatible-chat-language-model.ts
@@ -723,6 +723,7 @@ const openaiCompatibleTokenUsageSchema = z
prompt_tokens: z.number().nullish(),
completion_tokens: z.number().nullish(),
total_tokens: z.number().nullish(),
+ cached_tokens: z.number().nullish(),
prompt_tokens_details: z
.object({
cached_tokens: z.number().nullish(),
+174
View File
@@ -0,0 +1,174 @@
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
include: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodEnum<{
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
include: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodEnum<{
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.js b/dist/index.js
index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..dd7dbeb3bc307e0d355f4bb4939d06cc8eae7528 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1119,6 +1119,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
});
+ } else if (block.mediaType === "application/pdf") {
+ contentParts.push({
+ type: "input_file",
+ ...typeof block.data === "string" && block.data.startsWith("file-") ? { file_id: block.data } : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${(0, import_provider_utils5.convertToBase64)(block.data)}`
+ }
+ });
} else {
throw new import_provider4.UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1760,6 +1768,10 @@ var xaiLanguageModelResponsesOptions = import_v47.z.object({
* The ID of the previous response from the model.
*/
previousResponseId: import_v47.z.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: import_v47.z.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
@@ -2165,6 +2177,9 @@ var XaiResponsesLanguageModel = class {
},
...options.previousResponseId != null && {
previous_response_id: options.previousResponseId
+ },
+ ...options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey
}
};
if (xaiTools2 && xaiTools2.length > 0) {
diff --git a/dist/index.mjs b/dist/index.mjs
index a26af109585fc2bd3053b320142aa869c06d36f4..774adaf971b648544317a4fc65d0c56e488d4fc7 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1122,6 +1122,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
});
+ } else if (block.mediaType === "application/pdf") {
+ contentParts.push({
+ type: "input_file",
+ ...typeof block.data === "string" && block.data.startsWith("file-") ? { file_id: block.data } : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${convertToBase642(block.data)}`
+ }
+ });
} else {
throw new UnsupportedFunctionalityError3({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1763,6 +1771,10 @@ var xaiLanguageModelResponsesOptions = z7.object({
* The ID of the previous response from the model.
*/
previousResponseId: z7.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: z7.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
@@ -2186,6 +2198,9 @@ var XaiResponsesLanguageModel = class {
},
...options.previousResponseId != null && {
previous_response_id: options.previousResponseId
+ },
+ ...options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey
}
};
if (xaiTools2 && xaiTools2.length > 0) {
diff --git a/src/responses/convert-to-xai-responses-input.ts b/src/responses/convert-to-xai-responses-input.ts
index 9a3712990cf646a6f7ba757d3f2893a3c37c6ef9..b018a9b95eb21e6c12794e6bddb3d953f40441e5 100644
--- a/src/responses/convert-to-xai-responses-input.ts
+++ b/src/responses/convert-to-xai-responses-input.ts
@@ -64,6 +64,17 @@ export async function convertToXaiResponsesInput({
type: 'input_file',
file_url: block.data.toString(),
});
+ } else if (block.mediaType === 'application/pdf') {
+ contentParts.push({
+ type: 'input_file',
+ ...(typeof block.data === 'string' &&
+ block.data.startsWith('file-')
+ ? { file_id: block.data }
+ : {
+ filename: block.filename ?? 'file',
+ file_data: `data:application/pdf;base64,${convertToBase64(block.data)}`,
+ }),
+ });
} else {
throw new UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`,
diff --git a/src/responses/xai-responses-api.ts b/src/responses/xai-responses-api.ts
index 70483ea4f7c340b84c23766ac1d1df21e052842b..4b4d052860caa019556151e9e46dbfda8dfb18a9 100644
--- a/src/responses/xai-responses-api.ts
+++ b/src/responses/xai-responses-api.ts
@@ -27,7 +27,13 @@ export type XaiResponsesSystemMessage = {
export type XaiResponsesUserMessageContentPart =
| { type: 'input_text'; text: string }
| { type: 'input_image'; image_url: string }
- | { type: 'input_file'; file_url: string };
+ | {
+ type: 'input_file';
+ file_url?: string;
+ file_id?: string;
+ file_data?: string;
+ filename?: string;
+ };
export type XaiResponsesUserMessage = {
role: 'user';
diff --git a/src/responses/xai-responses-language-model.ts b/src/responses/xai-responses-language-model.ts
index f90df62eb9a30154388b1390e9f3acc3ccc022bf..00e61cba6cf048ae0045be692f33cb7e1e6a1188 100644
--- a/src/responses/xai-responses-language-model.ts
+++ b/src/responses/xai-responses-language-model.ts
@@ -182,6 +182,9 @@ export class XaiResponsesLanguageModel implements LanguageModelV3 {
...(options.previousResponseId != null && {
previous_response_id: options.previousResponseId,
}),
+ ...(options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey,
+ }),
};
if (xaiTools && xaiTools.length > 0) {
diff --git a/src/responses/xai-responses-options.ts b/src/responses/xai-responses-options.ts
index f8e96c061bf8793a402ababb8cad65bb2ad6aead..15c168892c1e8755453c61d3061e958cfd51ac71 100644
--- a/src/responses/xai-responses-options.ts
+++ b/src/responses/xai-responses-options.ts
@@ -32,6 +32,10 @@ export const xaiLanguageModelResponsesOptions = z.object({
* The ID of the previous response from the model.
*/
previousResponseId: z.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: z.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
-99
View File
@@ -1,99 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 135b95946139bbd1fc4b62239032931586189da0..7913520ad2f1c26b0f9621e7654f5fb570cba926 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1077,6 +1077,20 @@ async function convertToXaiResponsesInput({
const mediaType = block.mediaType === "image/*" ? "image/jpeg" : block.mediaType;
const imageUrl = block.data instanceof URL ? block.data.toString() : `data:${mediaType};base64,${(0, import_provider_utils5.convertToBase64)(block.data)}`;
contentParts.push({ type: "input_image", image_url: imageUrl });
+ } else if (block.mediaType === "application/pdf") {
+ if (block.data instanceof URL) {
+ contentParts.push({ type: "input_file", file_url: block.data.toString() });
+ } else {
+ contentParts.push({
+ type: "input_file",
+ ...(typeof block.data === "string" && block.data.startsWith("file-")
+ ? { file_id: block.data }
+ : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${(0, import_provider_utils5.convertToBase64)(block.data)}`,
+ }),
+ });
+ }
} else {
throw new import_provider4.UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType}`
diff --git a/dist/index.mjs b/dist/index.mjs
index 61be60d452682b94dcdda39ffc47cb994eb8bfb1..d928f7f46e91057cd97fade9cc238db611345bcf 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1080,6 +1080,20 @@ async function convertToXaiResponsesInput({
const mediaType = block.mediaType === "image/*" ? "image/jpeg" : block.mediaType;
const imageUrl = block.data instanceof URL ? block.data.toString() : `data:${mediaType};base64,${convertToBase642(block.data)}`;
contentParts.push({ type: "input_image", image_url: imageUrl });
+ } else if (block.mediaType === "application/pdf") {
+ if (block.data instanceof URL) {
+ contentParts.push({ type: "input_file", file_url: block.data.toString() });
+ } else {
+ contentParts.push({
+ type: "input_file",
+ ...(typeof block.data === "string" && block.data.startsWith("file-")
+ ? { file_id: block.data }
+ : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${convertToBase642(block.data)}`,
+ }),
+ });
+ }
} else {
throw new UnsupportedFunctionalityError3({
functionality: `file part media type ${block.mediaType}`
diff --git a/src/responses/convert-to-xai-responses-input.ts b/src/responses/convert-to-xai-responses-input.ts
index 19958d9fd90f7ce61bca70ad2d5f7b89a59fa63b..9329a1d56210af8aa33e5dbcb2916e24847070c1 100644
--- a/src/responses/convert-to-xai-responses-input.ts
+++ b/src/responses/convert-to-xai-responses-input.ts
@@ -54,6 +54,24 @@ export async function convertToXaiResponsesInput({
: `data:${mediaType};base64,${convertToBase64(block.data)}`;
contentParts.push({ type: 'input_image', image_url: imageUrl });
+ } else if (block.mediaType === 'application/pdf') {
+ if (block.data instanceof URL) {
+ contentParts.push({
+ type: 'input_file',
+ file_url: block.data.toString(),
+ });
+ } else {
+ contentParts.push({
+ type: 'input_file',
+ ...(typeof block.data === 'string' &&
+ block.data.startsWith('file-')
+ ? { file_id: block.data }
+ : {
+ filename: block.filename ?? 'file',
+ file_data: `data:application/pdf;base64,${convertToBase64(block.data)}`,
+ }),
+ });
+ }
} else {
throw new UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType}`,
diff --git a/src/responses/xai-responses-api.ts b/src/responses/xai-responses-api.ts
index df24c42d29fe7fc1dd7649cc2c4712a68c3a536b..00195468a83d43c1bfb50854ea040b55ea352433 100644
--- a/src/responses/xai-responses-api.ts
+++ b/src/responses/xai-responses-api.ts
@@ -26,7 +26,14 @@ export type XaiResponsesSystemMessage = {
export type XaiResponsesUserMessageContentPart =
| { type: 'input_text'; text: string }
- | { type: 'input_image'; image_url: string };
+ | { type: 'input_image'; image_url: string }
+ | {
+ type: 'input_file';
+ file_url?: string;
+ file_id?: string;
+ file_data?: string;
+ filename?: string;
+ };
export type XaiResponsesUserMessage = {
role: 'user';
+34
View File
@@ -0,0 +1,34 @@
diff --git a/dist/index.js b/dist/index.js
index 6fb7ae621cf5fe350ec9295af8f3d3507d265df3..ee16ee3af60f461fe2b074eb9cab44ea586c97ee 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -283,10 +283,10 @@ function createAiGateway(options) {
function parseAiGatewayOptions(options) {
const headers = new Headers();
if (options.skipCache === true) {
- headers.set("cf-skip-cache", "true");
+ headers.set("cf-aig-skip-cache", "true");
}
if (options.cacheTtl) {
- headers.set("cf-cache-ttl", options.cacheTtl.toString());
+ headers.set("cf-aig-cache-ttl", options.cacheTtl.toString());
}
if (options.metadata) {
headers.set("cf-aig-metadata", JSON.stringify(options.metadata));
diff --git a/dist/index.mjs b/dist/index.mjs
index 1c00a815117b5854073eb9c01cafa5bc273a367e..11acdaa65021952eb52622ebd3d9b87633e5f8e5 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -254,10 +254,10 @@ function createAiGateway(options) {
function parseAiGatewayOptions(options) {
const headers = new Headers();
if (options.skipCache === true) {
- headers.set("cf-skip-cache", "true");
+ headers.set("cf-aig-skip-cache", "true");
}
if (options.cacheTtl) {
- headers.set("cf-cache-ttl", options.cacheTtl.toString());
+ headers.set("cf-aig-cache-ttl", options.cacheTtl.toString());
}
if (options.metadata) {
headers.set("cf-aig-metadata", JSON.stringify(options.metadata));