Merge branch 'sst:dev' into feat/add-venice-provider

This commit is contained in:
george larson 2025-11-26 07:22:08 +00:00 committed by GitHub
commit afdc2d4486
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 2555 additions and 762 deletions

View file

@ -3,6 +3,8 @@ name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:

View file

@ -4,7 +4,7 @@ on:
push:
branches:
- dev
- fix-snapshot-2
- test-bedrock
- v0
concurrency: ${{ github.workflow }}-${{ github.ref }}

View file

@ -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",

View file

@ -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"}}}'
```

View file

@ -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<ReturnType<typeof getUserPrompt>>["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<true>()
await client.app.log<true>({
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`")
})()

81
install
View file

@ -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}"

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.110",
"version": "1.0.114",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View file

@ -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<ReturnType<typeof ZenData.list>>
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 &&

View file

@ -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 {

View file

@ -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}` } }),
)
},
}
}

View file

@ -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`)
);

View file

@ -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": {}
}
}

View file

@ -267,6 +267,13 @@
"when": 1761928273807,
"tag": "0037_messy_jackal",
"breakpoints": true
},
{
"idx": 38,
"version": "5",
"when": 1764110043942,
"tag": "0038_famous_magik",
"breakpoints": true
}
]
}

View file

@ -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": {

View file

@ -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(

View file

@ -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] })],
)

View file

@ -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",

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.110",
"version": "1.0.114",
"description": "",
"type": "module",
"scripts": {

View file

@ -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: {
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
forceMount={false}
// open={local.file.node(node.path)?.expanded}

View file

@ -1,7 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { DateTime } from "luxon"
@ -16,6 +15,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
interface PromptInputProps {
class?: string

View file

@ -1,4 +1,3 @@
import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@opencode-ai/util/binary"
@ -34,29 +33,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all(Object.values(load).map((p) => 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
},
}
},
})

View file

