diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 75533df70..f7d7f6081 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -3,6 +3,8 @@ name: opencode on: issue_comment: types: [created] + pull_request_review_comment: + types: [created] jobs: opencode: diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 815433f03..f8bbb78c6 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -4,7 +4,7 @@ on: push: branches: - dev - - fix-snapshot-2 + - test-bedrock - v0 concurrency: ${{ github.workflow }}-${{ github.ref }} diff --git a/bun.lock b/bun.lock index 171e06e58..d10b145c5 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -47,7 +47,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -74,7 +74,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -98,7 +98,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -122,7 +122,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -163,7 +163,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -191,7 +191,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -207,7 +207,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.110", + "version": "1.0.114", "bin": { "opencode": "./bin/opencode", }, @@ -294,7 +294,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -314,7 +314,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.110", + "version": "1.0.114", "devDependencies": { "@hey-api/openapi-ts": "0.81.0", "@tsconfig/node22": "catalog:", @@ -325,7 +325,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -338,7 +338,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", @@ -351,7 +351,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -383,7 +383,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "zod": "catalog:", }, @@ -393,7 +393,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/github/README.md b/github/README.md index 8e5b6d813..36342b409 100644 --- a/github/README.md +++ b/github/README.md @@ -30,6 +30,24 @@ Leave the following comment on a GitHub PR. opencode will implement the requeste Delete the attachment from S3 when the note is removed /oc ``` +#### Review specific code lines + +Leave a comment directly on code lines in the PR's "Files" tab. opencode will automatically detect the file, line numbers, and diff context to provide precise responses. + +``` +[Comment on specific lines in Files tab] +/oc add error handling here +``` + +When commenting on specific lines, opencode receives: + +- The exact file being reviewed +- The specific lines of code +- The surrounding diff context +- Line number information + +This allows for more targeted requests without needing to specify file paths or line numbers manually. + ## Installation Run the following command in the terminal from your GitHub repo: @@ -51,6 +69,8 @@ This will walk you through installing the GitHub app, creating the workflow, and on: issue_comment: types: [created] + pull_request_review_comment: + types: [created] jobs: opencode: @@ -135,3 +155,9 @@ Replace the image URL `https://github.com/user-attachments/assets/xxxxxxxx` with ``` MOCK_EVENT='{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4,"pull_request":{}},"comment":{"id":1,"body":"hey opencode, summarize thread"}}}' ``` + +### PR review comment event + +``` +MOCK_EVENT='{"eventName":"pull_request_review_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"pull_request":{"number":7},"comment":{"id":1,"body":"hey opencode, add error handling","path":"src/components/Button.tsx","diff_hunk":"@@ -45,8 +45,11 @@\n- const handleClick = () => {\n- console.log('clicked')\n+ const handleClick = useCallback(() => {\n+ console.log('clicked')\n+ doSomething()\n+ }, [doSomething])","line":47,"original_line":45,"position":10,"commit_id":"abc123","original_commit_id":"def456"}}}' +``` diff --git a/github/index.ts b/github/index.ts index b681ff92f..6d826326e 100644 --- a/github/index.ts +++ b/github/index.ts @@ -5,7 +5,7 @@ import { graphql } from "@octokit/graphql" import * as core from "@actions/core" import * as github from "@actions/github" import type { Context as GitHubContext } from "@actions/github/lib/context" -import type { IssueCommentEvent } from "@octokit/webhooks-types" +import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" import { createOpencodeClient } from "@opencode-ai/sdk" import { spawn } from "node:child_process" @@ -124,7 +124,7 @@ let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] try { - assertContextEvent("issue_comment") + assertContextEvent("issue_comment", "pull_request_review_comment") assertPayloadKeyword() await assertOpencodeConnected() @@ -241,19 +241,43 @@ function createOpencode() { } function assertPayloadKeyword() { - const payload = useContext().payload as IssueCommentEvent + const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent const body = payload.comment.body.trim() if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) { throw new Error("Comments must mention `/opencode` or `/oc`") } } +function getReviewCommentContext() { + const context = useContext() + if (context.eventName !== "pull_request_review_comment") { + return null + } + + const payload = context.payload as PullRequestReviewCommentEvent + return { + file: payload.comment.path, + diffHunk: payload.comment.diff_hunk, + line: payload.comment.line, + originalLine: payload.comment.original_line, + position: payload.comment.position, + commitId: payload.comment.commit_id, + originalCommitId: payload.comment.original_commit_id, + } +} + async function assertOpencodeConnected() { let retry = 0 let connected = false do { try { - await client.app.get() + await client.app.log({ + body: { + service: "github-workflow", + level: "info", + message: "Prepare to react to Github Workflow event", + }, + }) connected = true break } catch (e) {} @@ -383,11 +407,24 @@ async function createComment() { } async function getUserPrompt() { + const context = useContext() + const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent + const reviewContext = getReviewCommentContext() + let prompt = (() => { - const payload = useContext().payload as IssueCommentEvent const body = payload.comment.body.trim() - if (body === "/opencode" || body === "/oc") return "Summarize this thread" - if (body.includes("/opencode") || body.includes("/oc")) return body + if (body === "/opencode" || body === "/oc") { + if (reviewContext) { + return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}` + } + return "Summarize this thread" + } + if (body.includes("/opencode") || body.includes("/oc")) { + if (reviewContext) { + return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}` + } + return body + } throw new Error("Comments must mention `/opencode` or `/oc`") })() diff --git a/install b/install index f60a72642..77ecf34b9 100755 --- a/install +++ b/install @@ -11,43 +11,82 @@ requested_version=${VERSION:-} raw_os=$(uname -s) os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') -# Normalize various Unix-like identifiers case "$raw_os" in Darwin*) os="darwin" ;; Linux*) os="linux" ;; MINGW*|MSYS*|CYGWIN*) os="windows" ;; - esac -arch=$(uname -m) +esac +arch=$(uname -m) if [[ "$arch" == "aarch64" ]]; then arch="arm64" -elif [[ "$arch" == "x86_64" ]]; then +fi +if [[ "$arch" == "x86_64" ]]; then arch="x64" fi -if [ "$os" = "linux" ]; then - filename="$APP-$os-$arch.tar.gz" -else - filename="$APP-$os-$arch.zip" +if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) + if [ "$rosetta_flag" = "1" ]; then + arch="arm64" + fi fi - -case "$filename" in - *"-linux-"*) - [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1 +combo="$os-$arch" +case "$combo" in + linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) ;; - *"-darwin-"*) - [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1 - ;; - *"-windows-"*) - [[ "$arch" == "x64" ]] || exit 1 - ;; - *) - echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" - exit 1 + *) + echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" + exit 1 ;; esac +archive_ext=".zip" +if [ "$os" = "linux" ]; then + archive_ext=".tar.gz" +fi + +is_musl=false +if [ "$os" = "linux" ]; then + if [ -f /etc/alpine-release ]; then + is_musl=true + fi + + if command -v ldd >/dev/null 2>&1; then + if ldd --version 2>&1 | grep -qi musl; then + is_musl=true + fi + fi +fi + +needs_baseline=false +if [ "$arch" = "x64" ]; then + if [ "$os" = "linux" ]; then + if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then + needs_baseline=true + fi + fi + + if [ "$os" = "darwin" ]; then + avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) + if [ "$avx2" != "1" ]; then + needs_baseline=true + fi + fi +fi + +target="$os-$arch" +if [ "$needs_baseline" = "true" ]; then + target="$target-baseline" +fi +if [ "$is_musl" = "true" ]; then + target="$target-musl" +fi + +filename="$APP-$target$archive_ext" + + if [ "$os" = "linux" ]; then if ! command -v tar >/dev/null 2>&1; then echo -e "${RED}Error: 'tar' is required but not installed.${NC}" diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3a20b4752..76b333dff 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.110", + "version": "1.0.114", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 330bcc1cf..972b60d6c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -13,13 +13,20 @@ 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, RateLimitError } from "./error" -import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider" +import { + createBodyConverter, + createStreamPartConverter, + createResponseConverter, + ProviderHelper, + UsageInfo, +} from "./provider/provider" import { anthropicHelper } from "./provider/anthropic" import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" import { oaCompatHelper } from "./provider/openai-compatible" import { createRateLimiter } from "./rateLimiter" import { createDataDumper } from "./dataDumper" +import { createTrialLimiter } from "./trialLimiter" type ZenData = Awaited> type RetryOptions = { @@ -62,11 +69,13 @@ export async function handler( const zenData = ZenData.list() const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId) + const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip) + const isTrial = await trialLimiter?.isTrial() const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) await rateLimiter?.check() const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { - const providerInfo = selectProvider(zenData, modelInfo, sessionId, retry) + const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry) const authInfo = await authenticate(modelInfo, providerInfo) validateBilling(authInfo, modelInfo) validateModelSettings(authInfo) @@ -136,8 +145,10 @@ export async function handler( logger.debug("RESPONSE: " + body) dataDumper?.provideResponse(body) dataDumper?.flush() + const tokensInfo = providerInfo.normalizeUsage(json.usage) + await trialLimiter?.track(tokensInfo) await rateLimiter?.track() - await trackUsage(authInfo, modelInfo, providerInfo, json.usage) + await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) await reload(authInfo) return new Response(body, { status: res.status, @@ -169,7 +180,9 @@ export async function handler( await rateLimiter?.track() const usage = usageParser.retrieve() if (usage) { - await trackUsage(authInfo, modelInfo, providerInfo, usage) + const tokensInfo = providerInfo.normalizeUsage(usage) + await trialLimiter?.track(tokensInfo) + await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) await reload(authInfo) } c.close() @@ -275,8 +288,18 @@ export async function handler( return { id: modelId, ...modelData } } - function selectProvider(zenData: ZenData, modelInfo: ModelInfo, sessionId: string, retry: RetryOptions) { + function selectProvider( + zenData: ZenData, + modelInfo: ModelInfo, + sessionId: string, + isTrial: boolean, + retry: RetryOptions, + ) { const provider = (() => { + if (isTrial) { + return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider) + } + if (retry.retryCount === MAX_RETRIES) { return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) } @@ -432,9 +455,14 @@ export async function handler( providerInfo.apiKey = authInfo.provider.credentials } - async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) { + async function trackUsage( + authInfo: AuthInfo, + modelInfo: ModelInfo, + providerInfo: ProviderInfo, + usageInfo: UsageInfo, + ) { const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = - providerInfo.normalizeUsage(usage) + usageInfo const modelCost = modelInfo.cost200K && diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index 8366f3a63..730ad5a27 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -24,6 +24,15 @@ import { toOaCompatibleResponse, } from "./openai-compatible" +export type UsageInfo = { + inputTokens: number + outputTokens: number + reasoningTokens?: number + cacheReadTokens?: number + cacheWrite5mTokens?: number + cacheWrite1hTokens?: number +} + export type ProviderHelper = { format: ZenData.Format modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string @@ -34,14 +43,7 @@ export type ProviderHelper = { parse: (chunk: string) => void retrieve: () => any } - normalizeUsage: (usage: any) => { - inputTokens: number - outputTokens: number - reasoningTokens?: number - cacheReadTokens?: number - cacheWrite5mTokens?: number - cacheWrite1hTokens?: number - } + normalizeUsage: (usage: any) => UsageInfo } export interface CommonMessage { diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts new file mode 100644 index 000000000..15561c9f6 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/trialLimiter.ts @@ -0,0 +1,43 @@ +import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js" +import { UsageInfo } from "./provider/provider" + +export function createTrialLimiter(limit: number | undefined, ip: string) { + if (!limit) return + if (!ip) return + + let trial: boolean + + return { + isTrial: async () => { + const data = await Database.use((tx) => + tx + .select({ + usage: IpTable.usage, + }) + .from(IpTable) + .where(eq(IpTable.ip, ip)) + .then((rows) => rows[0]), + ) + + trial = (data?.usage ?? 0) < limit + return trial + }, + track: async (usageInfo: UsageInfo) => { + if (!trial) return + const usage = + usageInfo.inputTokens + + usageInfo.outputTokens + + (usageInfo.reasoningTokens ?? 0) + + (usageInfo.cacheReadTokens ?? 0) + + (usageInfo.cacheWrite5mTokens ?? 0) + + (usageInfo.cacheWrite1hTokens ?? 0) + await Database.use((tx) => + tx + .insert(IpTable) + .values({ ip, usage }) + .onDuplicateKeyUpdate({ set: { usage: sql`${IpTable.usage} + ${usage}` } }), + ) + }, + } +} diff --git a/packages/console/core/migrations/0038_famous_magik.sql b/packages/console/core/migrations/0038_famous_magik.sql new file mode 100644 index 000000000..ad195f555 --- /dev/null +++ b/packages/console/core/migrations/0038_famous_magik.sql @@ -0,0 +1,8 @@ +CREATE TABLE `ip` ( + `ip` varchar(45) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `usage` int, + CONSTRAINT `ip_ip_pk` PRIMARY KEY(`ip`) +); diff --git a/packages/console/core/migrations/meta/0038_snapshot.json b/packages/console/core/migrations/meta/0038_snapshot.json new file mode 100644 index 000000000..b0a59c497 --- /dev/null +++ b/packages/console/core/migrations/meta/0038_snapshot.json @@ -0,0 +1,981 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7", + "prevId": "8b7fa839-a088-408e-84a4-1a07325c0290", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 250fe59b3..8a1a38551 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1761928273807, "tag": "0037_messy_jackal", "breakpoints": true + }, + { + "idx": 38, + "version": "5", + "when": 1764110043942, + "tag": "0038_famous_magik", + "breakpoints": true } ] } diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c63e10282..ad2dcdf4a 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.110", + "version": "1.0.114", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index fd6cd095e..8cc181b7c 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -24,6 +24,12 @@ export namespace ZenData { cost: ModelCostSchema, cost200K: ModelCostSchema.optional(), allowAnonymous: z.boolean().optional(), + trial: z + .object({ + limit: z.number(), + provider: z.string(), + }) + .optional(), rateLimit: z.number().optional(), fallbackProvider: z.string().optional(), providers: z.array( diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts new file mode 100644 index 000000000..be5fb7fa2 --- /dev/null +++ b/packages/console/core/src/schema/ip.sql.ts @@ -0,0 +1,12 @@ +import { mysqlTable, int, primaryKey, varchar } from "drizzle-orm/mysql-core" +import { timestamps } from "../drizzle/types" + +export const IpTable = mysqlTable( + "ip", + { + ip: varchar("ip", { length: 45 }).notNull(), + ...timestamps, + usage: int("usage"), + }, + (table) => [primaryKey({ columns: [table.ip] })], +) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 82b2a2ed1..f0e09c25c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.110", + "version": "1.0.114", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d263bad8e..de270f8ff 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.110", + "version": "1.0.114", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 93517f10c..a98738c4c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.110", + "version": "1.0.114", "description": "", "type": "module", "scripts": { diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index f3729a8d3..0841c71d1 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -1,5 +1,5 @@ import { useLocal, type LocalFile } from "@/context/local" -import { Collapsible } from "@/ui" +import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" @@ -76,6 +76,7 @@ export default function FileTree(props: { p())).then(() => setStore("ready", true)) - 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" || part.state.status === "error") { - 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) - } - } - if ("error" in part.state) { - part.state.error = sanitize(part.state.error as string) - } - } - } - return part - } return { data: store, @@ -88,10 +65,7 @@ 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() - .map(sanitizePart) - .sort((a, b) => a.id.localeCompare(b.id)) + draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) } draft.session_diff[sessionID] = diff.data ?? [] }), @@ -105,7 +79,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, load, absolute, - sanitize, + get directory() { + return store.path.directory + }, } }, }) diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 2fe750fda..de16eff30 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() return ( - + {props.children} ) diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index e773fff57..58fcb20ce 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -1,8 +1,9 @@ import { useGlobalSync } from "@/context/global-sync" -import { base64Encode, getFilename } from "@/utils" +import { base64Encode } from "@/utils" import { For } from "solid-js" import { A } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" +import { getFilename } from "@opencode-ai/util/path" export default function Home() { const sync = useGlobalSync() diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index c9bb559d8..15180c885 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -3,7 +3,7 @@ import { DateTime } from "luxon" import { A, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Encode, getFilename } from "@/utils" +import { base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -11,6 +11,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { getFilename } from "@opencode-ai/util/path" export default function Layout(props: ParentProps) { const params = useParams() diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index a6188b8e9..40acac663 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,7 +1,6 @@ import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" import { PromptInput } from "@/components/prompt-input" import { DateTime } from "luxon" import { FileIcon } from "@opencode-ai/ui/file-icon" @@ -30,6 +29,7 @@ import type { JSX } from "solid-js" import { useSync } from "@/context/sync" import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" +import { getDirectory, getFilename } from "@opencode-ai/util/path" export default function Page() { const layout = useLayout() @@ -401,10 +401,15 @@ export default function Page() {
@@ -427,10 +432,18 @@ export default function Page() {
- +
diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx deleted file mode 100644 index 5fbb6c7a4..000000000 --- a/packages/desktop/src/ui/collapsible.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible" -import { Icon, IconProps } from "@opencode-ai/ui/icon" -import { splitProps } from "solid-js" -import type { ComponentProps, ParentProps } from "solid-js" - -export interface CollapsibleProps extends ComponentProps {} -export interface CollapsibleTriggerProps extends ComponentProps {} -export interface CollapsibleContentProps extends ComponentProps {} - -function CollapsibleRoot(props: CollapsibleProps) { - return -} - -function CollapsibleTrigger(props: CollapsibleTriggerProps) { - const [local, others] = splitProps(props, ["class"]) - return ( - - ) -} - -function CollapsibleContent(props: ParentProps) { - const [local, others] = splitProps(props, ["class", "children"]) - return ( - - {local.children} - - ) -} - -function CollapsibleArrow(props: Partial) { - const [local, others] = splitProps(props, ["class", "name"]) - return ( - - ) -} - -export const Collapsible = Object.assign(CollapsibleRoot, { - Trigger: CollapsibleTrigger, - Content: CollapsibleContent, - Arrow: CollapsibleArrow, -}) diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts deleted file mode 100644 index 8cbf0834f..000000000 --- a/packages/desktop/src/ui/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - Collapsible, - type CollapsibleProps, - type CollapsibleTriggerProps, - type CollapsibleContentProps, -} from "./collapsible" diff --git a/packages/desktop/src/utils/index.ts b/packages/desktop/src/utils/index.ts index 63a656cc4..e50efe837 100644 --- a/packages/desktop/src/utils/index.ts +++ b/packages/desktop/src/utils/index.ts @@ -1,3 +1,2 @@ -export * from "./path" export * from "./dom" export * from "./encode" diff --git a/packages/desktop/src/utils/path.ts b/packages/desktop/src/utils/path.ts deleted file mode 100644 index d23568ae6..000000000 --- a/packages/desktop/src/utils/path.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useSync } from "@/context/sync" - -export function getFilename(path: string) { - if (!path) return "" - const trimmed = path.replace(/[\/]+$/, "") - const parts = trimmed.split("/") - return parts[parts.length - 1] ?? "" -} - -export function getDirectory(path: string) { - const sync = useSync() - const parts = path.split("/") - const dir = parts.slice(0, parts.length - 1).join("/") - return dir ? sync.sanitize(dir + "/") : "" -} - -export function getFileExtension(path: string) { - const parts = path.split(".") - return parts[parts.length - 1] -} diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c562358d1..965e58db5 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.110", + "version": "1.0.114", "private": true, "type": "module", "scripts": { diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 50181d71f..fd6638a5f 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -7,6 +7,7 @@ import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } fro import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" +import { createDefaultOptions } from "@opencode-ai/ui/pierre" import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" @@ -16,7 +17,7 @@ import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" -import { HunkData, preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" const SessionDataMissingError = NamedError.create( "SessionDataMissingError", @@ -82,20 +83,7 @@ const getData = query(async (shareID) => { preloadMultiFileDiff({ oldFile: { name: diff.file, contents: diff.before }, newFile: { name: diff.file, contents: diff.after }, - options: { - theme: "OpenCode", - themeType: "system", - disableLineNumbers: false, - overflow: "wrap", - diffStyle: "unified", - diffIndicators: "bars", - disableBackground: false, - expansionLineCount: 20, - lineDiffType: "none", - maxLineDiffLength: 1000, - maxLineLengthForHighlighting: 1000, - disableFileHeader: true, - }, + options: createDefaultOptions("unified"), // annotations, }), ), @@ -141,205 +129,226 @@ export default function () { }} > - {(data) => ( - - {iife(() => { - const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - }) - const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id)) - if (!match().found) throw new Error(`Session ${data().sessionID} not found`) - const info = createMemo(() => data().session[match().index]) - const messages = createMemo(() => - data().sessionID - ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => b.time.created - a.time.created, - ) - : [], - ) - const firstUserMessage = createMemo(() => messages().at(0)) - const activeMessage = createMemo( - () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), - ) - function setActiveMessage(message: UserMessage | undefined) { - if (message) { - setStore("messageId", message.id) - } else { - setStore("messageId", undefined) + {(data) => { + const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id)) + if (!match().found) throw new Error(`Session ${data().sessionID} not found`) + const info = createMemo(() => data().session[match().index]) + + return ( + + {iife(() => { + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + }) + const messages = createMemo(() => + data().sessionID + ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( + (a, b) => b.time.created - a.time.created, + ) + : [], + ) + const firstUserMessage = createMemo(() => messages().at(0)) + const activeMessage = createMemo( + () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), + ) + function setActiveMessage(message: UserMessage | undefined) { + if (message) { + setStore("messageId", message.id) + } else { + setStore("messageId", undefined) + } } - } - const provider = createMemo(() => activeMessage()?.model?.providerID) - const modelID = createMemo(() => activeMessage()?.model?.modelID) - const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + const provider = createMemo(() => activeMessage()?.model?.providerID) + const modelID = createMemo(() => activeMessage()?.model?.modelID) + const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) - const title = () => ( -
-
-
- -
v{info().version}
-
-
- -
{model()?.name ?? modelID()}
-
-
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} + const title = () => ( +
+
+
+ +
v{info().version}
+
+
+ +
{model()?.name ?? modelID()}
+
+
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} +
+
{info().title}
-
{info().title}
-
- ) + ) - const turns = () => ( -
- {title()} -
- - {(message) => ( - - )} - -
-
- -
-
- ) - - const wide = createMemo(() => diffs().length === 0) - - return ( -
-
-
- - - -
-
- - -
-
-
- - ) - })} - - )} + ) + + const wide = createMemo(() => diffs().length === 0) + + return ( +
+
+
+ + + +
+
+ + +
+
+
+ + + 0}> + + + + Session + + + 5 Files Changed + + + + {turns()} + + + + + +
{turns()}
+
+
+
+
+ ) + })} + + ) + }} ) diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 336759b88..2dc3c7a1b 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The AI coding agent built for the terminal" -version = "1.0.110" +version = "1.0.114" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.114/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.114/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-linux-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.114/opencode-linux-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-linux-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.114/opencode-linux-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.114/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 4ee85fa6a..2868abcd3 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.110", + "version": "1.0.114", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1d61b56bf..e75542083 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.110", + "version": "1.0.114", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 1f60e81e9..b255e17d1 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -7,7 +7,7 @@ import { graphql } from "@octokit/graphql" import * as core from "@actions/core" import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" -import type { IssueCommentEvent } from "@octokit/webhooks-types" +import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" @@ -328,6 +328,8 @@ export const GithubInstallCommand = cmd({ on: issue_comment: types: [created] + pull_request_review_comment: + types: [created] jobs: opencode: @@ -378,7 +380,7 @@ export const GithubRunCommand = cmd({ const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context - if (context.eventName !== "issue_comment") { + if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") { core.setFailed(`Unsupported event type: ${context.eventName}`) process.exit(1) } @@ -387,9 +389,14 @@ export const GithubRunCommand = cmd({ const runId = normalizeRunId() const share = normalizeShare() const { owner, repo } = context.repo - const payload = context.payload as IssueCommentEvent + const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent + const issueEvent = isIssueCommentEvent(payload) ? payload : undefined const actor = context.actor - const issueId = payload.issue.number + + const issueId = + context.eventName === "pull_request_review_comment" + ? (payload as PullRequestReviewCommentEvent).pull_request.number + : (payload as IssueCommentEvent).issue.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" @@ -434,7 +441,7 @@ export const GithubRunCommand = cmd({ // 1. Issue // 2. Local PR // 3. Fork PR - if (payload.issue.pull_request) { + if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) { const prData = await fetchPR() // Local PR if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) { @@ -531,11 +538,45 @@ export const GithubRunCommand = cmd({ throw new Error(`Invalid share value: ${value}. Share must be a boolean.`) } + function isIssueCommentEvent( + event: IssueCommentEvent | PullRequestReviewCommentEvent, + ): event is IssueCommentEvent { + return "issue" in event + } + + function getReviewCommentContext() { + if (context.eventName !== "pull_request_review_comment") { + return null + } + + const reviewPayload = payload as PullRequestReviewCommentEvent + return { + file: reviewPayload.comment.path, + diffHunk: reviewPayload.comment.diff_hunk, + line: reviewPayload.comment.line, + originalLine: reviewPayload.comment.original_line, + position: reviewPayload.comment.position, + commitId: reviewPayload.comment.commit_id, + originalCommitId: reviewPayload.comment.original_commit_id, + } + } + async function getUserPrompt() { + const reviewContext = getReviewCommentContext() let prompt = (() => { const body = payload.comment.body.trim() - if (body === "/opencode" || body === "/oc") return "Summarize this thread" - if (body.includes("/opencode") || body.includes("/oc")) return body + if (body === "/opencode" || body === "/oc") { + if (reviewContext) { + return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}` + } + return "Summarize this thread" + } + if (body.includes("/opencode") || body.includes("/oc")) { + if (reviewContext) { + return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}` + } + return body + } throw new Error("Comments must mention `/opencode` or `/oc`") })() @@ -652,7 +693,10 @@ export const GithubRunCommand = cmd({ try { return await chat(`Summarize the following in less than 40 characters:\n\n${response}`) } catch (e) { - return `Fix issue: ${payload.issue.title}` + const title = issueEvent + ? issueEvent.issue.title + : (payload as PullRequestReviewCommentEvent).pull_request.title + return `Fix issue: ${title}` } } diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 58e8397db..f41b23ee9 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -26,6 +26,8 @@ interface SessionStats { } days: number costPerDay: number + tokensPerSession: number + medianTokensPerSession: number } export const StatsCommand = cmd({ @@ -116,6 +118,8 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro }, days: 0, costPerDay: 0, + tokensPerSession: 0, + medianTokensPerSession: 0, } if (filteredSessions.length > 1000) { @@ -129,6 +133,8 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro let earliestTime = Date.now() let latestTime = 0 + const sessionTotalTokens: number[] = [] + const BATCH_SIZE = 20 for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { const batch = filteredSessions.slice(i, i + BATCH_SIZE) @@ -164,6 +170,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro messageCount: messages.length, sessionCost, sessionTokens, + sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning, sessionToolUsage, earliestTime: session.time.created, latestTime: session.time.updated, @@ -175,6 +182,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro for (const result of batchResults) { earliestTime = Math.min(earliestTime, result.earliestTime) latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) stats.totalMessages += result.messageCount stats.totalCost += result.sessionCost @@ -197,6 +205,16 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } stats.days = actualDays stats.costPerDay = stats.totalCost / actualDays + const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning + stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0 + sessionTotalTokens.sort((a, b) => a - b) + const mid = Math.floor(sessionTotalTokens.length / 2) + stats.medianTokensPerSession = + sessionTotalTokens.length === 0 + ? 0 + : sessionTotalTokens.length % 2 === 0 + ? (sessionTotalTokens[mid - 1] + sessionTotalTokens[mid]) / 2 + : sessionTotalTokens[mid] return stats } @@ -227,8 +245,12 @@ export function displayStats(stats: SessionStats, toolLimit?: number) { console.log("├────────────────────────────────────────────────────────┤") const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay + const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`)) - console.log(renderRow("Cost/Day", `$${costPerDay.toFixed(2)}`)) + console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`)) + console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession)))) + const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession + console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession)))) console.log(renderRow("Input", formatNumber(stats.totalTokens.input))) console.log(renderRow("Output", formatNumber(stats.totalTokens.output))) console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read))) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 33be73ca2..13b02154e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,16 +186,13 @@ function App() { }) }) + let continued = false createEffect(() => { - if (sync.status !== "complete") return - if (args.continue) { - const match = sync.data.session.at(0)?.id - if (match) { - route.navigate({ - type: "session", - sessionID: match, - }) - } + if (continued || sync.status !== "complete" || !args.continue) return + const match = sync.data.session.at(0)?.id + if (match) { + continued = true + route.navigate({ type: "session", sessionID: match }) } }) @@ -481,7 +478,10 @@ function App() { v{Installation.VERSION} - {process.cwd().replace(Global.Path.home, "~")} + + {process.cwd().replace(Global.Path.home, "~")} + {sync.data.vcs?.vcs?.branch ? `:${sync.data.vcs.vcs.branch}` : ""} + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index c25e7e370..ba1dc70b2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,10 +1,11 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" -import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda" +import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { Keybind } from "@/util/keybind" export function DialogModel() { const local = useLocal() @@ -16,14 +17,45 @@ export function DialogModel() { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) - const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected()) const providers = createDialogProviderOptions() const options = createMemo(() => { - return [ - ...(showRecent() - ? local.model.recent().flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID)! + const query = ref()?.filter + const favorites = local.model.favorite() + const recents = local.model.recent() + const currentModel = local.model.current() + + const orderedRecents = currentModel + ? [ + currentModel, + ...recents.filter( + (item) => item.providerID !== currentModel.providerID || item.modelID !== currentModel.modelID, + ), + ] + : recents + + const isCurrent = (item: { providerID: string; modelID: string }) => + currentModel && item.providerID === currentModel.providerID && item.modelID === currentModel.modelID + + const currentIsFavorite = currentModel && favorites.some((fav) => isCurrent(fav)) + + const recentList = orderedRecents + .filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID)) + .slice(0, 5) + + const orderedFavorites = currentModel + ? [...favorites.filter((item) => isCurrent(item)), ...favorites.filter((item) => !isCurrent(item))] + : favorites + + const orderedRecentList = + currentModel && !currentIsFavorite + ? [...recentList.filter((item) => isCurrent(item)), ...recentList.filter((item) => !isCurrent(item))] + : recentList + + const favoriteOptions = + !query && favorites.length > 0 + ? orderedFavorites.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) if (!provider) return [] const model = provider.models[item.modelID] if (!model) return [] @@ -35,8 +67,9 @@ export function DialogModel() { modelID: model.id, }, title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", + description: `${provider.name} ★`, + category: "Favorites", + disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, onSelect: () => { dialog.clear() @@ -51,7 +84,44 @@ export function DialogModel() { }, ] }) - : []), + : [] + + const recentOptions = !query + ? orderedRecentList.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { + providerID: provider.id, + modelID: model.id, + }, + title: model.name ?? item.modelID, + description: provider.name, + category: "Recent", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model.id, + }, + { recent: true }, + ) + }, + }, + ] + }) + : [] + + return [ + ...favoriteOptions, + ...recentOptions, ...pipe( sync.data.provider, sortBy( @@ -62,28 +132,46 @@ export function DialogModel() { pipe( provider.models, entries(), - map(([model, info]) => ({ - value: { + map(([model, info]) => { + const value = { providerID: provider.id, modelID: model, - }, - title: info.name ?? model, - description: connected() ? provider.name : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model, - }, - { recent: true }, - ) - }, - })), - filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))), + } + const favorite = favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + return { + value, + title: info.name ?? model, + description: connected() ? `${provider.name}${favorite ? " ★" : ""}` : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect() { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model, + }, + { recent: true }, + ) + }, + } + }), + filter((x) => { + if (query) return true + const value = x.value + const inFavorites = favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + const inRecents = orderedRecents.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + if (inFavorites) return false + if (inRecents) return false + return true + }), sortBy((x) => x.title), ), ), @@ -113,6 +201,13 @@ export function DialogModel() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+f")[0], + title: "Favorite", + onTrigger: (option) => { + local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) + }, + }, ]} ref={setRef} title="Select model" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index 5240603f8..c6d22be7b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -14,10 +14,6 @@ export function DialogThemeList() { let ref: DialogSelectRef const initial = theme.selected - onMount(() => { - theme.set(Object.keys(theme.all())[0]) - }) - onCleanup(() => { if (!confirmed) theme.set(initial) }) @@ -26,6 +22,7 @@ export function DialogThemeList() { { theme.set(opt.value) }} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index fdf5b5b52..7249091e9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -705,25 +705,27 @@ export function Prompt(props: PromptProps) { // trim ' from the beginning and end of the pasted content. just // ' and nothing else const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - console.log(pastedContent, filepath) - try { - const file = Bun.file(filepath) - if (file.type.startsWith("image/")) { - event.preventDefault() - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(console.error) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const file = Bun.file(filepath) + if (file.type.startsWith("image/")) { + event.preventDefault() + const content = await file + .arrayBuffer() + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(console.error) + if (content) { + await pasteImage({ + filename: file.name, + mime: file.type, + content, + }) + return + } } - } - } catch {} + } catch {} + } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ( diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c3b38aab2..1703b365d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] + favorite: { + providerID: string + modelID: string + }[] }>({ ready: false, model: {}, recent: [], + favorite: [], }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + function save() { + Bun.write( + file, + JSON.stringify({ + recent: modelStore.recent, + favorite: modelStore.favorite, + }), + ) + } + file .json() .then((x) => { - setModelStore("recent", x.recent) + if (Array.isArray(x.recent)) setModelStore("recent", x.recent) + if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) }) .catch(() => {}) .finally(() => { @@ -184,6 +200,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ recent() { return modelStore.recent }, + favorite() { + return modelStore.favorite + }, parsed: createMemo(() => { const value = currentModel() const provider = sync.data.provider.find((x) => x.id === value.providerID)! @@ -206,6 +225,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!val) return setModelStore("model", agent.current().name, { ...val }) }, + cycleFavorite(direction: 1 | -1) { + const favorites = modelStore.favorite.filter((item) => isModelValid(item)) + if (!favorites.length) { + toast.show({ + variant: "info", + message: "Add a favorite model to use this shortcut", + duration: 3000, + }) + return + } + const current = currentModel() + let index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) + if (index === -1) { + index = direction === 1 ? 0 : favorites.length - 1 + } else { + index += direction + if (index < 0) index = favorites.length - 1 + if (index >= favorites.length) index = 0 + } + const next = favorites[index] + if (!next) return + setModelStore("model", agent.current().name, { ...next }) + const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 10) uniq.pop() + setModelStore("recent", uniq) + save() + }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { batch(() => { if (!isModelValid(model)) { @@ -219,17 +265,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("model", agent.current().name, model) if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() + if (uniq.length > 10) uniq.pop() setModelStore("recent", uniq) - Bun.write( - file, - JSON.stringify({ - recent: modelStore.recent, - }), - ) + save() } }) }, + toggleFavorite(model: { providerID: string; modelID: string }) { + batch(() => { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return + } + const exists = modelStore.favorite.some( + (x) => x.providerID === model.providerID && x.modelID === model.modelID, + ) + const next = exists + ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) + : [model, ...modelStore.favorite] + setModelStore("favorite", next) + save() + }) + }, } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index fa3b1c633..401a53ab4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,7 +1,8 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { batch, onCleanup } from "solid-js" +import { batch, onCleanup, onMount } from "solid-js" +import { iife } from "@/util/iife" export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", @@ -16,43 +17,49 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ [key in Event["type"]]: Extract }>() - sdk.event.subscribe().then(async (events) => { - let queue: Event[] = [] - let timer: Timer | undefined - let last = 0 - - const flush = () => { - if (queue.length === 0) return - const events = queue - queue = [] - timer = undefined - last = Date.now() - // Batch all event emissions so all store updates result in a single render - batch(() => { - for (const event of events) { - emitter.emit(event.type, event) - } + onMount(async () => { + while (true) { + if (abort.signal.aborted) break + const events = await sdk.event.subscribe({ + signal: abort.signal, }) - } + let queue: Event[] = [] + let timer: Timer | undefined + let last = 0 - for await (const event of events.stream) { - queue.push(event) - const elapsed = Date.now() - last - - if (timer) continue - // If we just flushed recently (within 16ms), batch this with future events - // Otherwise, process immediately to avoid latency - if (elapsed < 16) { - timer = setTimeout(flush, 16) - continue + const flush = () => { + if (queue.length === 0) return + const events = queue + queue = [] + timer = undefined + last = Date.now() + // Batch all event emissions so all store updates result in a single render + batch(() => { + for (const event of events) { + emitter.emit(event.type, event) + } + }) } - flush() - } - // Flush any remaining events - if (timer) clearTimeout(timer) - if (queue.length > 0) { - flush() + for await (const event of events.stream) { + queue.push(event) + const elapsed = Date.now() - last + + if (timer) continue + // If we just flushed recently (within 16ms), batch this with future events + // Otherwise, process immediately to avoid latency + if (elapsed < 16) { + timer = setTimeout(flush, 16) + continue + } + flush() + } + + // Flush any remaining events + if (timer) clearTimeout(timer) + if (queue.length > 0) { + flush() + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index c718f7700..80884c73a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -14,6 +14,7 @@ import type { SessionStatus, ProviderListResponse, ProviderAuthMethod, + VcsInfo, } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -59,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpStatus } formatter: FormatterStatus[] + vcs: VcsInfo | undefined }>({ provider_next: { all: [], @@ -82,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ lsp: [], mcp: {}, formatter: [], + vcs: undefined, }) const sdk = useSDK() @@ -238,6 +241,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) break } + + case "vcs.branch.updated": { + setStore("vcs", "vcs", { branch: event.properties.branch }) + break + } } }) @@ -276,6 +284,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), sdk.client.session.status().then((x) => setStore("session_status", x.data!)), sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})), + sdk.client.vcs.get().then((x) => setStore("vcs", x.data)), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f8526e72b..c0d173421 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,7 @@ const context = createContext<{ conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean + sync: ReturnType }>() function use() { @@ -319,7 +320,9 @@ export function Session() { value: "session.undo", keybind: "messages_undo", category: "Session", - onSelect: (dialog) => { + onSelect: async (dialog) => { + const status = sync.data.session_status[route.sessionID] + if (status?.type !== "idle") await sdk.client.session.abort({ path: { id: route.sessionID } }).catch(() => {}) const revert = session().revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return @@ -730,6 +733,7 @@ export function Session() { conceal, showThinking, showTimestamps, + sync, }} > @@ -1445,6 +1449,34 @@ ToolRegistry.register({ }, }) +ToolRegistry.register({ + name: "codesearch", + container: "inline", + render(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Code Search "{input.query}" ({metadata.results} results) + + ) + }, +}) + +ToolRegistry.register({ + name: "websearch", + container: "inline", + render(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Web Search "{input.query}" ({metadata.numResults} results) + + ) + }, +}) + ToolRegistry.register({ name: "edit", container: "block", @@ -1452,7 +1484,12 @@ ToolRegistry.register({ const ctx = use() const { theme, syntax } = useTheme() - const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked")) + const style = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "stacked" + // Default to "auto" behavior + return ctx.width > 120 ? "split" : "stacked" + }) const diff = createMemo(() => { const diff = props.metadata.diff ?? props.permission["diff"] diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 987bbd0b9..b8d2a5b14 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -50,6 +50,15 @@ export function DialogSelect(props: DialogSelectProps) { filter: "", }) + createEffect(() => { + if (props.current) { + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current)) + if (currentIndex >= 0) { + setStore("selected", currentIndex) + } + } + }) + let input: InputRenderable const filtered = createMemo(() => { @@ -88,7 +97,14 @@ export function DialogSelect(props: DialogSelectProps) { createEffect(() => { store.filter - setStore("selected", 0) + if (store.filter.length > 0) { + setStore("selected", 0) + } else if (props.current) { + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current)) + if (currentIndex >= 0) { + setStore("selected", currentIndex) + } + } scroll.scrollTo(0) }) @@ -237,7 +253,7 @@ export function DialogSelect(props: DialogSelectProps) { )} - + {(item) => ( diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b8ca3b2..383a47566 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -456,6 +456,10 @@ export namespace Config { }) .optional() .describe("Scroll acceleration settings"), + diff_style: z + .enum(["auto", "stacked"]) + .optional() + .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), }) export const Layout = z.enum(["auto", "stretch"]).meta({ @@ -540,6 +544,10 @@ export namespace Config { apiKey: z.string().optional(), baseURL: z.string().optional(), enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), + setCacheKey: z + .boolean() + .optional() + .describe("Enable promptCacheKey for this provider (default false)"), timeout: z .union([ z diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index cdebad4bd..4edbd5ace 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -17,7 +17,6 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER") - export const OPENCODE_EXPERIMENTAL_EXA = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 79a2a408b..44752fd50 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -88,9 +88,7 @@ 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(() => {}) - if (!tsserver) return - const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { + const proc = spawn(BunProc.which(), ["x", "@vtsls/language-server", "--stdio"], { cwd: root, env: { ...process.env, @@ -99,11 +97,6 @@ export namespace LSPServer { }) return { process: proc, - initialization: { - tsserver: { - path: tsserver, - }, - }, } }, } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 5840c9768..56fe4d13e 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -4,11 +4,11 @@ import { Format } from "../format" import { LSP } from "../lsp" import { FileWatcher } from "../file/watcher" import { File } from "../file" -import { Flag } from "../flag/flag" import { Project } from "./project" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" +import { Vcs } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" @@ -21,6 +21,7 @@ export async function InstanceBootstrap() { await LSP.init() FileWatcher.init() File.init() + Vcs.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts new file mode 100644 index 000000000..59b2f99b7 --- /dev/null +++ b/packages/opencode/src/project/vcs.ts @@ -0,0 +1,86 @@ +import { $ } from "bun" +import { watch, type FSWatcher } from "fs" +import path from "path" +import z from "zod" +import { Log } from "@/util/log" +import { Bus } from "@/bus" +import { Instance } from "./instance" + +const log = Log.create({ service: "vcs" }) + +export namespace Vcs { + export const Event = { + BranchUpdated: Bus.event( + "vcs.branch.updated", + z.object({ + branch: z.string().optional(), + }), + ), + } + + async function currentBranch() { + return $`git rev-parse --abbrev-ref HEAD` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => x.trim()) + .catch(() => undefined) + } + + const state = Instance.state( + async () => { + if (Instance.project.vcs !== "git") { + return { branch: async () => undefined, watcher: undefined } + } + let current = await currentBranch() + log.info("initialized", { branch: current }) + + const gitDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => x.trim()) + .catch(() => undefined) + if (!gitDir) { + log.warn("failed to resolve git directory") + return { branch: async () => current, watcher: undefined } + } + + const gitHead = path.join(gitDir, "HEAD") + let watcher: FSWatcher | undefined + // we should probably centralize file watching (see watcher.ts) + // but parcel still marked experimental rn + try { + watcher = watch(gitHead, async () => { + const next = await currentBranch() + if (next !== current) { + log.info("branch changed", { from: current, to: next }) + current = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } + }) + log.info("watching", { path: gitHead }) + } catch (e) { + log.warn("failed to watch git HEAD", { error: e }) + } + + return { + branch: async () => current, + watcher, + } + }, + async (state) => { + state.watcher?.close() + }, + ) + + export async function init() { + return state() + } + + export async function branch() { + return await state().then((s) => s.branch()) + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1cf0312ea..a4c406c0f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -130,6 +130,11 @@ export namespace Provider { credentialProvider: fromNodeProviderChain(), }, async getModel(sdk: any, modelID: string, _options?: Record) { + // Skip region prefixing if model already has global prefix + if (modelID.startsWith("global.")) { + return sdk.languageModel(modelID) + } + let regionPrefix = region.split("-")[0] switch (regionPrefix) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6cf03fc06..1cf53e5c6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -128,7 +128,13 @@ export namespace ProviderTransform { return undefined } - export function options(providerID: string, modelID: string, npm: string, sessionID: string): Record { + export function options( + providerID: string, + modelID: string, + npm: string, + sessionID: string, + providerOptions?: Record, + ): Record { const result: Record = {} // switch to providerID later, for now use this @@ -138,7 +144,7 @@ export namespace ProviderTransform { } } - if (providerID === "openai") { + if (providerID === "openai" || providerOptions?.setCacheKey) { result["promptCacheKey"] = sessionID } @@ -248,7 +254,7 @@ export namespace ProviderTransform { return standardLimit } - export function schema(_providerID: string, _modelID: string, schema: JSONSchema.BaseSchema) { + export function schema(providerID: string, modelID: string, schema: JSONSchema.BaseSchema) { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -265,11 +271,40 @@ export namespace ProviderTransform { } } } - - if (providerID === "google") { - } */ + // Convert integer enums to string enums for Google/Gemini + if (providerID === "google" || modelID.includes("gemini")) { + const convertIntEnumsToStrings = (obj: any): any => { + if (obj === null || typeof obj !== "object") { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(convertIntEnumsToStrings) + } + + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + if (key === "enum" && Array.isArray(value)) { + // Convert all enum values to strings + result[key] = value.map((v) => String(v)) + // If we have integer type with enum, change type to string + if (result.type === "integer" || result.type === "number") { + result.type = "string" + } + } else if (typeof value === "object" && value !== null) { + result[key] = convertIntEnumsToStrings(value) + } else { + result[key] = value + } + } + return result + } + + schema = convertIntEnumsToStrings(schema) + } + return schema } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 65c635ee1..1c8d2a003 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -20,6 +20,7 @@ import { MessageV2 } from "../session/message-v2" import { TuiRoute } from "./tui" import { Permission } from "../permission" import { Instance } from "../project/instance" +import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" import { Auth } from "../auth" import { Command } from "../command" @@ -365,6 +366,47 @@ export namespace Server { }) }, ) + .get( + "/vcs", + describeRoute({ + description: "Get VCS info for the current instance", + operationId: "vcs.get", + responses: { + 200: { + description: "VCS info", + content: { + "application/json": { + schema: resolver( + z + .object({ + worktree: z.string(), + directory: z.string(), + projectID: z.string(), + vcs: z + .object({ + branch: z.string(), + }) + .optional(), + }) + .meta({ + ref: "VcsInfo", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const branch = await Vcs.branch() + return c.json({ + worktree: Instance.worktree, + directory: Instance.directory, + projectID: Instance.project.id, + vcs: Instance.project.vcs ? { branch } : undefined, + }) + }, + ) .get( "/session", describeRoute({ diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e985372a1..41571bcef 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -224,6 +224,7 @@ export namespace SessionCompaction { }) } if (processor.message.error) return "stop" + Bus.publish(Event.Compacted, { sessionID: input.sessionID }) return "continue" } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6d1125c66..5bd833c0f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -333,13 +333,13 @@ export namespace SessionProcessor { error: e, }) const error = MessageV2.fromError(e, { providerID: input.providerID }) - if (error?.name === "APIError" && error.data.isRetryable) { + if ((error?.name === "APIError" && error.data.isRetryable) || error.data.message.includes("Overloaded")) { attempt++ - const delay = SessionRetry.delay(error, attempt) + const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) SessionStatus.set(input.sessionID, { type: "retry", attempt, - message: error.data.message, + message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message, next: Date.now() + delay, }) await SessionRetry.sleep(delay, input.abort).catch(() => {}) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e6c64f96b..a9c85caf1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -477,13 +477,14 @@ export namespace SessionPrompt { tools: lastUser.tools, processor, }) + const provider = await Provider.getProvider(model.providerID) const params = await Plugin.trigger( "chat.params", { sessionID: sessionID, agent: lastUser.agent, model: model.info, - provider: await Provider.getProvider(model.providerID), + provider, message: lastUser, }, { @@ -493,7 +494,9 @@ export namespace SessionPrompt { topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), options: pipe( {}, - mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID)), + mergeDeep( + ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID, provider?.options), + ), mergeDeep(model.info.options), mergeDeep(agent.options), ), @@ -591,6 +594,21 @@ export namespace SessionPrompt { // @ts-expect-error args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) } + // Transform tool schemas for provider compatibility + if (args.params.tools && Array.isArray(args.params.tools)) { + args.params.tools = args.params.tools.map((tool: any) => { + // Tools at middleware level have inputSchema, not parameters + if (tool.inputSchema && typeof tool.inputSchema === "object") { + // Transform the inputSchema for provider compatibility + return { + ...tool, + inputSchema: ProviderTransform.schema(model.providerID, model.modelID, tool.inputSchema), + } + } + // If no inputSchema, return tool unchanged + return tool + }) + } return args.params }, }, @@ -730,6 +748,8 @@ export namespace SessionPrompt { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute if (!execute) continue + + // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { await Plugin.trigger( "tool.execute.before", @@ -757,17 +777,17 @@ export namespace SessionPrompt { const textParts: string[] = [] const attachments: MessageV2.FilePart[] = [] - for (const item of result.content) { - if (item.type === "text") { - textParts.push(item.text) - } else if (item.type === "image") { + for (const contentItem of result.content) { + if (contentItem.type === "text") { + textParts.push(contentItem.text) + } else if (contentItem.type === "image") { attachments.push({ id: Identifier.ascending("part"), sessionID: input.sessionID, messageID: input.processor.message.id, type: "file", - mime: item.mimeType, - url: `data:${item.mimeType};base64,${item.data}`, + mime: contentItem.mimeType, + url: `data:${contentItem.mimeType};base64,${contentItem.data}`, }) } // Add support for other types if needed @@ -1412,9 +1432,18 @@ export namespace SessionPrompt { if (!isFirst) return const small = (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + const provider = await Provider.getProvider(small.providerID) const options = pipe( {}, - mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)), + mergeDeep( + ProviderTransform.options( + small.providerID, + small.modelID, + small.npm ?? "", + input.session.id, + provider?.options, + ), + ), mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })), mergeDeep(small.info.options), ) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 75472b568..4ad81ea08 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -19,32 +19,34 @@ export namespace SessionRetry { }) } - export function delay(error: MessageV2.APIError, attempt: number) { - const headers = error.data.responseHeaders - if (headers) { - const retryAfterMs = headers["retry-after-ms"] - if (retryAfterMs) { - const parsedMs = Number.parseFloat(retryAfterMs) - if (!Number.isNaN(parsedMs)) { - return parsedMs + export function delay(attempt: number, error?: MessageV2.APIError) { + if (error) { + const headers = error.data.responseHeaders + if (headers) { + const retryAfterMs = headers["retry-after-ms"] + if (retryAfterMs) { + const parsedMs = Number.parseFloat(retryAfterMs) + if (!Number.isNaN(parsedMs)) { + return parsedMs + } } - } - const retryAfter = headers["retry-after"] - if (retryAfter) { - const parsedSeconds = Number.parseFloat(retryAfter) - if (!Number.isNaN(parsedSeconds)) { - // convert seconds to milliseconds - return Math.ceil(parsedSeconds * 1000) + const retryAfter = headers["retry-after"] + if (retryAfter) { + const parsedSeconds = Number.parseFloat(retryAfter) + if (!Number.isNaN(parsedSeconds)) { + // convert seconds to milliseconds + return Math.ceil(parsedSeconds * 1000) + } + // Try parsing as HTTP date format + const parsed = Date.parse(retryAfter) - Date.now() + if (!Number.isNaN(parsed) && parsed > 0) { + return Math.ceil(parsed) + } } - // Try parsing as HTTP date format - const parsed = Date.parse(retryAfter) - Date.now() - if (!Number.isNaN(parsed) && parsed > 0) { - return Math.ceil(parsed) - } - } - return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) + return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) + } } return Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a741e12be..adeab43f6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -97,8 +97,9 @@ export namespace ToolRegistry { WebFetchTool, TodoWriteTool, TodoReadTool, + WebSearchTool, + CodeSearchTool, ...(config.experimental?.batch_tool === true ? [BatchTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []), ...custom, ] } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index dc7470f0a..b685eae95 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -13,49 +13,49 @@ function apiError(headers?: Record): MessageV2.APIError { describe("session.retry.delay", () => { test("caps delay at 30 seconds when headers missing", () => { const error = apiError() - const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(error, index + 1)) + const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error)) expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000]) }) test("prefers retry-after-ms when shorter than exponential", () => { const error = apiError({ "retry-after-ms": "1500" }) - expect(SessionRetry.delay(error, 4)).toBe(1500) + expect(SessionRetry.delay(4, error)).toBe(1500) }) test("uses retry-after seconds when reasonable", () => { const error = apiError({ "retry-after": "30" }) - expect(SessionRetry.delay(error, 3)).toBe(30000) + expect(SessionRetry.delay(3, error)).toBe(30000) }) test("accepts http-date retry-after values", () => { const date = new Date(Date.now() + 20000).toUTCString() const error = apiError({ "retry-after": date }) - const d = SessionRetry.delay(error, 1) + const d = SessionRetry.delay(1, error) expect(d).toBeGreaterThanOrEqual(19000) expect(d).toBeLessThanOrEqual(20000) }) test("ignores invalid retry hints", () => { const error = apiError({ "retry-after": "not-a-number" }) - expect(SessionRetry.delay(error, 1)).toBe(2000) + expect(SessionRetry.delay(1, error)).toBe(2000) }) test("ignores malformed date retry hints", () => { const error = apiError({ "retry-after": "Invalid Date String" }) - expect(SessionRetry.delay(error, 1)).toBe(2000) + expect(SessionRetry.delay(1, error)).toBe(2000) }) test("ignores past date retry hints", () => { const pastDate = new Date(Date.now() - 5000).toUTCString() const error = apiError({ "retry-after": pastDate }) - expect(SessionRetry.delay(error, 1)).toBe(2000) + expect(SessionRetry.delay(1, error)).toBe(2000) }) test("uses retry-after values even when exceeding 10 minutes with headers", () => { const error = apiError({ "retry-after": "50" }) - expect(SessionRetry.delay(error, 1)).toBe(50000) + expect(SessionRetry.delay(1, error)).toBe(50000) const longError = apiError({ "retry-after-ms": "700000" }) - expect(SessionRetry.delay(longError, 1)).toBe(700000) + expect(SessionRetry.delay(1, longError)).toBe(700000) }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8b55b0689..c08186b20 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.110", + "version": "1.0.114", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index d65d93a4e..1d8ae6371 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.110", + "version": "1.0.114", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index afc9696f1..0dc470566 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -23,6 +23,8 @@ import type { InstanceDisposeResponses, PathGetData, PathGetResponses, + VcsGetData, + VcsGetResponses, SessionListData, SessionListResponses, SessionCreateData, @@ -311,6 +313,18 @@ class Path extends _HeyApiClient { } } +class Vcs extends _HeyApiClient { + /** + * Get VCS info for the current instance + */ + public get(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/vcs", + ...options, + }) + } +} + class Session extends _HeyApiClient { /** * List all sessions @@ -995,6 +1009,7 @@ export class OpencodeClient extends _HeyApiClient { tool = new Tool({ client: this._client }) instance = new Instance({ client: this._client }) path = new Path({ client: this._client }) + vcs = new Vcs({ client: this._client }) session = new Session({ client: this._client }) command = new Command({ client: this._client }) provider = new Provider({ client: this._client }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 2de8ca2f1..08335dd39 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -589,6 +589,13 @@ export type EventSessionError = { } } +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -670,6 +677,7 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError + | EventVcsBranchUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -995,6 +1003,10 @@ export type Config = { */ enabled: boolean } + /** + * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column + */ + diff_style?: "auto" | "stacked" } /** * Command configuration, see https://opencode.ai/docs/commands @@ -1123,11 +1135,15 @@ export type Config = { * GitHub Enterprise URL for copilot authentication */ enterpriseUrl?: string + /** + * Enable promptCacheKey for this provider (default false) + */ + setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - [key: string]: unknown | string | (number | false) | undefined + [key: string]: unknown | string | boolean | (number | false) | undefined } } } @@ -1247,6 +1263,15 @@ export type Path = { directory: string } +export type VcsInfo = { + worktree: string + directory: string + projectID: string + vcs?: { + branch: string + } +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -1683,6 +1708,24 @@ export type PathGetResponses = { export type PathGetResponse = PathGetResponses[keyof PathGetResponses] +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + export type SessionListData = { body?: never path?: never diff --git a/packages/sdk/python/sst.pyi b/packages/sdk/python/sst.pyi index 1c423e9ac..984fecde5 100644 --- a/packages/sdk/python/sst.pyi +++ b/packages/sdk/python/sst.pyi @@ -55,6 +55,9 @@ class Resource: class EMAILOCTOPUS_API_KEY: type: str value: str + class Enterprise: + type: str + url: str class EnterpriseStorage: name: str type: str diff --git a/packages/slack/package.json b/packages/slack/package.json index 0b9d95c85..8f6af596f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.110", + "version": "1.0.114", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 7950e47f3..9363c50b4 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.110", + "version": "1.0.114", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/ui/package.json b/packages/ui/package.json index e326c85a9..eb126a6fa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,9 +1,10 @@ { "name": "@opencode-ai/ui", - "version": "1.0.110", + "version": "1.0.114", "type": "module", "exports": { "./*": "./src/components/*.tsx", + "./pierre": "./src/components/pierre.ts", "./hooks": "./src/hooks/index.ts", "./context": "./src/context/index.ts", "./context/*": "./src/context/*.tsx", diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css index 8cfac06b0..5724307cd 100644 --- a/packages/ui/src/components/accordion.css +++ b/packages/ui/src/components/accordion.css @@ -74,18 +74,6 @@ border-bottom-right-radius: var(--radius-md); } - [data-slot="accordion-item"]:has(+ &) { - &[data-closed] { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - [data-slot="accordion-trigger"] { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - } - } - margin-bottom: 8px; - } - & + [data-slot="accordion-item"] { margin-top: 8px; @@ -96,6 +84,20 @@ } } + &:has(+ [data-slot="accordion-item"][data-expanded]) { + margin-bottom: 8px; + + &[data-closed] { + border-bottom-left-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + + [data-slot="accordion-trigger"] { + border-bottom-left-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + } + } + } + &[data-closed] + &[data-closed] { [data-slot="accordion-trigger"] { border-top: none; diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index a301e34c7..788baf549 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,5 +1,6 @@ import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs" import { ComponentProps, createEffect, splitProps } from "solid-js" +import { createDefaultOptions, styleVariables } from "./pierre" export type CodeProps = FileOptions & { file: FileContents @@ -14,12 +15,7 @@ export function Code(props: CodeProps) { createEffect(() => { const instance = new File({ - theme: "OpenCode", - overflow: "wrap", // or 'scroll' - themeType: "system", // 'system', 'light', or 'dark' - disableFileHeader: true, - disableLineNumbers: false, // optional - // lang: 'typescript', // optional - auto-detected from filename if not provided + ...createDefaultOptions("unified"), ...others, }) @@ -34,16 +30,7 @@ export function Code(props: CodeProps) { return (
= FileDiffOptions & { preloadedDiff?: PreloadMultiFileDiffResult @@ -15,6 +16,8 @@ export type DiffProps = FileDiffOptions & { // interface ThreadMetadata { // threadId: string // } +// +// export function Diff(props: DiffProps) { let container!: HTMLDivElement @@ -24,27 +27,12 @@ export function Diff(props: DiffProps) { let fileDiffInstance: FileDiff | undefined const cleanupFunctions: Array<() => void> = [] - const defaultOptions: FileDiffOptions = { - theme: "OpenCode", - themeType: "system", - disableLineNumbers: false, - overflow: "wrap", - diffStyle: "unified", - diffIndicators: "bars", - disableBackground: false, - expansionLineCount: 20, - lineDiffType: props.diffStyle === "split" ? "word-alt" : "none", - maxLineDiffLength: 1000, - maxLineLengthForHighlighting: 1000, - disableFileHeader: true, - } - createEffect(() => { if (props.preloadedDiff) return container.innerHTML = "" if (!fileDiffInstance) { fileDiffInstance = new FileDiff({ - ...defaultOptions, + ...createDefaultOptions(props.diffStyle), ...others, ...(props.preloadedDiff ?? {}), }) @@ -60,22 +48,19 @@ export function Diff(props: DiffProps) { onMount(() => { if (isServer) return fileDiffInstance = new FileDiff({ - ...defaultOptions, - // You can optionally pass a render function for rendering out line - // annotations. Just return the dom node to render - // renderAnnotation(annotation: DiffLineAnnotation): HTMLElement { - // // Despite the diff itself being rendered in the shadow dom, - // // annotations are inserted via the web components 'slots' api and you - // // can use all your normal normal css and styling for them - // const element = document.createElement("div") - // element.innerText = annotation.metadata.threadId - // return element - // }, + ...createDefaultOptions(props.diffStyle), ...others, ...(props.preloadedDiff ?? {}), }) // @ts-expect-error - fileContainer is private but needed for SSR hydration fileDiffInstance.fileContainer = fileDiffRef + fileDiffInstance.hydrate({ + oldFile: local.before, + newFile: local.after, + lineAnnotations: local.annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + }) // Hydrate annotation slots with interactive SolidJS components // if (props.annotations.length > 0 && props.renderAnnotation != null) { @@ -108,38 +93,11 @@ export function Diff(props: DiffProps) { }) return ( -
+
- {/* Only render on server - client hydrates the existing content */} - {isServer && props.preloadedDiff && ( - <> - {/* Declarative Shadow DOM - browsers parse this and create a shadow root */} - - {/* Render static annotation slots on server. - Client will clear these and mount interactive components. */} - {/* */} - {/* {(annotation) => { */} - {/* const slotName = `annotation-${annotation.side}-${annotation.lineNumber}` */} - {/* return
{props.renderAnnotation?.(annotation)}
*/} - {/* }} */} - {/*
*/} - - )} + + {(preloadedDiff) =>