diff --git a/bun.lock b/bun.lock
index dc3f6b62..d1f6cfd2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -114,7 +114,6 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
- "@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
@@ -141,7 +140,6 @@
"@types/luxon": "3.7.1",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
- "opencode": "workspace:*",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -281,17 +279,24 @@
"version": "0.15.29",
"dependencies": {
"@kobalte/core": "catalog:",
+ "@opencode-ai/sdk": "workspace:*",
"@pierre/precision-diffs": "catalog:",
+ "@shikijs/transformers": "3.9.2",
"@solidjs/meta": "catalog:",
+ "@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
+ "marked": "16.2.0",
+ "marked-shiki": "1.2.1",
"remeda": "catalog:",
+ "shiki": "3.9.2",
"solid-js": "catalog:",
"solid-list": "catalog:",
"virtua": "catalog:",
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
+ "@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
@@ -348,7 +353,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/precision-diffs": "0.3.6",
+ "@pierre/precision-diffs": "0.4.1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
@@ -942,7 +947,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
- "@pierre/precision-diffs": ["@pierre/precision-diffs@0.3.6", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-cKM3HcMmyr5wPFll0bHYcgHplcHgMlL6Dw4Pi4giL0jVt7ySlGwwVyXTRFW5Fva43stOL+EWB+9U5VBDSktBJA=="],
+ "@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -1080,7 +1085,7 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
- "@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+ "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="],
@@ -3518,6 +3523,8 @@
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
+ "@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
@@ -3530,10 +3537,6 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
- "@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
-
- "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
-
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
@@ -3798,8 +3801,6 @@
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
- "shiki/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
-
"sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3954,6 +3955,8 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+ "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
@@ -4088,6 +4091,8 @@
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
+ "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
@@ -4350,6 +4355,8 @@
"@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
+ "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
diff --git a/package.json b/package.json
index 32e974f5..2a0c07d8 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/precision-diffs": "0.3.6",
+ "@pierre/precision-diffs": "0.4.1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx
index e68b89ae..e1cea636 100644
--- a/packages/console/app/src/component/header.tsx
+++ b/packages/console/app/src/component/header.tsx
@@ -41,7 +41,7 @@ export function Header(props: { zen?: boolean }) {
notation: "compact",
compactDisplay: "short",
}).format(githubData()?.stars!)
- : "25K",
+ : "29K",
)
const [store, setStore] = createStore({
diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts
index 3dc00adb..e33737d5 100644
--- a/packages/console/app/src/routes/api/enterprise.ts
+++ b/packages/console/app/src/routes/api/enterprise.ts
@@ -26,7 +26,7 @@ export async function POST(event: APIEvent) {
// Create email content
const emailContent = `
${body.message}
---
+--
${body.name}
${body.role}
${body.email}`.trim()
diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx
index 5bca6f38..4af0ccce 100644
--- a/packages/console/app/src/routes/enterprise/index.tsx
+++ b/packages/console/app/src/routes/enterprise/index.tsx
@@ -65,94 +65,95 @@ export default function Enterprise() {
Your code is yours
OpenCode operates securely inside your organization with no data or context stored
- and no licensing restrictions or ownership claims. Start a trial with your team
- , then deploy it across your organization by integrating it with your SSO and internal AI gateway.
+ and no licensing restrictions or ownership claims. Start a trial with your team,
+ then deploy it across your organization by integrating it with your SSO and
+ internal AI gateway.
Let us know and how we can help.
-
-
-
-
-
+
+
+ Thanks to OpenCode, we found a way to create software to track all our assets —
+ even the imaginary ones.
+
- Thanks to OpenCode, we found a way to create software to track all our assets —
- even the imaginary ones.
-
-
diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx
index 287c2573..fe6c5698 100644
--- a/packages/console/app/src/routes/index.tsx
+++ b/packages/console/app/src/routes/index.tsx
@@ -219,8 +219,8 @@ export default function Home() {
[*]
- With over 26,000 GitHub stars, 188 contributors, and almost{" "}
- 3,000 commits, OpenCode is used and trusted by over 200,000 {" "}
+ With over 29,000 GitHub stars, 230 contributors, and almost{" "}
+ 3,500 commits, OpenCode is used and trusted by over 250,000 {" "}
developers every month.
@@ -274,7 +274,7 @@ export default function Home() {
- Fig 1. 26K GitHub Stars
+ Fig 1. 29K GitHub Stars
@@ -577,7 +577,7 @@ export default function Home() {
- Fig 2. 188 Contributors
+ Fig 2. 230 Contributors
@@ -619,7 +619,7 @@ export default function Home() {
- Fig 3. 200K Monthly Devs
+ Fig 3. 250K Monthly Devs
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index f7a1f0e1..3163de34 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -13,7 +13,11 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
-import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
+import {
+ createBodyConverter,
+ createStreamPartConverter,
+ createResponseConverter,
+} from "./provider/provider"
import { Format } from "./format"
import { anthropicHelper } from "./provider/anthropic"
import { openaiHelper } from "./provider/openai"
@@ -43,7 +47,11 @@ export async function handler(
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
- const providerInfo = selectProvider(zenData, modelInfo, input.request.headers.get("x-real-ip") ?? "")
+ const providerInfo = selectProvider(
+ zenData,
+ modelInfo,
+ input.request.headers.get("x-real-ip") ?? "",
+ )
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(modelInfo, authInfo)
validateModelSettings(authInfo)
@@ -222,7 +230,11 @@ export async function handler(
return { id: modelId, ...modelData }
}
- function selectProvider(zenData: ZenData, model: Awaited>, ip: string) {
+ function selectProvider(
+ zenData: ZenData,
+ model: Awaited>,
+ ip: string,
+ ) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array(provider.weight ?? 1).fill(provider))
@@ -239,7 +251,11 @@ export async function handler(
return {
...provider,
...zenData.providers[provider.id],
- ...(provider.id === "anthropic" ? anthropicHelper : provider.id === "openai" ? openaiHelper : oaCompatHelper),
+ ...(provider.id === "anthropic"
+ ? anthropicHelper
+ : provider.id === "openai"
+ ? openaiHelper
+ : oaCompatHelper),
}
}
@@ -279,11 +295,20 @@ export async function handler(
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
- .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
- .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)))
+ .innerJoin(
+ UserTable,
+ and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
+ )
+ .leftJoin(
+ ModelTable,
+ and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
+ )
.leftJoin(
ProviderTable,
- and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
+ and(
+ eq(ProviderTable.workspaceID, KeyTable.workspaceID),
+ eq(ProviderTable.provider, providerInfo.id),
+ ),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
@@ -307,12 +332,20 @@ export async function handler(
}
function validateBilling(model: Model, authInfo: Awaited>) {
- if (!authInfo || authInfo.isFree) return
+ if (!authInfo) return
+ if (authInfo.provider?.credentials) return
+ if (authInfo.isFree) return
if (model.allowAnonymous) return
const billing = authInfo.billing
- if (!billing.paymentMethodID) throw new CreditsError("No payment method")
- if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+ if (!billing.paymentMethodID)
+ throw new CreditsError(
+ `No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
+ )
+ if (billing.balance <= 0)
+ throw new CreditsError(
+ `Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
+ )
const now = new Date()
const currentYear = now.getUTCFullYear()
@@ -327,7 +360,7 @@ export async function handler(
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(
- `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
+ `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
}
@@ -340,7 +373,9 @@ export async function handler(
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
- throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
+ throw new UserLimitError(
+ `You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
+ )
}
}
@@ -364,12 +399,19 @@ export async function handler(
providerInfo: Awaited>,
usage: any,
) {
- const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
- providerInfo.normalizeUsage(usage)
+ const {
+ inputTokens,
+ outputTokens,
+ reasoningTokens,
+ cacheReadTokens,
+ cacheWrite5mTokens,
+ cacheWrite1hTokens,
+ } = providerInfo.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
- inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
+ inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
+ 200_000
? modelInfo.cost200K
: modelInfo.cost
@@ -420,7 +462,8 @@ export async function handler(
if (!authInfo) return
- const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
+ const cost =
+ authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
@@ -460,7 +503,9 @@ export async function handler(
`,
timeMonthlyUsageUpdated: sql`now()`,
})
- .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
+ .where(
+ and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
+ )
})
await Database.use((tx) =>
@@ -487,7 +532,10 @@ export async function handler(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
- or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
+ or(
+ isNull(BillingTable.timeReloadLockedTill),
+ lt(BillingTable.timeReloadLockedTill, sql`now()`),
+ ),
),
),
)
diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts
new file mode 100644
index 00000000..af9bcc3a
--- /dev/null
+++ b/packages/console/core/script/lookup-user.ts
@@ -0,0 +1,33 @@
+import { Database, eq } from "../src/drizzle/index.js"
+import { AuthTable } from "../src/schema/auth.sql"
+
+// get input from command line
+const email = process.argv[2]
+if (!email) {
+ console.error("Usage: bun lookup-user.ts ")
+ process.exit(1)
+}
+
+const authData = await printTable("Auth", (tx) =>
+ tx.select().from(AuthTable).where(eq(AuthTable.subject, email)),
+)
+if (authData.length === 0) {
+ console.error("User not found")
+ process.exit(1)
+}
+
+await printTable("Auth", (tx) =>
+ tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)),
+)
+
+function printTable(
+ title: string,
+ callback: (tx: Database.TxOrDb) => Promise,
+): Promise {
+ return Database.use(async (tx) => {
+ const data = await callback(tx)
+ console.log(`== ${title} ==`)
+ console.table(data)
+ return data
+ })
+}
diff --git a/packages/console/core/script/reset-db.ts b/packages/console/core/script/reset-db.ts
index 96ecf14e..bd00e196 100644
--- a/packages/console/core/script/reset-db.ts
+++ b/packages/console/core/script/reset-db.ts
@@ -1,13 +1,21 @@
import { Resource } from "@opencode-ai/console-resource"
-import { Database } from "@opencode-ai/console-core/drizzle/index.js"
-import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
-import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js"
-import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
-import { BillingTable, PaymentTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
-import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { Database } from "../src/drizzle/index.js"
+import { UserTable } from "../src/schema/user.sql.js"
+import { AccountTable } from "../src/schema/account.sql.js"
+import { WorkspaceTable } from "../src/schema/workspace.sql.js"
+import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
+import { KeyTable } from "../src/schema/key.sql.js"
if (Resource.App.stage !== "frank") throw new Error("This script is only for frank")
-for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) {
+for (const table of [
+ AccountTable,
+ BillingTable,
+ KeyTable,
+ PaymentTable,
+ UsageTable,
+ UserTable,
+ WorkspaceTable,
+]) {
await Database.use((tx) => tx.delete(table))
}
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 40081521..32fe27b8 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -4,6 +4,7 @@
"description": "",
"type": "module",
"scripts": {
+ "typecheck": "tsgo --noEmit",
"start": "vite",
"dev": "vite",
"build": "vite build",
@@ -11,7 +12,6 @@
},
"license": "MIT",
"devDependencies": {
- "opencode": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1",
@@ -26,7 +26,6 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
- "@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx
index 11518e73..c214fd5e 100644
--- a/packages/desktop/src/components/code.tsx
+++ b/packages/desktop/src/components/code.tsx
@@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
import { useLocal, type TextSelection } from "@/context/local"
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
-import { useShiki } from "@/context/shiki"
+import { useShiki } from "@opencode-ai/ui"
type DefinedSelection = Exclude
diff --git a/packages/desktop/src/components/diff-changes.tsx b/packages/desktop/src/components/diff-changes.tsx
deleted file mode 100644
index 3b633f70..00000000
--- a/packages/desktop/src/components/diff-changes.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { FileDiff } from "@opencode-ai/sdk"
-import { createMemo, Show } from "solid-js"
-
-export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) {
- const additions = createMemo(() =>
- Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions,
- )
- const deletions = createMemo(() =>
- Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions,
- )
- const total = createMemo(() => additions() + deletions())
- return (
- 0}>
-
- {`+${additions()}`}
- {`-${deletions()}`}
-
-
- )
-}
diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx
deleted file mode 100644
index e0f185f5..00000000
--- a/packages/desktop/src/components/markdown.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useMarked } from "@/context/marked"
-import { createResource } from "solid-js"
-
-function strip(text: string): string {
- const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
- const match = text.match(wrappedRe)
- return match ? match[2] : text
-}
-export function Markdown(props: { text: string; class?: string }) {
- const marked = useMarked()
- const [html] = createResource(
- () => strip(props.text),
- async (markdown) => {
- return marked.parse(markdown)
- },
- )
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx
deleted file mode 100644
index 589ca311..00000000
--- a/packages/desktop/src/components/message.tsx
+++ /dev/null
@@ -1,459 +0,0 @@
-import type { Part, ReasoningPart, TextPart, ToolPart, Message, AssistantMessage, UserMessage } from "@opencode-ai/sdk"
-import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
-import { Dynamic } from "solid-js/web"
-import { Markdown } from "./markdown"
-import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui"
-import { getDirectory, getFilename } from "@/utils"
-import type { Tool } from "opencode/tool/tool"
-import type { ReadTool } from "opencode/tool/read"
-import type { ListTool } from "opencode/tool/ls"
-import type { GlobTool } from "opencode/tool/glob"
-import type { GrepTool } from "opencode/tool/grep"
-import type { WebFetchTool } from "opencode/tool/webfetch"
-import type { TaskTool } from "opencode/tool/task"
-import type { BashTool } from "opencode/tool/bash"
-import type { EditTool } from "opencode/tool/edit"
-import type { WriteTool } from "opencode/tool/write"
-import type { TodoWriteTool } from "opencode/tool/todo"
-import { DiffChanges } from "./diff-changes"
-
-export function Message(props: { message: Message; parts: Part[] }) {
- return (
-
-
- {(userMessage) => }
-
-
- {(assistantMessage) => }
-
-
- )
-}
-
-function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
- const filteredParts = createMemo(() => {
- return props.parts?.filter((x) => {
- if (x.type === "reasoning") return false
- return x.type !== "tool" || x.tool !== "todoread"
- })
- })
- return (
-
- )
-}
-
-function UserMessage(props: { message: UserMessage; parts: Part[] }) {
- const text = createMemo(() =>
- props.parts
- ?.filter((p) => p.type === "text" && !p.synthetic)
- ?.map((p) => (p as TextPart).text)
- ?.join(""),
- )
- return {text()}
-}
-
-export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) {
- const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING])
- return (
-
-
-
- )
-}
-
-const PART_MAPPING = {
- text: TextPart,
- tool: ToolPart,
- reasoning: ReasoningPart,
-}
-
-function ReasoningPart(props: { part: ReasoningPart; message: Message }) {
- return (
-
-
-
- )
-}
-
-function TextPart(props: { part: TextPart; message: Message }) {
- return (
-
-
-
- )
-}
-
-function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) {
- const component = createMemo(() => {
- const render = ToolRegistry.render(props.part.tool) ?? GenericTool
- const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
- const input = props.part.state.status === "completed" ? props.part.state.input : {}
-
- return (
-
- )
- })
-
- return {component()}
-}
-
-type TriggerTitle = {
- title: string
- titleClass?: string
- subtitle?: string
- subtitleClass?: string
- args?: string[]
- argsClass?: string
- action?: JSX.Element
-}
-
-const isTriggerTitle = (val: any): val is TriggerTitle => {
- return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
-}
-
-function BasicTool(props: {
- icon: IconProps["name"]
- trigger: TriggerTitle | JSX.Element
- children?: JSX.Element
- hideDetails?: boolean
-}) {
- const resolved = children(() => props.children)
- return (
-
-
-
-
-
-
-
-
- {(trigger) => (
-
-
-
- {trigger().title}
-
-
-
- {trigger().subtitle}
-
-
-
-
- {(arg) => (
-
- {arg}
-
- )}
-
-
-
-
{trigger().action}
-
- )}
-
- {props.trigger as JSX.Element}
-
-
-
-
-
-
-
-
-
- {resolved()}
-
-
- // <>
- // {props.part.state.error.replace("Error: ", "")}
- // >
- )
-}
-
-function GenericTool(props: ToolProps) {
- return
-}
-
-type ToolProps = {
- input: Partial>
- metadata: Partial>
- tool: string
- output?: string
- hideDetails?: boolean
-}
-
-const ToolRegistry = (() => {
- const state: Record<
- string,
- {
- name: string
- render?: Component>
- }
- > = {}
- function register(input: { name: string; render?: Component> }) {
- state[input.name] = input
- return input
- }
- return {
- register,
- render(name: string) {
- return state[name]?.render
- },
- }
-})()
-
-ToolRegistry.register({
- name: "read",
- render(props) {
- return (
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "list",
- render(props) {
- return (
-
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "glob",
- render(props) {
- return (
-
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "grep",
- render(props) {
- const args = []
- if (props.input.pattern) args.push("pattern=" + props.input.pattern)
- if (props.input.include) args.push("include=" + props.input.include)
- return (
-
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "webfetch",
- render(props) {
- return (
-
-
-
- ),
- }}
- >
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "task",
- render(props) {
- return (
-
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "bash",
- render(props) {
- return (
-
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "edit",
- render(props) {
- return (
-
-
-
Edit
-
-
- {getDirectory(props.input.filePath!)}
-
- {getFilename(props.input.filePath ?? "")}
-
-
-
-
-
-
-
-
- }
- >
-
-
-
-
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "write",
- render(props) {
- return (
-
-
-
Write
-
-
- {getDirectory(props.input.filePath!)}
-
- {getFilename(props.input.filePath ?? "")}
-
-
- {/* */}
-
- }
- >
-
- {props.output}
-
-
- )
- },
-})
-
-ToolRegistry.register({
- name: "todowrite",
- render(props) {
- return (
- t.status === "completed").length}/${props.input.todos?.length}`,
- }}
- >
-
-
-
- {(todo) => (
-
- {todo.content}
-
- )}
-
-
-
-
- )
- },
-})
diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx
deleted file mode 100644
index d56197ed..00000000
--- a/packages/desktop/src/components/progress-circle.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Component, createMemo } from "solid-js"
-
-interface ProgressCircleProps {
- percentage: number
- size?: number
- strokeWidth?: number
-}
-
-export const ProgressCircle: Component = (props) => {
- // --- Set default values for props ---
- const size = () => props.size || 16
- const strokeWidth = () => props.strokeWidth || 3
-
- // --- Constants for SVG calculation ---
- const viewBoxSize = 16
- const center = viewBoxSize / 2
- const radius = () => center - strokeWidth() / 2
- const circumference = createMemo(() => 2 * Math.PI * radius())
-
- // --- Reactive Calculation for the progress offset ---
- const offset = createMemo(() => {
- const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
- const progress = clampedPercentage / 100
- return circumference() * (1 - progress)
- })
-
- return (
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index d6276c15..f1c19388 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -71,7 +71,7 @@ export const PromptInput: Component = (props) => {
}
})
- const { flat, active, onInput, onKeyDown } = useFilteredList({
+ const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({
items: local.file.search,
key: (x) => x,
onSelect: (path) => {
@@ -81,6 +81,11 @@ export const PromptInput: Component = (props) => {
},
})
+ createEffect(() => {
+ local.model.recent()
+ refetch()
+ })
+
createEffect(
on(
() => store.contentParts,
@@ -369,16 +374,20 @@ export const PromptInput: Component = (props) => {
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
- groupBy={(x) => x.provider.name}
+ groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
sortGroupsBy={(a, b) => {
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+ if (a.category === "Recent" && b.category !== "Recent") return -1
+ if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
return order.indexOf(aProvider) - order.indexOf(bProvider)
}}
- onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
+ onSelect={(x) =>
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
+ }
trigger={
{local.model.current()?.name ?? "Select model"}
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx
deleted file mode 100644
index e1f3beae..00000000
--- a/packages/desktop/src/components/session-timeline.tsx
+++ /dev/null
@@ -1,536 +0,0 @@
-import { Icon, Tooltip } from "@opencode-ai/ui"
-import { Collapsible } from "@/ui"
-import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
-import { DateTime } from "luxon"
-import {
- createSignal,
- For,
- Match,
- splitProps,
- Switch,
- type ComponentProps,
- type ParentProps,
- createEffect,
- createMemo,
- Show,
-} from "solid-js"
-import { getFilename } from "@/utils"
-import { Markdown } from "./markdown"
-import { Code } from "./code"
-import { createElementSize } from "@solid-primitives/resize-observer"
-import { createScrollPosition } from "@solid-primitives/scroll"
-import { ProgressCircle } from "./progress-circle"
-import { pipe, sumBy } from "remeda"
-import { useSync } from "@/context/sync"
-import { useLocal } from "@/context/local"
-
-function Part(props: ParentProps & ComponentProps<"div">) {
- const [local, others] = splitProps(props, ["class", "classList", "children"])
- return (
-
- )
-}
-
-function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps) {
- return (
-
-
- {props.title}
-
-
-
- {props.children}
-
-
-
- )
-}
-
-function ReadToolPart(props: { part: ToolPart }) {
- const sync = useSync()
- const local = useLocal()
- return (
-
-
- Reading file...
-
-
- {(state) => {
- const path = state().input["filePath"] as string
- return (
- local.file.open(path)}>
- Read {getFilename(path)}
-
- )
- }}
-
-
- {(state) => (
-
-
- Read {getFilename(state().input["filePath"] as string)}
-
-
{sync.sanitize(state().error)}
-
- )}
-
-
- )
-}
-
-function EditToolPart(props: { part: ToolPart }) {
- const sync = useSync()
- return (
-
-
- Preparing edit...
-
-
- {(state) => (
-
- Edit {getFilename(state().input["filePath"] as string)}
- >
- }
- >
-
-
- )}
-
-
- {(state) => (
-
- Edit {getFilename(state().input["filePath"] as string)}
- >
- }
- >
- {sync.sanitize(state().error)}
-
- )}
-
-
- )
-}
-
-function WriteToolPart(props: { part: ToolPart }) {
- const sync = useSync()
- return (
-
-
- Preparing write...
-
-
- {(state) => (
-
- Write {getFilename(state().input["filePath"] as string)}
- >
- }
- >
-
-
- )}
-
-
- {(state) => (
-
-
- Write {getFilename(state().input["filePath"] as string)}
-
-
{sync.sanitize(state().error)}
-
- )}
-
-
- )
-}
-
-function BashToolPart(props: { part: ToolPart }) {
- const sync = useSync()
- return (
-
-
- Writing shell command...
-
-
- {(state) => (
-
- Run command: {state().input["command"]}
- >
- }
- >
-
-
- )}
-
-
- {(state) => (
-
- Shell {state().input["command"]}
- >
- }
- >
- {sync.sanitize(state().error)}
-
- )}
-
-
- )
-}
-
-function ToolPart(props: { part: ToolPart }) {
- // read
- // edit
- // write
- // bash
- // ls
- // glob
- // grep
- // todowrite
- // todoread
- // webfetch
- // websearch
- // patch
- // task
- return (
-
-
- {props.part.type}:{props.part.tool}
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-export default function SessionTimeline(props: { session: string; class?: string }) {
- const sync = useSync()
- const [scrollElement, setScrollElement] = createSignal(undefined)
- const [root, setRoot] = createSignal(undefined)
- const [tail, setTail] = createSignal(true)
- const size = createElementSize(root)
- const scroll = createScrollPosition(scrollElement)
-
- const valid = (part: Part) => {
- if (!part) return false
- switch (part.type) {
- case "step-start":
- case "step-finish":
- case "file":
- case "patch":
- return false
- case "text":
- return !part.synthetic && part.text.trim()
- case "reasoning":
- return part.text.trim()
- case "tool":
- switch (part.tool) {
- case "todoread":
- case "todowrite":
- case "list":
- case "grep":
- return false
- }
- return true
- default:
- return true
- }
- }
-
- const hasValidParts = (message: Message) => {
- return sync.data.part[message.id]?.filter(valid).length > 0
- }
-
- const hasTextPart = (message: Message) => {
- return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
- }
-
- const session = createMemo(() => sync.session.get(props.session))
- const messages = createMemo(() => sync.data.message[props.session] ?? [])
- const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
- const working = createMemo(() => {
- const last = messages()[messages().length - 1]
- if (!last) return false
- if (last.role === "user") return true
- return !last.time.completed
- })
-
- const cost = createMemo(() => {
- const total = pipe(
- messages(),
- sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
- )
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(total)
- })
-
- const last = createMemo(() => {
- return messages().findLast((x) => x.role === "assistant") as AssistantMessage
- })
-
- const model = createMemo(() => {
- if (!last()) return
- const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
- return model
- })
-
- const tokens = createMemo(() => {
- if (!last()) return
- const tokens = last().tokens
- const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
- return new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(total)
- })
-
- const context = createMemo(() => {
- if (!last()) return
- if (!model()?.limit.context) return 0
- const tokens = last().tokens
- const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
- return Math.round((total / model()!.limit.context) * 100)
- })
-
- const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
- let p = el?.parentElement
- while (p && p !== document.body) {
- const s = getComputedStyle(p)
- if (s.overflowY === "auto" || s.overflowY === "scroll") return p
- p = p.parentElement
- }
- return undefined
- }
-
- createEffect(() => {
- if (!root()) return
- setScrollElement(getScrollParent(root()!))
- })
-
- const scrollToBottom = () => {
- const element = scrollElement()
- if (!element) return
- element.scrollTop = element.scrollHeight
- }
-
- createEffect(() => {
- size.height
- if (tail()) scrollToBottom()
- })
-
- createEffect(() => {
- if (working()) {
- setTail(true)
- scrollToBottom()
- }
- })
-
- let lastScrollY = 0
- createEffect(() => {
- if (scroll.y < lastScrollY) {
- setTail(false)
- }
- lastScrollY = scroll.y
- })
-
- const duration = (part: Part) => {
- switch (part.type) {
- default:
- if (
- "time" in part &&
- part.time &&
- "start" in part.time &&
- part.time.start &&
- "end" in part.time &&
- part.time.end
- ) {
- const start = DateTime.fromMillis(part.time.start)
- const end = DateTime.fromMillis(part.time.end)
- return end.diff(start).toFormat("s")
- }
- return ""
- }
- }
-
- createEffect(() => {
- console.log("WHAT")
- console.log(JSON.stringify(messagesWithValidParts()))
- })
-
- return (
-
-
-
-
-
-
-
- {context()}%
-
-
{cost()}
-
-
-
- )}
-
-
-
-
-
-
-
- Raw Session Data
-
-
-
-
-
-
-
-
-
-
- session
-
-
-
-
-
-
-
-
-
- {(message) => (
- <>
-
-
-
-
-
- {message.role === "user" ? "user" : "assistant"}
-
-
-
-
-
-
-
-
-
- {(part) => (
-
-
-
-
-
- {part.type}
-
-
-
-
-
-
-
-
- )}
-
- >
- )}
-
-
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 9c4d70fc..4607a184 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -45,6 +45,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
+ function isModelValid(model: ModelKey) {
+ const provider = sync.data.provider.find((x) => x.id === model.providerID)
+ return !!provider?.models[model.modelID]
+ }
+
+ function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
+ for (const modelFn of modelFns) {
+ const model = modelFn()
+ if (!model) continue
+ if (isModelValid(model)) return model
+ }
+ }
+
+ // Automatically update model when agent changes
+ createEffect(() => {
+ const value = agent.current()
+ if (value.model) {
+ if (isModelValid(value.model))
+ model.set({
+ providerID: value.model.providerID,
+ modelID: value.model.modelID,
+ })
+ // else
+ // toast.show({
+ // type: "warning",
+ // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
+ // duration: 3000,
+ // })
+ }
+ })
+
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [store, setStore] = createStore<{
@@ -76,11 +107,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
- const list = createMemo(() =>
- sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
- )
- const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
-
const [store, setStore] = createStore<{
model: Record
recent: ModelKey[]
@@ -95,27 +121,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
localStorage.setItem("model", JSON.stringify(store.recent))
})
- const fallback = createMemo(() => {
- if (store.recent.length) return store.recent[0]
+ const list = createMemo(() =>
+ sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
+ )
+ const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
+
+ const fallbackModel = createMemo(() => {
+ if (sync.data.config.model) {
+ const [providerID, modelID] = sync.data.config.model.split("/")
+ if (isModelValid({ providerID, modelID })) {
+ return {
+ providerID,
+ modelID,
+ }
+ }
+ }
+
+ for (const item of store.recent) {
+ if (isModelValid(item)) {
+ return item
+ }
+ }
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
- return { modelID: model.id, providerID: provider.id }
+ return {
+ providerID: provider.id,
+ modelID: model.id,
+ }
})
- const current = createMemo(() => {
+ const currentModel = createMemo(() => {
const a = agent.current()
- return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
+ const key = getFirstValidModel(
+ () => store.model[a.name],
+ () => a.model,
+ fallbackModel,
+ )!
+ return find(key)
})
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
return {
- list,
- current,
+ current: currentModel,
recent,
+ list,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
- setStore("model", agent.current().name, model ?? fallback())
+ setStore("model", agent.current().name, model ?? fallbackModel())
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
@@ -279,10 +332,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
break
case "file.watcher.updated":
- setTimeout(sync.load.changes, 1000)
- const relativePath = relative(event.properties.file)
- if (relativePath.startsWith(".git/")) return
- load(relativePath)
+ // setTimeout(sync.load.changes, 1000)
+ // const relativePath = relative(event.properties.file)
+ // if (relativePath.startsWith(".git/")) return
+ // load(relativePath)
break
}
})
@@ -480,8 +533,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const getMessageText = (message: Message | Message[] | undefined): string => {
if (!message) return ""
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
- const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file")
-
return sync.data.part[message.id]
?.filter((p) => p.type === "text")
?.filter((p) => !p.synthetic)
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 12aa9587..ce2fa1ea 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
- const parts = store.part[event.properties.part.messageID]
+ const part = sanitizePart(event.properties.part)
+ const parts = store.part[part.messageID]
if (!parts) {
- setStore("part", event.properties.part.messageID, [event.properties.part])
+ setStore("part", part.messageID, [part])
break
}
- const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
+ const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
- setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
+ setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
- event.properties.part.messageID,
+ part.messageID,
produce((draft) => {
- draft.splice(result.index, 0, event.properties.part)
+ draft.splice(result.index, 0, part)
}),
)
break
@@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
+ const sanitizePart = (part: Part) => {
+ if (part.type === "tool") {
+ if (part.state.status === "completed") {
+ for (const key in part.state.metadata) {
+ if (typeof part.state.metadata[key] === "string") {
+ part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
+ }
+ }
+ for (const key in part.state.input) {
+ if (typeof part.state.input[key] === "string") {
+ part.state.input[key] = sanitize(part.state.input[key] as string)
+ }
+ }
+ }
+ }
+ return part
+ }
return {
data: store,
@@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
- draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+ draft.part[message.info.id] = message.parts
+ .slice()
+ .map(sanitizePart)
+ .sort((a, b) => a.id.localeCompare(b.id))
}
}),
)
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9c7a07fe..0d631a5a 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -3,9 +3,7 @@ import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
-import { Fonts } from "@opencode-ai/ui"
-import { ShikiProvider } from "./context/shiki"
-import { MarkedProvider } from "./context/marked"
+import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 5216c427..5237d78b 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -9,6 +9,10 @@ import {
Accordion,
Diff,
Collapsible,
+ Part,
+ DiffChanges,
+ ProgressCircle,
+ Message,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
@@ -32,11 +36,8 @@ import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
-import { ProgressCircle } from "@/components/progress-circle"
-import { Message, Part } from "@/components/message"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
-import { DiffChanges } from "@/components/diff-changes"
-import { Markdown } from "@/components/markdown"
+import { Markdown } from "@opencode-ai/ui"
export default function Page() {
const local = useLocal()
@@ -271,7 +272,7 @@ export default function Page() {
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
return (
-
+
- props.onTabClick(props.file)}>
+ props.onTabClick(props.file)}
+ >
+ props.onTabClose(props.file)}
+ />
- props.onTabClose(props.file)}
- />
@@ -377,6 +384,7 @@ export default function Page() {
{(session) => {
const diffs = createMemo(() => session.summary?.diffs ?? [])
const filesChanged = createMemo(() => diffs().length)
+ const updated = DateTime.fromMillis(session.time.updated)
return (
@@ -385,7 +393,14 @@ export default function Page() {
{session.title}
- {DateTime.fromMillis(session.time.updated).toRelative()}
+ {Math.abs(updated.diffNow().as("seconds")) < 60
+ ? "Now"
+ : updated
+ .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
+ ?.replace(" ago", "")
+ ?.replace(/ days?/, "d")
+ ?.replace(" min.", "m")
+ ?.replace(" hr.", "h")}
@@ -497,7 +512,7 @@ export default function Page() {
+
New session
@@ -528,101 +543,14 @@ export default function Page() {
>
{(message) => {
- const countLines = (text: string) => {
- if (!text) return 0
- return text.split("\n").length
- }
-
- const additions = createMemo(
- () =>
- message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0,
- )
-
- const deletions = createMemo(
- () =>
- message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0,
- )
-
- const totalBeforeLines = createMemo(
- () =>
- message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ??
- 0,
- )
-
- const blockCounts = createMemo(() => {
- const TOTAL_BLOCKS = 5
-
- const adds = additions()
- const dels = deletions()
- const unchanged = Math.max(0, totalBeforeLines() - dels)
-
- const totalActivity = unchanged + adds + dels
-
- if (totalActivity === 0) {
- return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
- }
-
- const percentAdded = adds / totalActivity
- const percentDeleted = dels / totalActivity
- const added_raw = percentAdded * TOTAL_BLOCKS
- const deleted_raw = percentDeleted * TOTAL_BLOCKS
-
- let added = adds > 0 ? Math.ceil(added_raw) : 0
- let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0
-
- let total_allocated = added + deleted
- if (total_allocated > TOTAL_BLOCKS) {
- if (added_raw < deleted_raw) {
- added = Math.floor(added_raw)
- } else {
- deleted = Math.floor(deleted_raw)
- }
-
- total_allocated = added + deleted
- if (total_allocated > TOTAL_BLOCKS) {
- if (added_raw < deleted_raw) {
- deleted = Math.floor(deleted_raw)
- } else {
- added = Math.floor(added_raw)
- }
- }
- }
-
- const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted)
-
- return { added, deleted, neutral }
- })
-
- const ADD_COLOR = "var(--icon-diff-add-base)"
- const DELETE_COLOR = "var(--icon-diff-delete-base)"
- const NEUTRAL_COLOR = "var(--icon-weak-base)"
-
- const visibleBlocks = createMemo(() => {
- const counts = blockCounts()
- const blocks = [
- ...Array(counts.added).fill(ADD_COLOR),
- ...Array(counts.deleted).fill(DELETE_COLOR),
- ...Array(counts.neutral).fill(NEUTRAL_COLOR),
- ]
- return blocks.slice(0, 5)
- })
+ const diffs = createMemo(() => message.summary?.diffs ?? [])
return (
local.session.setActiveMessage(message.id)}
>
-
-
-
-
- {(color, i) => (
-
- )}
-
-
-
-
+
{/* Title */}
-
+
{title() ?? prompt()}
@@ -825,7 +753,7 @@ export default function Page() {
-
+
{(assistantMessage) => {
const parts = createMemo(
@@ -873,7 +801,7 @@ export default function Page() {
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
-
+
)
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 7bde4a31..a979f8ba 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -14,6 +14,8 @@ import { Agent } from "../../agent/agent"
import { Command } from "../../command"
import { SessionPrompt } from "../../session/prompt"
import { EOL } from "os"
+import { Permission } from "@/permission"
+import { select } from "@clack/prompts"
const TOOL: Record
= {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -229,7 +231,9 @@ export const RunCommand = cmd({
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title ||
- (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
+ (Object.keys(part.state.input).length > 0
+ ? JSON.stringify(part.state.input)
+ : "Unknown")
printEvent(color, tool, title)
@@ -275,6 +279,31 @@ export const RunCommand = cmd({
UI.error(err)
})
+ Bus.subscribe(Permission.Event.Updated, async (evt) => {
+ const permission = evt.properties
+ const message = `Permission required to run: ${permission.title}`
+
+ const result = await select({
+ message,
+ options: [
+ { value: "once", label: "Allow once" },
+ { value: "always", label: "Always allow" },
+ { value: "reject", label: "Reject" },
+ ],
+ initialValue: "once",
+ }).catch(() => "reject")
+ const response = (result.toString().includes("cancel") ? "reject" : result) as
+ | "once"
+ | "always"
+ | "reject"
+
+ Permission.respond({
+ sessionID: session.id,
+ permissionID: permission.id,
+ response,
+ })
+ })
+
await (async () => {
if (args.command) {
return await SessionPrompt.command({
diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts
index 1bc20de3..7c873ae5 100644
--- a/packages/opencode/src/cli/error.ts
+++ b/packages/opencode/src/cli/error.ts
@@ -1,3 +1,4 @@
+import { ConfigMarkdown } from "@/config/markdown"
import { Config } from "../config/config"
import { MCP } from "../mcp"
import { UI } from "./ui"
@@ -7,16 +8,22 @@ export function FormatError(input: unknown) {
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input)) {
return (
- `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
+ `Config file at ${input.data.path} is not valid JSON(C)` +
+ (input.data.message ? `: ${input.data.message}` : "")
)
}
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.`
}
+ if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
+ return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
+ }
if (Config.InvalidError.isInstance(input))
return [
- `Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""),
- ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
+ `Config file at ${input.data.path} is invalid` +
+ (input.data.message ? `: ${input.data.message}` : ""),
+ ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ??
+ []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 12a5c162..031c4d82 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -9,7 +9,6 @@ import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
-import matter from "gray-matter"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import {
@@ -21,6 +20,7 @@ import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
+import { ConfigMarkdown } from "./markdown"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -191,8 +191,7 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
- const content = await Bun.file(item).text()
- const md = matter(content)
+ const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const name = (() => {
@@ -231,8 +230,7 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
- const content = await Bun.file(item).text()
- const md = matter(content)
+ const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
// Extract relative path from agent folder for nested agents
@@ -274,8 +272,7 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
- const content = await Bun.file(item).text()
- const md = matter(content)
+ const md = await ConfigMarkdown.parse(item)
if (!md.data) continue
const config = {
diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts
index a4dcbf5d..3e84bbf4 100644
--- a/packages/opencode/src/config/markdown.ts
+++ b/packages/opencode/src/config/markdown.ts
@@ -1,3 +1,7 @@
+import { NamedError } from "@/util/error"
+import matter from "gray-matter"
+import { z } from "zod"
+
export namespace ConfigMarkdown {
export const FILE_REGEX = /(? {
if (!clients.includes(client)) return
- const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
+
+ const wait = waitForDiagnostics
+ ? client.waitForDiagnostics({ path: input })
+ : Promise.resolve()
await client.notify.open({ path: input })
return wait
}).catch((err) => {
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index ee0f73fc..da450879 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -54,7 +54,17 @@ export namespace LSPServer {
export const Deno: Info = {
id: "deno",
- root: NearestRoot(["deno.json", "deno.jsonc"]),
+ root: async (file) => {
+ const files = Filesystem.up({
+ targets: ["deno.json", "deno.jsonc"],
+ start: path.dirname(file),
+ stop: Instance.directory,
+ })
+ const first = await files.next()
+ await files.return()
+ if (!first.value) return undefined
+ return path.dirname(first.value)
+ },
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
async spawn(root) {
const deno = Bun.which("deno")
@@ -78,7 +88,9 @@ export namespace LSPServer {
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root) {
- const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
+ const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(
+ () => {},
+ )
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
@@ -101,7 +113,13 @@ export namespace LSPServer {
export const Vue: Info = {
id: "vue",
extensions: [".vue"],
- root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
+ root: NearestRoot([
+ "package-lock.json",
+ "bun.lockb",
+ "bun.lock",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ ]),
async spawn(root) {
let binary = Bun.which("vue-language-server")
const args: string[] = []
@@ -149,17 +167,31 @@ export namespace LSPServer {
export const ESLint: Info = {
id: "eslint",
- root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
+ root: NearestRoot([
+ "package-lock.json",
+ "bun.lockb",
+ "bun.lock",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ ]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root) {
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
if (!eslint) return
log.info("spawning eslint server")
- const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
+ const serverPath = path.join(
+ Global.Path.bin,
+ "vscode-eslint",
+ "server",
+ "out",
+ "eslintServer.js",
+ )
if (!(await Bun.file(serverPath).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading and building VS Code ESLint server")
- const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
+ const response = await fetch(
+ "https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip",
+ )
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
@@ -284,12 +316,25 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
- root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
+ root: NearestRoot([
+ "pyproject.toml",
+ "setup.py",
+ "setup.cfg",
+ "requirements.txt",
+ "Pipfile",
+ "pyrightconfig.json",
+ ]),
async spawn(root) {
let binary = Bun.which("pyright-langserver")
const args = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
+ const js = path.join(
+ Global.Path.bin,
+ "node_modules",
+ "pyright",
+ "dist",
+ "pyright-langserver.js",
+ )
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], {
@@ -307,9 +352,11 @@ export namespace LSPServer {
const initialization: Record = {}
- const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter(
- (p): p is string => p !== undefined,
- )
+ const potentialVenvPaths = [
+ process.env["VIRTUAL_ENV"],
+ path.join(root, ".venv"),
+ path.join(root, "venv"),
+ ].filter((p): p is string => p !== undefined)
for (const venvPath of potentialVenvPaths) {
const isWindows = process.platform === "win32"
const potentialPythonPath = isWindows
@@ -360,7 +407,9 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading elixir-ls from GitHub releases")
- const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
+ const response = await fetch(
+ "https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
+ )
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
@@ -410,7 +459,9 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading zls from GitHub releases")
- const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
+ const releaseResponse = await fetch(
+ "https://api.github.com/repos/zigtools/zls/releases/latest",
+ )
if (!releaseResponse.ok) {
log.error("Failed to fetch zls release info")
return
@@ -585,7 +636,13 @@ export namespace LSPServer {
export const Clangd: Info = {
id: "clangd",
- root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
+ root: NearestRoot([
+ "compile_commands.json",
+ "compile_flags.txt",
+ ".clangd",
+ "CMakeLists.txt",
+ "Makefile",
+ ]),
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
let bin = Bun.which("clangd", {
@@ -595,7 +652,9 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading clangd from GitHub releases")
- const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
+ const releaseResponse = await fetch(
+ "https://api.github.com/repos/clangd/clangd/releases/latest",
+ )
if (!releaseResponse.ok) {
log.error("Failed to fetch clangd release info")
return
@@ -664,12 +723,24 @@ export namespace LSPServer {
export const Svelte: Info = {
id: "svelte",
extensions: [".svelte"],
- root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
+ root: NearestRoot([
+ "package-lock.json",
+ "bun.lockb",
+ "bun.lock",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ ]),
async spawn(root) {
let binary = Bun.which("svelteserver")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
+ const js = path.join(
+ Global.Path.bin,
+ "node_modules",
+ "svelte-language-server",
+ "bin",
+ "server.js",
+ )
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
@@ -704,9 +775,17 @@ export namespace LSPServer {
export const Astro: Info = {
id: "astro",
extensions: [".astro"],
- root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
+ root: NearestRoot([
+ "package-lock.json",
+ "bun.lockb",
+ "bun.lock",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ ]),
async spawn(root) {
- const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
+ const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(
+ () => {},
+ )
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return
@@ -716,7 +795,14 @@ export namespace LSPServer {
let binary = Bun.which("astro-ls")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
+ const js = path.join(
+ Global.Path.bin,
+ "node_modules",
+ "@astrojs",
+ "language-server",
+ "bin",
+ "nodeServer.js",
+ )
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
@@ -794,7 +880,9 @@ export namespace LSPServer {
.then(({ stdout }) => stdout.toString().trim())
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await fs.exists(launcherJar))) {
- log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
+ log.error(
+ `Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`,
+ )
return
}
const configFile = path.join(
@@ -860,7 +948,9 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading lua-language-server from GitHub releases")
- const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
+ const releaseResponse = await fetch(
+ "https://api.github.com/repos/LuaLS/lua-language-server/releases/latest",
+ )
if (!releaseResponse.ok) {
log.error("Failed to fetch lua-language-server release info")
return
@@ -897,7 +987,9 @@ export namespace LSPServer {
const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}`
if (!supportedCombos.includes(assetSuffix)) {
- log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`)
+ log.error(
+ `Platform ${platform} and architecture ${arch} is not supported by lua-language-server`,
+ )
return
}
@@ -920,7 +1012,10 @@ export namespace LSPServer {
// Unlike zls which is a single self-contained binary,
// lua-language-server needs supporting files (meta/, locale/, etc.)
// Extract entire archive to dedicated directory to preserve all files
- const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`)
+ const installDir = path.join(
+ Global.Path.bin,
+ `lua-language-server-${lualsArch}-${lualsPlatform}`,
+ )
// Remove old installation if exists
const stats = await fs.stat(installDir).catch(() => undefined)
@@ -945,7 +1040,11 @@ export namespace LSPServer {
await fs.rm(tempPath, { force: true })
// Binary is located in bin/ subdirectory within the extracted archive
- bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
+ bin = path.join(
+ installDir,
+ "bin",
+ "lua-language-server" + (platform === "win32" ? ".exe" : ""),
+ )
if (!(await Bun.file(bin).exists())) {
log.error("Failed to extract lua-language-server binary")
@@ -954,7 +1053,9 @@ export namespace LSPServer {
if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
- log.error("Failed to set executable permission for lua-language-server binary", { error })
+ log.error("Failed to set executable permission for lua-language-server binary", {
+ error,
+ })
})
if (!ok) return
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 96dfbad0..080f9d99 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -534,7 +534,6 @@ export namespace SessionPrompt {
args,
},
)
- item.parameters.parse(args)
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: options.abortSignal!,
@@ -618,7 +617,7 @@ export namespace SessionPrompt {
return {
title: "",
- metadata: {},
+ metadata: result.metadata ?? {},
output,
}
}
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 830c298a..342645c3 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -33,6 +33,14 @@ export const TaskTool = Tool.define("task", async () => {
})
const msg = await Session.getMessage({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
+
+ ctx.metadata({
+ title: params.description,
+ metadata: {
+ sessionId: session.id,
+ },
+ })
+
const messageID = Identifier.ascending("message")
const parts: Record = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
@@ -44,6 +52,7 @@ export const TaskTool = Tool.define("task", async () => {
title: params.description,
metadata: {
summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)),
+ sessionId: session.id,
},
})
})
@@ -87,6 +96,7 @@ export const TaskTool = Tool.define("task", async () => {
title: params.description,
metadata: {
summary: all,
+ sessionId: session.id,
},
output: (result.parts.findLast((x: any) => x.type === "text") as any)?.text ?? "",
}
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index c7a28c51..f826d0c9 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -42,8 +42,13 @@ export namespace Tool {
return {
id,
init: async () => {
- if (init instanceof Function) return init()
- return init
+ const toolInfo = init instanceof Function ? await init() : init
+ const execute = toolInfo.execute
+ toolInfo.execute = (args, ctx) => {
+ toolInfo.parameters.parse(args)
+ return execute(args, ctx)
+ }
+ return toolInfo
},
}
}
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 708b9257..e0f1d992 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -490,19 +490,7 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
- // Priority 2: Config file model setting
- if selectedProvider == nil && a.Config.Model != "" {
- if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
- } else {
- slog.Debug("Config model not found", "model", a.Config.Model)
- }
- }
-
- // Priority 3: Current agent's preferred model
+ // Priority 2: Current agent's preferred model
if selectedProvider == nil && a.Agent().Model.ModelID != "" {
if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
model != nil {
@@ -522,6 +510,18 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
+ // Priority 3: Config file model setting
+ if selectedProvider == nil && a.Config.Model != "" {
+ if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
+ model != nil {
+ selectedProvider = provider
+ selectedModel = model
+ slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
+ } else {
+ slog.Debug("Config model not found", "model", a.Config.Model)
+ }
+ }
+
// Priority 4: Recent model usage (most recently used model)
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
diff --git a/packages/tui/internal/app/app_test.go b/packages/tui/internal/app/app_test.go
index 9260a991..e716d437 100644
--- a/packages/tui/internal/app/app_test.go
+++ b/packages/tui/internal/app/app_test.go
@@ -226,3 +226,79 @@ func TestFindProviderByID(t *testing.T) {
})
}
}
+
+// TestModelSelectionPriority tests the priority order for model selection
+func TestModelSelectionPriority(t *testing.T) {
+ providers := []opencode.Provider{
+ {
+ ID: "anthropic",
+ Models: map[string]opencode.Model{
+ "claude-opus": {ID: "claude-opus"},
+ },
+ },
+ {
+ ID: "openai",
+ Models: map[string]opencode.Model{
+ "gpt-4": {ID: "gpt-4"},
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ agentProviderID string
+ agentModelID string
+ configModel string
+ expectedProviderID string
+ expectedModelID string
+ description string
+ }{
+ {
+ name: "agent model takes priority over config",
+ agentProviderID: "openai",
+ agentModelID: "gpt-4",
+ configModel: "anthropic/claude-opus",
+ expectedProviderID: "openai",
+ expectedModelID: "gpt-4",
+ description: "When agent specifies a model, it should be used even if config has a different model",
+ },
+ {
+ name: "config model used when agent has no model",
+ agentProviderID: "",
+ agentModelID: "",
+ configModel: "anthropic/claude-opus",
+ expectedProviderID: "anthropic",
+ expectedModelID: "claude-opus",
+ description: "When agent has no model specified, config model should be used as fallback",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var selectedProvider *opencode.Provider
+ var selectedModel *opencode.Model
+
+ // Simulate priority 2: Agent model check
+ if tt.agentModelID != "" {
+ selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID)
+ }
+
+ // Simulate priority 3: Config model fallback
+ if selectedProvider == nil && tt.configModel != "" {
+ selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel)
+ }
+
+ if selectedProvider == nil || selectedModel == nil {
+ t.Fatalf("Expected to find model, but got nil - %s", tt.description)
+ }
+
+ if selectedProvider.ID != tt.expectedProviderID {
+ t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description)
+ }
+
+ if selectedModel.ID != tt.expectedModelID {
+ t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description)
+ }
+ })
+ }
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8fd6bff6..91ab3867 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -11,11 +11,13 @@
"./fonts/*": "./src/assets/fonts/*"
},
"scripts": {
+ "typecheck": "tsgo --noEmit",
"dev": "vite",
"generate:tailwind": "bun run script/tailwind.ts"
},
"devDependencies": {
"@types/bun": "catalog:",
+ "@tsconfig/node22": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
@@ -24,11 +26,17 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
+ "@opencode-ai/sdk": "workspace:*",
"@pierre/precision-diffs": "catalog:",
+ "@shikijs/transformers": "3.9.2",
"@solidjs/meta": "catalog:",
+ "@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
+ "marked": "16.2.0",
+ "marked-shiki": "1.2.1",
"remeda": "catalog:",
+ "shiki": "3.9.2",
"solid-js": "catalog:",
"solid-list": "catalog:",
"virtua": "catalog:"
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
new file mode 100644
index 00000000..f3d9f865
--- /dev/null
+++ b/packages/ui/src/components/basic-tool.css
@@ -0,0 +1,76 @@
+[data-component="tool-trigger"] {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ gap: 20px;
+ justify-content: space-between;
+
+ [data-slot="tool-trigger-content"] {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ gap: 20px;
+ }
+
+ [data-slot="tool-icon"] {
+ flex-shrink: 0;
+ }
+
+ [data-slot="tool-info"] {
+ flex-grow: 1;
+ min-width: 0;
+ }
+
+ [data-slot="tool-info-structured"] {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: space-between;
+ }
+
+ [data-slot="tool-info-main"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ [data-slot="tool-title"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-base);
+
+ &.capitalize {
+ text-transform: capitalize;
+ }
+ }
+
+ [data-slot="tool-subtitle"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-weak);
+ }
+
+ [data-slot="tool-arg"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-weak);
+ }
+}
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
new file mode 100644
index 00000000..43574fbb
--- /dev/null
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -0,0 +1,95 @@
+import { children, For, Match, Show, Switch, type JSX } from "solid-js"
+import { Collapsible } from "./collapsible"
+import { Icon, IconProps } from "./icon"
+
+export type TriggerTitle = {
+ title: string
+ titleClass?: string
+ subtitle?: string
+ subtitleClass?: string
+ args?: string[]
+ argsClass?: string
+ action?: JSX.Element
+}
+
+const isTriggerTitle = (val: any): val is TriggerTitle => {
+ return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
+}
+
+export interface BasicToolProps {
+ icon: IconProps["name"]
+ trigger: TriggerTitle | JSX.Element
+ children?: JSX.Element
+ hideDetails?: boolean
+}
+
+export function BasicTool(props: BasicToolProps) {
+ const resolved = children(() => props.children)
+ return (
+
+
+
+
+
+
+
+
+ {(trigger) => (
+
+
+
+ {trigger().title}
+
+
+
+ {trigger().subtitle}
+
+
+
+
+ {(arg) => (
+
+ {arg}
+
+ )}
+
+
+
+
{trigger().action}
+
+ )}
+
+ {props.trigger as JSX.Element}
+
+
+
+
+
+
+
+
+
+ {resolved()}
+
+
+ )
+}
+
+export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
+ return
+}
diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css
new file mode 100644
index 00000000..a9b7c878
--- /dev/null
+++ b/packages/ui/src/components/card.css
@@ -0,0 +1,29 @@
+[data-component="card"] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--surface-inset-base);
+ border: 1px solid var(--border-weaker-base);
+ transition: background-color 0.15s ease;
+ border-radius: 8px;
+ padding: 6px 12px;
+ overflow: clip;
+
+ &[data-variant="error"] {
+ background-color: var(--surface-critical-weak);
+ border: 1px solid var(--border-critical-base);
+ color: rgba(218, 51, 25, 0.6);
+
+ /* text-12-regular */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ &[data-component="icon"] {
+ color: var(--icon-critical-active);
+ }
+ }
+}
diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx
new file mode 100644
index 00000000..3fb225ab
--- /dev/null
+++ b/packages/ui/src/components/card.tsx
@@ -0,0 +1,22 @@
+import { type ComponentProps, splitProps } from "solid-js"
+
+export interface CardProps extends ComponentProps<"div"> {
+ variant?: "normal" | "error" | "warning" | "success" | "info"
+}
+
+export function Card(props: CardProps) {
+ const [split, rest] = splitProps(props, ["variant", "class", "classList"])
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index 3d8c8ebe..4b2c14d4 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -11,7 +11,7 @@
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
- height: 40px;
+ height: 32px;
padding: 6px 8px 6px 12px;
align-items: center;
align-self: stretch;
diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css
new file mode 100644
index 00000000..eb95c467
--- /dev/null
+++ b/packages/ui/src/components/diff-changes.css
@@ -0,0 +1,39 @@
+[data-component="diff-changes"] {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ align-items: center;
+
+ [data-slot="additions"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ text-align: right;
+ color: var(--text-diff-add-base);
+ }
+
+ [data-slot="deletions"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ text-align: right;
+ color: var(--text-diff-delete-base);
+ }
+}
+
+[data-component="diff-changes"][data-variant="bars"] {
+ width: 18px;
+ flex-shrink: 0;
+
+ svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+}
diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx
new file mode 100644
index 00000000..433c47f3
--- /dev/null
+++ b/packages/ui/src/components/diff-changes.tsx
@@ -0,0 +1,122 @@
+import type { FileDiff } from "@opencode-ai/sdk"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
+
+export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "default" | "bars" }) {
+ const variant = () => props.variant ?? "default"
+
+ const additions = createMemo(() =>
+ Array.isArray(props.diff)
+ ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
+ : props.diff.additions,
+ )
+ const deletions = createMemo(() =>
+ Array.isArray(props.diff)
+ ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
+ : props.diff.deletions,
+ )
+ const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
+
+ const countLines = (text: string) => {
+ if (!text) return 0
+ return text.split("\n").length
+ }
+
+ const totalBeforeLines = createMemo(() => {
+ if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
+ return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
+ })
+
+ const blockCounts = createMemo(() => {
+ const TOTAL_BLOCKS = 5
+
+ const adds = additions() ?? 0
+ const dels = deletions() ?? 0
+
+ if (adds === 0 && dels === 0) {
+ return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
+ }
+
+ const total = adds + dels
+
+ if (total < 5) {
+ const added = adds > 0 ? 1 : 0
+ const deleted = dels > 0 ? 1 : 0
+ const neutral = TOTAL_BLOCKS - added - deleted
+ return { added, deleted, neutral }
+ }
+
+ const ratio = adds > dels ? adds / dels : dels / adds
+ let BLOCKS_FOR_COLORS = TOTAL_BLOCKS
+
+ if (total < 20) {
+ BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
+ } else if (ratio < 4) {
+ BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
+ }
+
+ const percentAdded = adds / total
+ const percentDeleted = dels / total
+
+ const added_raw = percentAdded * BLOCKS_FOR_COLORS
+ const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS
+
+ let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0
+ let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0
+
+ // Cap bars based on actual change magnitude
+ if (adds > 0 && adds <= 5) added = Math.min(added, 1)
+ if (adds > 5 && adds <= 10) added = Math.min(added, 2)
+ if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1)
+ if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2)
+
+ let total_allocated = added + deleted
+ if (total_allocated > BLOCKS_FOR_COLORS) {
+ if (added_raw > deleted_raw) {
+ added = BLOCKS_FOR_COLORS - deleted
+ } else {
+ deleted = BLOCKS_FOR_COLORS - added
+ }
+ total_allocated = added + deleted
+ }
+
+ const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated)
+
+ return { added, deleted, neutral }
+ })
+
+ const ADD_COLOR = "var(--icon-diff-add-base)"
+ const DELETE_COLOR = "var(--icon-diff-delete-base)"
+ const NEUTRAL_COLOR = "var(--icon-weak-base)"
+
+ const visibleBlocks = createMemo(() => {
+ const counts = blockCounts()
+ const blocks = [
+ ...Array(counts.added).fill(ADD_COLOR),
+ ...Array(counts.deleted).fill(DELETE_COLOR),
+ ...Array(counts.neutral).fill(NEUTRAL_COLOR),
+ ]
+ return blocks.slice(0, 5)
+ })
+
+ return (
+ 0 : true}>
+
+
+
+
+
+
+ {(color, i) => }
+
+
+
+
+
+ {`+${additions()}`}
+ {`-${deletions()}`}
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css
index c4e83187..860e3b1d 100644
--- a/packages/ui/src/components/diff.css
+++ b/packages/ui/src/components/diff.css
@@ -22,5 +22,9 @@
width: var(--pjs-column-content-width);
left: var(--pjs-column-number-width);
padding-left: 8px;
+
+ [data-slot="diff-hunk-separator-content-span"] {
+ mix-blend-mode: var(--text-mix-blend-mode);
+ }
}
}
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 731b1bfe..e9e46d6b 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -3,12 +3,12 @@ import {
FileDiff,
type DiffLineAnnotation,
type HunkData,
- DiffFileRendererOptions,
+ FileDiffOptions,
// registerCustomTheme,
} from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
-export type DiffProps = Omit, "themes"> & {
+export type DiffProps = FileDiffOptions & {
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation[]
@@ -54,13 +54,9 @@ export function Diff(props: DiffProps) {
// When ready to render, simply call .render with old/new file, optional
// annotations and a container element to hold the diff
createEffect(() => {
- // @ts-expect-error
const instance = new FileDiff({
// theme: "pierre-light",
- // theme: "pierre-light",
- // Or can also provide a 'themes' prop, which allows the code to adapt
- // to your OS light or dark theme
- themes: { dark: "pierre-dark", light: "pierre-light" },
+ theme: { dark: "pierre-dark", light: "pierre-light" },
// When using the 'themes' prop, 'themeType' allows you to force 'dark'
// or 'light' theme, or inherit from the OS ('system') theme.
themeType: "system",
@@ -113,8 +109,11 @@ export function Diff(props: DiffProps) {
numCol.dataset["slot"] = "diff-hunk-separator-line-number"
fragment.appendChild(numCol)
const contentCol = document.createElement("div")
- contentCol.textContent = `${hunkData.lines} unmodified lines`
contentCol.dataset["slot"] = "diff-hunk-separator-content"
+ const span = document.createElement("span")
+ span.dataset["slot"] = "diff-hunk-separator-content-span"
+ span.textContent = `${hunkData.lines} unmodified lines`
+ contentCol.appendChild(span)
fragment.appendChild(contentCol)
return fragment
},
@@ -170,7 +169,7 @@ export function Diff(props: DiffProps) {
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
- "--pjs-tab-size": 4,
+ "--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 5736146e..a2e12729 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -149,6 +149,7 @@ const newIcons = {
console: ` `,
"code-lines": ` `,
"square-arrow-top-right": ` `,
+ "circle-ban-sign": ` `,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 16cbb7d9..8d6ddc89 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,15 +1,25 @@
export * from "./accordion"
export * from "./button"
+export * from "./card"
export * from "./checkbox"
export * from "./collapsible"
export * from "./dialog"
export * from "./diff"
+export * from "./diff-changes"
export * from "./icon"
export * from "./icon-button"
export * from "./input"
export * from "./fonts"
export * from "./list"
+export * from "./markdown"
+export * from "./message-part"
+export * from "./progress-circle"
export * from "./select"
export * from "./select-dialog"
export * from "./tabs"
+export * from "./basic-tool"
export * from "./tooltip"
+
+export * from "../context/helper"
+export * from "../context/shiki"
+export * from "../context/marked"
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
new file mode 100644
index 00000000..abc505a9
--- /dev/null
+++ b/packages/ui/src/components/markdown.css
@@ -0,0 +1,37 @@
+[data-component="markdown"] {
+ min-width: 0;
+ max-width: 100%;
+ overflow: auto;
+ scrollbar-width: none;
+ color: var(--text-base);
+
+ /* text-14-regular */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large); /* 142.857% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ h1,
+ h2,
+ h3 {
+ margin-top: 16px;
+ margin-bottom: 8px;
+ font-weight: var(--font-weight-medium);
+ }
+
+ p {
+ margin-bottom: 8px;
+ }
+
+ ul,
+ ol {
+ margin-top: 16px;
+ margin-bottom: 16px;
+ }
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
new file mode 100644
index 00000000..071132e8
--- /dev/null
+++ b/packages/ui/src/components/markdown.tsx
@@ -0,0 +1,36 @@
+import { useMarked } from "../context/marked"
+import { ComponentProps, createResource, splitProps } from "solid-js"
+
+function strip(text: string): string {
+ const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
+ const match = text.match(wrappedRe)
+ return match ? match[2] : text
+}
+
+export function Markdown(
+ props: ComponentProps<"div"> & {
+ text: string
+ class?: string
+ classList?: Record
+ },
+) {
+ const [local, others] = splitProps(props, ["text", "class", "classList"])
+ const marked = useMarked()
+ const [html] = createResource(
+ () => strip(local.text),
+ async (markdown) => {
+ return marked.parse(markdown)
+ },
+ )
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
new file mode 100644
index 00000000..fa251a2b
--- /dev/null
+++ b/packages/ui/src/components/message-part.css
@@ -0,0 +1,129 @@
+[data-component="assistant-message"] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+[data-component="user-message"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-base);
+ display: -webkit-box;
+ line-clamp: 3;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+[data-component="text-part"] {
+ [data-component="markdown"] {
+ margin-top: 32px;
+ }
+}
+
+[data-component="tool-error"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ [data-slot="icon"] {
+ color: var(--icon-critical-active);
+ }
+
+ [data-slot="content"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ [data-slot="title"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--ember-light-11);
+ text-transform: capitalize;
+ }
+}
+
+[data-component="tool-output"] {
+ white-space: pre;
+}
+
+[data-component="edit-trigger"],
+[data-component="write-trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ [data-slot="title-area"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ [data-slot="title"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-base);
+ text-transform: capitalize;
+ }
+
+ [data-slot="path"] {
+ display: flex;
+ }
+
+ [data-slot="directory"] {
+ color: var(--text-weak);
+ }
+
+ [data-slot="filename"] {
+ color: var(--text-strong);
+ }
+
+ [data-slot="actions"] {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: flex-end;
+ }
+}
+
+[data-component="edit-content"] {
+ border-top: 1px solid var(--border-weaker-base);
+}
+
+[data-component="tool-action"] {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+[data-component="todos"] {
+ padding: 10px 12px 24px 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ [data-slot="todo-content"] {
+ &[data-completed="completed"] {
+ text-decoration: line-through;
+ color: var(--text-weaker);
+ }
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
new file mode 100644
index 00000000..1aaab751
--- /dev/null
+++ b/packages/ui/src/components/message-part.tsx
@@ -0,0 +1,446 @@
+import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
+import { Dynamic } from "solid-js/web"
+import {
+ AssistantMessage,
+ Message as MessageType,
+ Part as PartType,
+ TextPart,
+ ToolPart,
+ UserMessage,
+} from "@opencode-ai/sdk"
+import { BasicTool } from "./basic-tool"
+import { GenericTool } from "./basic-tool"
+import { Card } from "./card"
+import { Icon } from "./icon"
+import { Checkbox } from "./checkbox"
+import { Diff } from "./diff"
+import { DiffChanges } from "./diff-changes"
+import { Markdown } from "./markdown"
+
+export interface MessageProps {
+ message: MessageType
+ parts: PartType[]
+}
+
+export interface MessagePartProps {
+ part: PartType
+ message: MessageType
+ hideDetails?: boolean
+}
+
+export type PartComponent = Component
+
+export const PART_MAPPING: Record = {}
+
+function getFilename(path: string) {
+ if (!path) return ""
+ const trimmed = path.replace(/[\/]+$/, "")
+ const parts = trimmed.split("/")
+ return parts[parts.length - 1] ?? ""
+}
+
+function getDirectory(path: string) {
+ const parts = path.split("/")
+ const dir = parts.slice(0, parts.length - 1).join("/")
+ return dir ? dir + "/" : ""
+}
+
+export function registerPartComponent(type: string, component: PartComponent) {
+ PART_MAPPING[type] = component
+}
+
+export function Message(props: MessageProps) {
+ return (
+
+
+ {(userMessage) => (
+
+ )}
+
+
+ {(assistantMessage) => (
+
+ )}
+
+
+ )
+}
+
+export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
+ const filteredParts = createMemo(() => {
+ return props.parts?.filter((x) => {
+ if (x.type === "reasoning") return false
+ return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
+ })
+ })
+ return {(part) => }
+}
+
+export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
+ const text = createMemo(() =>
+ props.parts
+ ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic)
+ ?.map((p) => (p as TextPart).text)
+ ?.join(""),
+ )
+ return {text()}
+}
+
+export function Part(props: MessagePartProps) {
+ const component = createMemo(() => PART_MAPPING[props.part.type])
+ return (
+
+
+
+ )
+}
+
+export interface ToolProps {
+ input: Record
+ metadata: Record
+ tool: string
+ output?: string
+ hideDetails?: boolean
+}
+
+export type ToolComponent = Component
+
+const state: Record<
+ string,
+ {
+ name: string
+ render?: ToolComponent
+ }
+> = {}
+
+export function registerTool(input: { name: string; render?: ToolComponent }) {
+ state[input.name] = input
+ return input
+}
+
+export function getTool(name: string) {
+ return state[name]?.render
+}
+
+export const ToolRegistry = {
+ register: registerTool,
+ render: getTool,
+}
+
+PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ const part = props.part as ToolPart
+ const component = createMemo(() => {
+ const render = ToolRegistry.render(part.tool) ?? GenericTool
+ const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
+ const input = part.state.status === "completed" ? part.state.input : {}
+
+ return (
+
+
+ {(error) => {
+ const cleaned = error().replace("Error: ", "")
+ const [title, ...rest] = cleaned.split(": ")
+ return (
+
+
+
+
+
+
+
{title}
+
{rest.join(": ")}
+
+
+ {cleaned}
+
+
+
+ )
+ }}
+
+
+
+
+
+ )
+ })
+
+ return {component()}
+}
+
+PART_MAPPING["text"] = function TextPartDisplay(props) {
+ const part = props.part as TextPart
+ return (
+
+
+
+
+
+ )
+}
+
+PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
+ const part = props.part as any
+ return (
+
+
+
+
+
+ )
+}
+
+ToolRegistry.register({
+ name: "read",
+ render(props) {
+ return (
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "list",
+ render(props) {
+ return (
+
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "glob",
+ render(props) {
+ return (
+
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "grep",
+ render(props) {
+ const args = []
+ if (props.input.pattern) args.push("pattern=" + props.input.pattern)
+ if (props.input.include) args.push("include=" + props.input.include)
+ return (
+
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "webfetch",
+ render(props) {
+ return (
+
+
+
+ ),
+ }}
+ >
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "task",
+ render(props) {
+ return (
+
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "bash",
+ render(props) {
+ return (
+
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "edit",
+ render(props) {
+ return (
+
+
+
Edit
+
+
+ {getDirectory(props.input.filePath!)}
+
+ {getFilename(props.input.filePath ?? "")}
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "write",
+ render(props) {
+ return (
+
+
+
Write
+
+
+ {getDirectory(props.input.filePath!)}
+
+ {getFilename(props.input.filePath ?? "")}
+
+
+ {/* */}
+
+ }
+ >
+
+ {props.output}
+
+
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "todowrite",
+ render(props) {
+ return (
+ t.status === "completed").length}/${props.input.todos?.length}`,
+ }}
+ >
+
+
+
+ {(todo: any) => (
+
+
+ {todo.content}
+
+
+ )}
+
+
+
+
+ )
+ },
+})
diff --git a/packages/ui/src/components/progress-circle.css b/packages/ui/src/components/progress-circle.css
new file mode 100644
index 00000000..59182518
--- /dev/null
+++ b/packages/ui/src/components/progress-circle.css
@@ -0,0 +1,12 @@
+[data-component="progress-circle"] {
+ transform: rotate(-90deg);
+
+ [data-slot="background"] {
+ stroke: var(--border-weak-base);
+ }
+
+ [data-slot="progress"] {
+ stroke: var(--border-active);
+ transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1);
+ }
+}
diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx
new file mode 100644
index 00000000..a659c0f2
--- /dev/null
+++ b/packages/ui/src/components/progress-circle.tsx
@@ -0,0 +1,63 @@
+import { type ComponentProps, createMemo, splitProps } from "solid-js"
+
+export interface ProgressCircleProps extends Pick, "class" | "classList"> {
+ percentage: number
+ size?: number
+ strokeWidth?: number
+}
+
+export function ProgressCircle(props: ProgressCircleProps) {
+ const [split, rest] = splitProps(props, [
+ "percentage",
+ "size",
+ "strokeWidth",
+ "class",
+ "classList",
+ ])
+
+ const size = () => split.size || 16
+ const strokeWidth = () => split.strokeWidth || 3
+
+ const viewBoxSize = 16
+ const center = viewBoxSize / 2
+ const radius = () => center - strokeWidth() / 2
+ const circumference = createMemo(() => 2 * Math.PI * radius())
+
+ const offset = createMemo(() => {
+ const clampedPercentage = Math.max(0, Math.min(100, split.percentage || 0))
+ const progress = clampedPercentage / 100
+ return circumference() * (1 - progress)
+ })
+
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index 29057fc8..1d786fb4 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -57,9 +57,6 @@
border-bottom: 1px solid var(--border-weak-base);
border-right: 1px solid var(--border-weak-base);
background-color: var(--background-base);
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
&:disabled {
pointer-events: none;
diff --git a/packages/ui/src/context/helper.tsx b/packages/ui/src/context/helper.tsx
new file mode 100644
index 00000000..6be88e77
--- /dev/null
+++ b/packages/ui/src/context/helper.tsx
@@ -0,0 +1,25 @@
+import { createContext, Show, useContext, type ParentProps } from "solid-js"
+
+export function createSimpleContext>(input: {
+ name: string
+ init: ((input: Props) => T) | (() => T)
+}) {
+ const ctx = createContext()
+
+ return {
+ provider: (props: ParentProps) => {
+ const init = input.init(props)
+ return (
+ // @ts-expect-error
+
+ {props.children}
+
+ )
+ },
+ use() {
+ const value = useContext(ctx)
+ if (!value) throw new Error(`${input.name} context must be used within a context provider`)
+ return value
+ },
+ }
+}
diff --git a/packages/desktop/src/context/marked.tsx b/packages/ui/src/context/marked.tsx
similarity index 100%
rename from packages/desktop/src/context/marked.tsx
rename to packages/ui/src/context/marked.tsx
diff --git a/packages/desktop/src/context/shiki.tsx b/packages/ui/src/context/shiki.tsx
similarity index 98%
rename from packages/desktop/src/context/shiki.tsx
rename to packages/ui/src/context/shiki.tsx
index b6c278bf..d33b98ab 100644
--- a/packages/desktop/src/context/shiki.tsx
+++ b/packages/ui/src/context/shiki.tsx
@@ -373,7 +373,11 @@ const theme: ThemeInput = {
},
},
{
- scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
+ scope: [
+ "storage.modifier.import.java",
+ "variable.language.wildcard.java",
+ "storage.modifier.package.java",
+ ],
settings: {
foreground: "var(--text-base)",
},
diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx
index b3ddf69e..ca9bc538 100644
--- a/packages/ui/src/hooks/use-filtered-list.tsx
+++ b/packages/ui/src/hooks/use-filtered-list.tsx
@@ -11,18 +11,22 @@ export interface FilteredListProps {
current?: T
groupBy?: (x: T) => string
sortBy?: (a: T, b: T) => number
- sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
+ sortGroupsBy?: (
+ a: { category: string; items: T[] },
+ b: { category: string; items: T[] },
+ ) => number
onSelect?: (value: T | undefined) => void
}
export function useFilteredList(props: FilteredListProps) {
const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
- const [grouped] = createResource(
+ const [grouped, { refetch }] = createResource(
() => store.filter,
async (filter) => {
const needle = filter?.toLowerCase()
- const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
+ const all =
+ (typeof props.items === "function" ? await props.items(needle) : props.items) || []
const result = pipe(
all,
(x) => {
@@ -76,10 +80,11 @@ export function useFilteredList(props: FilteredListProps) {
}
return {
- filter: () => store.filter,
grouped,
+ filter: () => store.filter,
flat,
reset,
+ refetch,
clear: () => setStore("filter", ""),
onKeyDown,
onInput,
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 94fa894d..cea5a082 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -6,15 +6,21 @@
@import "./base.css" layer(base);
@import "../components/accordion.css" layer(components);
+@import "../components/basic-tool.css" layer(components);
@import "../components/button.css" layer(components);
+@import "../components/card.css" layer(components);
@import "../components/checkbox.css" layer(components);
@import "../components/diff.css" layer(components);
+@import "../components/diff-changes.css" layer(components);
@import "../components/collapsible.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);
@import "../components/input.css" layer(components);
@import "../components/list.css" layer(components);
+@import "../components/markdown.css" layer(components);
+@import "../components/message-part.css" layer(components);
+@import "../components/progress-circle.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/select-dialog.css" layer(components);
@import "../components/tabs.css" layer(components);
diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css
index 600de584..c401dcae 100644
--- a/packages/ui/src/styles/theme.css
+++ b/packages/ui/src/styles/theme.css
@@ -59,10 +59,14 @@
0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)),
0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12);
+
+ --text-mix-blend-mode: multiply;
}
:root {
/* OC-1-Light */
+ --text-mix-blend-mode: multiply;
+
color-scheme: light;
--background-base: #f8f7f7;
--background-weak: var(--smoke-light-3);
@@ -292,6 +296,8 @@
--button-ghost-hover2: var(--smoke-light-alpha-3);
@media (prefers-color-scheme: dark) {
+ --text-mix-blend-mode: plus-lighter;
+
/* OC-1-Dark */
color-scheme: dark;
--background-base: var(--smoke-dark-1);
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index 8b4ebee2..440aa8f9 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -1,10 +1,11 @@
{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
// General
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
-
// Modules
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
@@ -12,9 +13,16 @@
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
-
+ "lib": [
+ "es2022",
+ "dom",
+ "dom.iterable"
+ ],
// Type Checking & Safety
"strict": true,
- "types": ["vite/client"]
+ "types": [
+ "vite/client",
+ "bun"
+ ]
}
}