@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
{iife(() => {
const sync = useSync()
return (
<DataProvider data={sync.data}>
<DataProvider data={sync.data} directory={directory()}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)

View file

@ -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()

View file

@ -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()

View file

@ -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() {
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
actions={
<Tooltip value="Open in tab">
@ -427,10 +432,18 @@ export default function Page() {
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<SessionReview diffs={session.diffs()} split class="pb-40" />
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
split
/>
</div>
</Tabs.Content>
</Show>

View file

@ -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<typeof KobalteCollapsible> {}
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
export interface CollapsibleContentProps extends ComponentProps<typeof KobalteCollapsible.Content> {}
function CollapsibleRoot(props: CollapsibleProps) {
return <KobalteCollapsible forceMount {...props} />
}
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
const [local, others] = splitProps(props, ["class"])
return (
<KobalteCollapsible.Trigger
classList={{
"w-full group/collapsible": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
function CollapsibleContent(props: ParentProps<CollapsibleContentProps>) {
const [local, others] = splitProps(props, ["class", "children"])
return (
<KobalteCollapsible.Content
classList={{
"h-0 overflow-hidden transition-all duration-100 ease-out": true,
"data-expanded:h-fit": true,
[local.class]: !!local.class,
}}
{...others}
>
{local.children}
</KobalteCollapsible.Content>
)
}
function CollapsibleArrow(props: Partial<IconProps>) {
const [local, others] = splitProps(props, ["class", "name"])
return (
<Icon
name={local.name ?? "chevron-right"}
classList={{
"flex-none text-text-muted transition-transform duration-100": true,
"group-data-[expanded]/collapsible:rotate-90": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
export const Collapsible = Object.assign(CollapsibleRoot, {
Trigger: CollapsibleTrigger,
Content: CollapsibleContent,
Arrow: CollapsibleArrow,
})

View file

@ -1,6 +0,0 @@
export {
Collapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
} from "./collapsible"

View file

@ -1,3 +1,2 @@
export * from "./path"
export * from "./dom"
export * from "./encode"

View file

@ -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]
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.110",
"version": "1.0.114",
"private": true,
"type": "module",
"scripts": {

View file

@ -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<any>({
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 () {
}}
>
<Show when={data()}>
{(data) => (
<DataProvider data={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 (
<DataProvider data={data()} directory={info().directory}>
{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 = () => (
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
const title = () => (
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
{title()}
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
}}
/>
)}
</For>
</div>
<div class="flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="hidden md:flex w-full flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": !wide(),
"px-6 max-w-2xl": wide(),
}}
>
{title()}
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<>
<div class="md:hidden absolute right-full">
<MessageNav
class="mt-2 mr-3"
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size="compact"
/>
</div>
<div
classList={{
"hidden md:block": true,
"absolute right-[90%]": !wide(),
"absolute right-full": wide(),
}}
>
<MessageNav
classList={{
"mt-2.5 mr-3": !wide(),
"mt-0.5 mr-8": wide(),
}}
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size={wide() ? "normal" : "compact"}
/>
</div>
</>
</Show>
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
{title()}
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Show>
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
}}
/>
)}
</For>
</div>
<div class="flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs class="md:hidden">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
5 Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content forceMount value="review" class="!overflow-hidden hidden data-[selected]:block">
<div class="relative px-4 pt-8 h-full overflow-y-auto no-scrollbar">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div class="md:hidden !overflow-hidden">{turns()}</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
)}
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="hidden md:flex w-full flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": !wide(),
"px-6 max-w-2xl": wide(),
}}
>
{title()}
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<>
<div class="md:hidden absolute right-full">
<MessageNav
class="mt-2 mr-3"
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size="compact"
/>
</div>
<div
classList={{
"hidden md:block": true,
"absolute right-[90%]": !wide(),
"absolute right-full": wide(),
}}
>
<MessageNav
classList={{
"mt-2.5 mr-3": !wide(),
"mt-0.5 mr-8": wide(),
}}
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size={wide() ? "normal" : "compact"}
/>
</div>
</>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs class="md:hidden">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
5 Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div class="md:hidden !overflow-hidden">{turns()}</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
)
}}
</Show>
</ErrorBoundary>
)

View file

@ -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"]

View file

@ -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",

View file

@ -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,

View file

@ -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}`
}
}

View file

@ -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)))

View file

@ -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() {
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
<text fg={theme.textMuted}>
{process.cwd().replace(Global.Path.home, "~")}
{sync.data.vcs?.vcs?.branch ? `:${sync.data.vcs.vcs.branch}` : ""}
</text>
</box>
</box>
<Show when={false}>

View file

@ -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(() => <DialogProvider />)
},
},
{
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"

View file

@ -14,10 +14,6 @@ export function DialogThemeList() {
let ref: DialogSelectRef<string>
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() {
<DialogSelect
title="Themes"
options={options}
current={initial}
onMove={(opt) => {
theme.set(opt.value)
}}

View file

@ -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 (

View file

@ -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()
})
},
}
})

View file

@ -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<Event, { type: key }>
}>()
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()
}
}
})

View file

@ -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")
})

View file

@ -80,6 +80,7 @@ const context = createContext<{
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
sync: ReturnType<typeof useSync>
}>()
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,
}}
>
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
@ -1445,6 +1449,34 @@ ToolRegistry.register<typeof WebFetchTool>({
},
})
ToolRegistry.register({
name: "codesearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register({
name: "websearch",
container: "inline",
render(props: ToolProps<any>) {
const input = props.input as any
const metadata = props.metadata as any
return (
<ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register<typeof EditTool>({
name: "edit",
container: "block",
@ -1452,7 +1484,12 @@ ToolRegistry.register<typeof EditTool>({
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"]

View file

@ -50,6 +50,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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<T>(props: DialogSelectProps<T>) {
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<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={2}>
<For each={props.keybind ?? []}>
{(item) => (
<text>

View file

@ -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

View file

@ -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()

View file

@ -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,
},
},
}
},
}

View file

@ -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) {

View file

@ -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())
}
}

View file

@ -130,6 +130,11 @@ export namespace Provider {
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
// Skip region prefixing if model already has global prefix
if (modelID.startsWith("global.")) {
return sdk.languageModel(modelID)
}
let regionPrefix = region.split("-")[0]
switch (regionPrefix) {

View file

@ -128,7 +128,13 @@ export namespace ProviderTransform {
return undefined
}
export function options(providerID: string, modelID: string, npm: string, sessionID: string): Record<string, any> {
export function options(
providerID: string,
modelID: string,
npm: string,
sessionID: string,
providerOptions?: Record<string, any>,
): Record<string, any> {
const result: Record<string, any> = {}
// 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
}

View file

@ -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({

View file

@ -224,6 +224,7 @@ export namespace SessionCompaction {
})
}
if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
return "continue"
}

View file

@ -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(() => {})

View file

@ -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),
)

View file

@ -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)

View file

@ -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,
]
}

View file

@ -13,49 +13,49 @@ function apiError(headers?: Record<string, string>): 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)
})
})

View file

@ -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",

View file

@ -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",

View file

@ -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<ThrowOnError extends boolean = false>(options?: Options<VcsGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<VcsGetResponses, unknown, ThrowOnError>({
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 })

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/tauri",
"private": true,
"version": "1.0.110",
"version": "1.0.114",
"type": "module",
"scripts": {
"dev": "vite",

View file

@ -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",

View file

@ -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;

View file

@ -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<T = {}> = FileOptions<T> & {
file: FileContents
@ -14,12 +15,7 @@ export function Code<T>(props: CodeProps<T>) {
createEffect(() => {
const instance = new File<T>({
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<T>("unified"),
...others,
})
@ -34,16 +30,7 @@ export function Code<T>(props: CodeProps<T>) {
return (
<div
data-component="code"
style={{
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
"--pjs-min-number-column-width": "4ch",
}}
style={styleVariables}
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,

View file

@ -1,7 +1,8 @@
import { type FileContents, FileDiff, type DiffLineAnnotation, FileDiffOptions } from "@pierre/precision-diffs"
import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
import { ComponentProps, createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "./pierre"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
preloadedDiff?: PreloadMultiFileDiffResult<T>
@ -15,6 +16,8 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
// interface ThreadMetadata {
// threadId: string
// }
//
//
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
@ -24,27 +27,12 @@ export function Diff<T>(props: DiffProps<T>) {
let fileDiffInstance: FileDiff<T> | undefined
const cleanupFunctions: Array<() => void> = []
const defaultOptions: FileDiffOptions<T> = {
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<T>({
...defaultOptions,
...createDefaultOptions(props.diffStyle),
...others,
...(props.preloadedDiff ?? {}),
})
@ -60,22 +48,19 @@ export function Diff<T>(props: DiffProps<T>) {
onMount(() => {
if (isServer) return
fileDiffInstance = new FileDiff<T>({
...defaultOptions,
// You can optionally pass a render function for rendering out line
// annotations. Just return the dom node to render
// renderAnnotation(annotation: DiffLineAnnotation<T>): 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<T>(props: DiffProps<T>) {
})
return (
<div
data-component="diff"
style={{
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
"--pjs-min-number-column-width": "4ch",
}}
ref={container}
>
<div data-component="diff" style={styleVariables} ref={container}>
<file-diff ref={fileDiffRef} id="ssr-diff">
{/* Only render on server - client hydrates the existing content */}
{isServer && props.preloadedDiff && (
<>
{/* Declarative Shadow DOM - browsers parse this and create a shadow root */}
<template shadowrootmode="open">
<div innerHTML={props.preloadedDiff!.prerenderedHTML} />
</template>
{/* Render static annotation slots on server.
Client will clear these and mount interactive components. */}
{/* <For each={props.annotations}> */}
{/* {(annotation) => { */}
{/* const slotName = `annotation-${annotation.side}-${annotation.lineNumber}` */}
{/* return <div slot={slotName}>{props.renderAnnotation?.(annotation)}</div> */}
{/* }} */}
{/* </For> */}
</>
)}
<Show when={isServer && props.preloadedDiff}>
{(preloadedDiff) => <template shadowrootmode="open" innerHTML={preloadedDiff().prerenderedHTML} />}
</Show>
</file-diff>
</div>
)

View file

@ -47,4 +47,24 @@
display: none;
}
}
:not(pre) > code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 0.9em;
/* background-color: var(--surface-base-strong); */
/* padding: 0.15em 0.35em; */
/* border-radius: var(--radius-sm); */
padding: 2px 2px;
margin: 0 1.5px;
border-radius: 2px;
background: var(--surface-base);
box-shadow: 0 0 0 0.5px var(--border-weak-base);
/* &::before, */
/* &::after { */
/* content: "\`"; */
/* } */
}
}

View file

@ -63,6 +63,17 @@
[data-component="tool-output"] {
white-space: pre;
padding: 8px 12px;
height: fit-content;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
pre {
margin: 0;
padding: 0;
}
}
[data-component="edit-trigger"],

View file

@ -16,35 +16,26 @@ import { Checkbox } from "./checkbox"
import { Diff } from "./diff"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { sanitize, sanitizePart } from "@opencode-ai/util/sanitize"
export interface MessageProps {
message: MessageType
parts: PartType[]
sanitize?: RegExp
}
export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
sanitize?: RegExp
}
export type PartComponent = Component<MessagePartProps>
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
function getFilename(path: string) {
if (!path) return ""
const trimmed = path.replace(/[\/]+$/, "")
const parts = trimmed.split("/")
return parts[parts.length - 1] ?? ""
}
function getDirectory(path: string) {
const parts = path.split("/")
const dir = parts.slice(0, parts.length - 1).join("/")
return dir ? dir + "/" : ""
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@ -57,21 +48,27 @@ export function Message(props: MessageProps) {
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
sanitize={props.sanitize}
/>
)}
</Match>
</Switch>
)
}
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
const filteredParts = createMemo(() => {
return props.parts?.filter((x) => {
if (x.type === "reasoning") return false
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
})
})
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
return (
<For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
)
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
@ -88,7 +85,13 @@ export function Part(props: MessagePartProps) {
const component = createMemo(() => PART_MAPPING[props.part.type])
return (
<Show when={component()}>
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
<Dynamic
component={component()}
part={props.part}
message={props.message}
hideDetails={props.hideDetails}
sanitize={props.sanitize}
/>
</Show>
)
}
@ -99,6 +102,7 @@ export interface ToolProps {
tool: string
output?: string
hideDetails?: boolean
sanitize?: RegExp
}
export type ToolComponent = Component<ToolProps>
@ -166,6 +170,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
metadata={metadata}
output={part.state.status === "completed" ? part.state.output : undefined}
hideDetails={props.hideDetails}
sanitize={props.sanitize}
/>
</Match>
</Switch>
@ -177,10 +182,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const part = props.part as TextPart
const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part))
return (
<Show when={part.text.trim()}>
<div data-component="text-part">
<Markdown text={part.text.trim()} />
<Markdown text={sanitized().text.trim()} />
</div>
</Show>
)
@ -205,7 +211,7 @@ ToolRegistry.register({
icon="glasses"
trigger={{
title: "Read",
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
subtitle: props.input.filePath ? getFilename(sanitize(props.input.filePath, props.sanitize)) : "",
}}
/>
)
@ -216,9 +222,12 @@ ToolRegistry.register({
name: "list",
render(props) {
return (
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
<BasicTool
icon="bullet-list"
trigger={{ title: "List", subtitle: getDirectory(sanitize(props.input.path, props.sanitize) || "/") }}
>
<Show when={false && props.output}>
<div data-component="tool-output">{props.output}</div>
<div data-component="tool-output">{sanitize(props.output, props.sanitize)}</div>
</Show>
</BasicTool>
)
@ -321,12 +330,14 @@ ToolRegistry.register({
icon="console"
trigger={{
title: "Shell",
subtitle: "Ran " + props.input.command,
subtitle: props.input.description,
}}
>
<Show when={false && props.output}>
<div data-component="tool-output">{props.output}</div>
</Show>
<div data-component="tool-output">
<Markdown
text={`\`\`\`command\n$ ${sanitize(props.input.command, props.sanitize)}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
/>
</div>
</BasicTool>
)
},
@ -344,9 +355,13 @@ ToolRegistry.register({
<div data-slot="message-part-title">Edit</div>
<div data-slot="message-part-path">
<Show when={props.input.filePath?.includes("/")}>
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
<span data-slot="message-part-directory">
{getDirectory(sanitize(props.input.filePath!, props.sanitize))}
</span>
</Show>
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
<span data-slot="message-part-filename">
{getFilename(sanitize(props.input.filePath ?? "", props.sanitize))}
</span>
</div>
</div>
<div data-slot="message-part-actions">
@ -361,11 +376,11 @@ ToolRegistry.register({
<div data-component="edit-content">
<Diff
before={{
name: getFilename(props.metadata.filediff.path),
name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
contents: props.metadata.filediff.before,
}}
after={{
name: getFilename(props.metadata.filediff.path),
name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
contents: props.metadata.filediff.after,
}}
/>

View file

@ -6,6 +6,7 @@ import type { AssistantMessage as AssistantMessageType, ToolPart } from "@openco
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const data = useData()
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.part[m.id]))
const done = createMemo(() => props.done ?? false)
const currentTask = createMemo(
@ -152,7 +153,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
)
return (
<div data-slot="message-progress-item">
<Part message={message()!} part={part} />
<Part message={message()!} part={part} sanitize={sanitizer()} />
</div>
)
}}

View file

@ -0,0 +1,68 @@
import { FileDiffOptions } from "@pierre/precision-diffs"
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {
theme: "OpenCode",
themeType: "system",
disableLineNumbers: false,
overflow: "wrap",
diffStyle: style,
diffIndicators: "bars",
disableBackground: false,
expansionLineCount: 20,
lineDiffType: style === "split" ? "word-alt" : "none",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
unsafeCSS: `
[data-pjs-header],
[data-pjs] {
[data-separator-wrapper] {
margin: 0 !important;
border-radius: 0 !important;
}
[data-expand-button] {
width: 6.5ch !important;
height: 24px !important;
justify-content: end !important;
padding-left: 3ch !important;
padding-inline: 1ch !important;
}
[data-separator-multi-button] {
grid-template-rows: 10px 10px !important;
[data-expand-button] {
height: 12px !important;
}
}
[data-separator-content] {
height: 24px !important;
}
}`,
// hunkSeparators(hunkData: HunkData) {
// const fragment = document.createDocumentFragment()
// const numCol = document.createElement("div")
// numCol.innerHTML = `<svg data-slot="diff-hunk-separator-line-number-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.97978 14.0204L8.62623 13.6668L9.33334 12.9597L9.68689 13.3133L9.33333 13.6668L8.97978 14.0204ZM12 16.3335L12.3535 16.6871L12 17.0406L11.6464 16.687L12 16.3335ZM14.3131 13.3133L14.6667 12.9597L15.3738 13.6668L15.0202 14.0204L14.6667 13.6668L14.3131 13.3133ZM12.5 16.0002V16.5002H11.5V16.0002H12H12.5ZM9.33333 13.6668L9.68689 13.3133L12.3535 15.9799L12 16.3335L11.6464 16.687L8.97978 14.0204L9.33333 13.6668ZM12 16.3335L11.6464 15.9799L14.3131 13.3133L14.6667 13.6668L15.0202 14.0204L12.3535 16.6871L12 16.3335ZM6.5 8.00016V7.50016H8.5V8.00016V8.50016H6.5V8.00016ZM9.5 8.00016V7.50016H11.5V8.00016V8.50016H9.5V8.00016ZM12.5 8.00016V7.50016H14.5V8.00016V8.50016H12.5V8.00016ZM15.5 8.00016V7.50016H17.5V8.00016V8.50016H15.5V8.00016ZM12 10.5002H12.5V16.0002H12H11.5V10.5002H12Z" fill="currentColor"/></svg> `
// numCol.dataset["slot"] = "diff-hunk-separator-line-number"
// fragment.appendChild(numCol)
// const contentCol = document.createElement("div")
// contentCol.dataset["slot"] = "diff-hunk-separator-content"
// const span = document.createElement("span")
// span.dataset["slot"] = "diff-hunk-separator-content-span"
// span.textContent = `${hunkData.lines} unmodified lines`
// contentCol.appendChild(span)
// fragment.appendChild(contentCol)
// return fragment
// },
} as const
}
export const styleVariables = {
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
"--pjs-min-number-column-width": "4ch",
}

View file

@ -14,6 +14,7 @@ interface SelectDialogProps<T>
emptyMessage?: string
children: (item: T) => JSX.Element
onSelect?: (value: T | undefined) => void
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
@ -65,9 +66,12 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
setStore("mouseActive", false)
if (e.key === "Escape") return
const all = flat()
const selected = all.find((x) => others.key(x) === active())
props.onKeyEvent?.(e, selected)
if (e.key === "Enter") {
e.preventDefault()
const selected = flat().find((x) => others.key(x) === active())
if (selected) handleSelect(selected)
} else {
onKeyDown(e)

View file

@ -5,11 +5,14 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
/* [data-slot="session-review-container"] { */
/* height: 100%; */
/* } */
[data-slot="session-review-header"] {
position: sticky;
top: 0;

View file

@ -6,7 +6,7 @@ import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { For, Match, Show, Switch, type JSX, splitProps } from "solid-js"
import { For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileDiff } from "@opencode-ai/sdk"
import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
@ -15,6 +15,7 @@ export interface SessionReviewProps {
split?: boolean
class?: string
classList?: Record<string, boolean | undefined>
classes?: { root?: string; header?: string; container?: string }
actions?: JSX.Element
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
}
@ -39,17 +40,21 @@ export const SessionReview = (props: SessionReviewProps) => {
}
}
const [split] = splitProps(props, ["class", "classList"])
return (
<div
data-component="session-review"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
[props.class ?? ""]: !!props.class,
}}
>
<div data-slot="session-review-header">
<div
data-slot="session-review-header"
classList={{
[props.classes?.header ?? ""]: !!props.classes?.header,
}}
>
<div data-slot="session-review-title">Session changes</div>
<div data-slot="session-review-actions">
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
@ -61,47 +66,54 @@ export const SessionReview = (props: SessionReviewProps) => {
{props.actions}
</div>
</div>
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => (
<Accordion.Item forceMount value={diff.file} data-slot="session-review-accordion-item">
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
<div
data-slot="session-review-container"
classList={{
[props.classes?.container ?? ""]: !!props.classes?.container,
}}
>
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => (
<Accordion.Item forceMount value={diff.file} data-slot="session-review-accordion-item">
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
</div>
</div>
<div data-slot="session-review-trigger-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
<div data-slot="session-review-trigger-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<Diff
preloadedDiff={diff.preloaded}
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<Diff
preloadedDiff={diff.preloaded}
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</div>
)
}

View file

@ -31,6 +31,7 @@ export function SessionTurn(
const match = Binary.search(data.session, props.sessionID, (s) => s.id)
if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
const userMessages = createMemo(() =>
messages()
@ -116,7 +117,7 @@ export function SessionTurn(
</div>
</div>
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
<Message message={msg()} parts={parts()} sanitize={sanitizer()} />
</div>
{/* Summary */}
<Show when={completed()}>
@ -222,10 +223,11 @@ export function SessionTurn(
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
sanitize={sanitizer()}
/>
)
}
return <Message message={assistantMessage} parts={parts()} />
return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
}}
</For>
<Show when={error()}>

View file

@ -23,7 +23,7 @@ type Data = {
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: { data: Data }) => {
return props.data
init: (props: { data: Data; directory: string }) => {
return { ...props.data, directory: props.directory }
},
})

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.110",
"version": "1.0.114",
"private": true,
"type": "module",
"exports": {

View file

@ -0,0 +1,28 @@
import type { Part } from "@opencode-ai/sdk/client"
export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? ""
export const sanitizePart = (part: Part, remove: RegExp) => {
if (part.type === "text") {
part.text = sanitize(part.text, remove)
} else if (part.type === "reasoning") {
part.text = sanitize(part.text, remove)
} else 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, remove)
}
}
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, remove)
}
}
if ("error" in part.state) {
part.state.error = sanitize(part.state.error as string, remove)
}
}
}
return part
}

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.110",
"version": "1.0.114",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -45,6 +45,8 @@ Or you can set it up manually.
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
@ -129,3 +131,20 @@ Here are some examples of how you can use opencode in GitHub.
```
opencode will implement the requested change and commit it to the same PR.
- **Review specific code lines**
Leave a comment directly on code lines in the PR's "Files" tab. opencode automatically detects 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.

View file

@ -138,10 +138,6 @@ The free models:
- Grok Code Fast 1 is currently free on OpenCode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
:::tip
Subscription plans and a free tier are coming soon.
:::
<a href={email}>Contact us</a> if you have any questions.
---

View file

@ -215,6 +215,11 @@ site-search > button span {
.starlight-aside__content {
margin-top: 0;
}
@media (max-width: 768px) {
flex-direction: column;
gap: 6px;
}
}
site-search > button > kbd {
@ -368,6 +373,10 @@ nav.sidebar ul.top-level > li > details > summary .group-label > span {
.content-panel {
padding: 2rem 3rem !important;
@media (max-width: 768px) {
padding: 1rem 1.5rem !important;
}
}
.expressive-code {

View file

@ -35,7 +35,7 @@ if (!Script.preview) {
body: {
model: {
providerID: "opencode",
modelID: "kimi-k2",
modelID: "claude-haiku-4-5",
},
parts: [
{
@ -50,6 +50,7 @@ if (!Script.preview) {
- Do NOT include any information about code changes if they do not affect the user facing changes.
- For commits that are already well-written and descriptive, avoid rewording them. Simply capitalize the first letter, fix any misspellings, and ensure proper English grammar.
- DO NOT read any other commits than the ones listed above (THIS IS IMPORTANT TO AVOID DUPLICATING THINGS IN OUR CHANGELOG)
- If a commit was made and then reverted do not include it in the changelog. If the commits only include a revert but not the original commit, then include the revert in the changelog.
IMPORTANT: ONLY return a bulleted list of changes, do not include any other information. Do not include a preamble like "Based on my analysis..."

View file

@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.110",
"version": "1.0.114",
"publisher": "sst-dev",
"repository": {
"type": "git",